Compare commits
469 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9522b8aa8d | ||
|
|
fd881b4c61 | ||
|
|
424ca715c9 | ||
|
|
fe3c5f7a1b | ||
|
|
e601fc93d6 | ||
|
|
8f7076e4ef | ||
|
|
bcc2c38ab7 | ||
|
|
529d227e74 | ||
|
|
6b56efcf14 | ||
|
|
f23c8a9243 | ||
|
|
98660567e5 | ||
|
|
5bf2c27030 | ||
|
|
2f4b300274 | ||
|
|
da5852d330 | ||
|
|
81fa8544dd | ||
|
|
e407286c39 | ||
|
|
908f7b8985 | ||
|
|
98edbcaeb2 | ||
|
|
482b7e8017 | ||
|
|
acf295a259 | ||
|
|
a0667da4de | ||
|
|
f95ad86d5b | ||
|
|
72f03c75c8 | ||
|
|
14cb8f0014 | ||
|
|
0d57870311 | ||
|
|
fb6fca152f | ||
|
|
11a33455ce | ||
|
|
124076ed72 | ||
|
|
294f591152 | ||
|
|
f9414dc815 | ||
|
|
99c1e0e262 | ||
|
|
f6c344873d | ||
|
|
f8f768337e | ||
|
|
c64694e40f | ||
|
|
116791f29f | ||
|
|
69fd7e0b7d | ||
|
|
ac539d1f90 | ||
|
|
9774a17f7e | ||
|
|
1f7b0c076c | ||
|
|
51c6c37ea6 | ||
|
|
790de8cfa6 | ||
|
|
f49f2ecb6c | ||
|
|
9647fb358b | ||
|
|
e9e28ae26a | ||
|
|
60032c186d | ||
|
|
e7011ca0a5 | ||
|
|
0382113567 | ||
|
|
18fe633979 | ||
|
|
2d8b4d9c2a | ||
|
|
d4d6050862 | ||
|
|
6bed5265e2 | ||
|
|
a1b4fdf624 | ||
|
|
b9ea1573ea | ||
|
|
7a56545e9e | ||
|
|
2bf9b66af7 | ||
|
|
215a6faae9 | ||
|
|
61f37e0260 | ||
|
|
b2c434a1fd | ||
|
|
0d2bcbf25b | ||
|
|
a3d1838a8c | ||
|
|
692fb1a68c | ||
|
|
c71d915a4b | ||
|
|
a0b5dec8b9 | ||
|
|
e2f71b10ec | ||
|
|
743e4fce0b | ||
|
|
d97c608323 | ||
|
|
89baa3cabf | ||
|
|
d83712b093 | ||
|
|
806309fc33 | ||
|
|
70f6343a2c | ||
|
|
03dca869c8 | ||
|
|
84a10d4eb1 | ||
|
|
554a77fbca | ||
|
|
e12f5e41ff | ||
|
|
79ad003bc6 | ||
|
|
fc417022c9 | ||
|
|
f427d9f1c4 | ||
|
|
409f185f7e | ||
|
|
6b080455ff | ||
|
|
da726ecd15 | ||
|
|
a8f61878ca | ||
|
|
73e929f0cf | ||
|
|
60420c3e32 | ||
|
|
a02e933375 | ||
|
|
73df6519f0 | ||
|
|
ac3a34ff58 | ||
|
|
8d85b521c8 | ||
|
|
6d89010a1f | ||
|
|
8c85fdd7b5 | ||
|
|
cc535b0d0a | ||
|
|
d275b56dc1 | ||
|
|
ad1fc9b9c7 | ||
|
|
1ea6fb9300 | ||
|
|
9d96ab8f6a | ||
|
|
4f518d2315 | ||
|
|
7377476f97 | ||
|
|
a55bd4458c | ||
|
|
22cb7f7d8f | ||
|
|
7b46595503 | ||
|
|
aa30f6ef98 | ||
|
|
5107cd28c4 | ||
|
|
b537d73a55 | ||
|
|
9a5c49bd08 | ||
|
|
19cf204dc4 | ||
|
|
a75baba1f6 | ||
|
|
a2dd45fd69 | ||
|
|
b90cdb8686 | ||
|
|
16e79c6546 | ||
|
|
f3fbff291f | ||
|
|
f994088d38 | ||
|
|
091a49ff78 | ||
|
|
357313b555 | ||
|
|
3b64d8b0a5 | ||
|
|
6fa95d9f4f | ||
|
|
15ff5ede7e | ||
|
|
d89c826e18 | ||
|
|
5e485fb87e | ||
|
|
6b7e8bef1d | ||
|
|
5cb2312806 | ||
|
|
aa7543ad0c | ||
|
|
b6df80dcef | ||
|
|
c0ad75cc4d | ||
|
|
612002ec33 | ||
|
|
bb96b96e24 | ||
|
|
49fc63d422 | ||
|
|
350315fa56 | ||
|
|
fa859a3b5d | ||
|
|
b2f5110871 | ||
|
|
18d0cae6b0 | ||
|
|
a6f380444a | ||
|
|
631333f48e | ||
|
|
f279317105 | ||
|
|
f09b03338e | ||
|
|
6e011ae70e | ||
|
|
854fbe53be | ||
|
|
1ef252fbc2 | ||
|
|
aaebe01892 | ||
|
|
83efffb7f9 | ||
|
|
b89aa4488c | ||
|
|
2029148e7c | ||
|
|
8b33414c55 | ||
|
|
0e177a7a4c | ||
|
|
11fc6a61d5 | ||
|
|
ca5ab6edf5 | ||
|
|
bbefca71e5 | ||
|
|
001adcee62 | ||
|
|
4870cdd76f | ||
|
|
3dc8e87a27 | ||
|
|
1cd069df5e | ||
|
|
4dd1a960c1 | ||
|
|
2c8dc3e6a7 | ||
|
|
49f8b3b7f6 | ||
|
|
dd9dc34308 | ||
|
|
a8b41945d0 | ||
|
|
fa776c34de | ||
|
|
a3a4bbbb83 | ||
|
|
52e1276c8d | ||
|
|
241be5eaee | ||
|
|
a32903218e | ||
|
|
6620fc8570 | ||
|
|
388a4d93e4 | ||
|
|
85898d3531 | ||
|
|
1f2e1691f9 | ||
|
|
2693f5f496 | ||
|
|
854f7d7f2e | ||
|
|
1cac67d4c5 | ||
|
|
72970720d2 | ||
|
|
b5c75caea0 | ||
|
|
f421fd771f | ||
|
|
748f3a3a4f | ||
|
|
59ccf6181e | ||
|
|
c7f5e6b5b0 | ||
|
|
10f99673c5 | ||
|
|
aff5e8f44d | ||
|
|
7db5a48e35 | ||
|
|
fe73e76fe9 | ||
|
|
faa22feebf | ||
|
|
9773c02e7d | ||
|
|
628902bb70 | ||
|
|
c2e981b35a | ||
|
|
2f40eeb49f | ||
|
|
cfb2501576 | ||
|
|
4057906b2c | ||
|
|
93fe97b94d | ||
|
|
aa2df465a0 | ||
|
|
350438b2c4 | ||
|
|
075499b695 | ||
|
|
b361adbe30 | ||
|
|
c448322367 | ||
|
|
b6d4b58f86 | ||
|
|
bbb00ff36f | ||
|
|
07dc823528 | ||
|
|
b9ae97e5ec | ||
|
|
dfafbdd882 | ||
|
|
35d0227862 | ||
|
|
c8842cc71f | ||
|
|
620974217a | ||
|
|
392d47852d | ||
|
|
f714cd66f7 | ||
|
|
425e196dfc | ||
|
|
1ffe617287 | ||
|
|
ea93d197ab | ||
|
|
37c569a976 | ||
|
|
7a189bd5e5 | ||
|
|
d3876eb7b0 | ||
|
|
64cb848a37 | ||
|
|
162e51a0af | ||
|
|
59b9991a2c | ||
|
|
97128673ff | ||
|
|
fdac444aed | ||
|
|
c656903772 | ||
|
|
61b5ab8a49 | ||
|
|
550df1be89 | ||
|
|
99c14533a5 | ||
|
|
b759fdb6e3 | ||
|
|
374e1f65c6 | ||
|
|
3d6526de3e | ||
|
|
8f43c7d3d8 | ||
|
|
e5b7ad5be2 | ||
|
|
8227ce1158 | ||
|
|
35b80178ed | ||
|
|
80b0dba9fe | ||
|
|
a5497dc215 | ||
|
|
964fb5d251 | ||
|
|
e24ee05337 | ||
|
|
c6858d505f | ||
|
|
0ea1e47176 | ||
|
|
5355b91f37 | ||
|
|
86e7eb1087 | ||
|
|
043d89c03b | ||
|
|
1cbad1057d | ||
|
|
d906771b18 | ||
|
|
76ef9c0388 | ||
|
|
262d96f8d7 | ||
|
|
41b7466325 | ||
|
|
76f2c5f9fc | ||
|
|
e5a1fc9e2d | ||
|
|
11f9e260ed | ||
|
|
e209bdec65 | ||
|
|
6432851a78 | ||
|
|
31fb22a7c3 | ||
|
|
bc47e30ad3 | ||
|
|
58cf7c720f | ||
|
|
48bf73de80 | ||
|
|
76a3f4e86c | ||
|
|
3a760282f1 | ||
|
|
71affc0239 | ||
|
|
3b95d23d23 | ||
|
|
8cd5345f8c | ||
|
|
fda393b5e1 | ||
|
|
264f9f84ed | ||
|
|
1d73760901 | ||
|
|
03a13df47b | ||
|
|
5160f22d91 | ||
|
|
3bbc2bf986 | ||
|
|
90f68da42f | ||
|
|
f37438b7a7 | ||
|
|
826d124a5f | ||
|
|
c162fd178b | ||
|
|
9b92e48a6e | ||
|
|
5b5c15b7f3 | ||
|
|
6e9cd4c11b | ||
|
|
8c03c73b28 | ||
|
|
2c10ceba5b | ||
|
|
2a3110cd3d | ||
|
|
924ea435b1 | ||
|
|
0e4a389910 | ||
|
|
720dc14ecf | ||
|
|
51f5f0b82d | ||
|
|
f380a6f8cf | ||
|
|
437a033739 | ||
|
|
2b77e4d292 | ||
|
|
0e104ee936 | ||
|
|
a820bf7bd0 | ||
|
|
09fdec8fbd | ||
|
|
80f6d733b9 | ||
|
|
838345ba46 | ||
|
|
c2378d33b4 | ||
|
|
95575bc040 | ||
|
|
2926871eab | ||
|
|
5b05ea285c | ||
|
|
48a2e6881f | ||
|
|
edbeaa2f77 | ||
|
|
48a85a620d | ||
|
|
cc8db71ecf | ||
|
|
e4573f74a4 | ||
|
|
8cff72cf59 | ||
|
|
73a9de7708 | ||
|
|
104318ab8c | ||
|
|
8ec4659949 | ||
|
|
ffa8ff8427 | ||
|
|
4ef1339ba2 | ||
|
|
3702efdcb3 | ||
|
|
bbdfbe1ab7 | ||
|
|
cc1fc5c269 | ||
|
|
bc32fa64bf | ||
|
|
cfc7de9c77 | ||
|
|
945ab30373 | ||
|
|
494125227f | ||
|
|
a4919b06f9 | ||
|
|
790ba406bf | ||
|
|
e0367056bd | ||
|
|
4bf0dc192c | ||
|
|
4575a0ddce | ||
|
|
837cbff092 | ||
|
|
4108047644 | ||
|
|
347cf4f67d | ||
|
|
7f9344a556 | ||
|
|
8907b692c1 | ||
|
|
6c0d5cb601 | ||
|
|
5c69a146f6 | ||
|
|
de75ae5b9e | ||
|
|
9c9e2c6a62 | ||
|
|
917c18a423 | ||
|
|
aac81c2fba | ||
|
|
9e82839fb7 | ||
|
|
ae2f74777b | ||
|
|
4c5d67606f | ||
|
|
0d2a0f91c7 | ||
|
|
b65fa3e2c7 | ||
|
|
e87d2e1218 | ||
|
|
00ae320b51 | ||
|
|
3d46d24038 | ||
|
|
8b04484ff7 | ||
|
|
7f9f3f683b | ||
|
|
fb2ce06621 | ||
|
|
89f5e87601 | ||
|
|
e124755363 | ||
|
|
d0ccbe2786 | ||
|
|
25dec602b8 | ||
|
|
bbf7007250 | ||
|
|
2b4f8ff00d | ||
|
|
b467b58ee7 | ||
|
|
facefeddae | ||
|
|
141bdb1307 | ||
|
|
b53da61e7c | ||
|
|
ede93323af | ||
|
|
8ccf79175a | ||
|
|
9fa330a0a0 | ||
|
|
3693857960 | ||
|
|
c5f97e8bb0 | ||
|
|
2cb7b4d1ea | ||
|
|
6247cece94 | ||
|
|
417f5c3610 | ||
|
|
3e6f3bd807 | ||
|
|
6346c7fe9b | ||
|
|
11c5a3f050 | ||
|
|
10645b1b94 | ||
|
|
e106dcd76a | ||
|
|
cb30a57a59 | ||
|
|
98da4c0011 | ||
|
|
fc0c316ef2 | ||
|
|
eaf363635e | ||
|
|
b91aa0668f | ||
|
|
53c2f5885a | ||
|
|
5717f77e00 | ||
|
|
3f8dfdd938 | ||
|
|
9e1fbedc4d | ||
|
|
f9eb588d4c | ||
|
|
181ee43107 | ||
|
|
cc30bc1897 | ||
|
|
1232b30e29 | ||
|
|
03aae46880 | ||
|
|
25ce947df5 | ||
|
|
b8f486d8e4 | ||
|
|
6305ff7410 | ||
|
|
b2941894cd | ||
|
|
83056519ec | ||
|
|
3cdfbbac56 | ||
|
|
f61e85c2d6 | ||
|
|
217ebf8c33 | ||
|
|
b32114f2f2 | ||
|
|
6209cdbe0e | ||
|
|
afde81ef3e | ||
|
|
fbbd71e7f2 | ||
|
|
54cf168b4d | ||
|
|
c25b14976c | ||
|
|
39c68075fb | ||
|
|
ce15958a9a | ||
|
|
8d06defbcb | ||
|
|
0d807a37d6 | ||
|
|
9a0a2d84da | ||
|
|
29e2be47d0 | ||
|
|
b2e1f66dbb | ||
|
|
bfe9ee457d | ||
|
|
a034b70449 | ||
|
|
4226654772 | ||
|
|
4ea8ab08a3 | ||
|
|
702fc120af | ||
|
|
9453084481 | ||
|
|
c6dbbc4135 | ||
|
|
ddc53bcb6f | ||
|
|
e50509ac45 | ||
|
|
2ddba469b2 | ||
|
|
4e1b2ccbaa | ||
|
|
e0b8a2400a | ||
|
|
151ba569a7 | ||
|
|
2cb755fe44 | ||
|
|
eeef49fd19 | ||
|
|
6b2626120c | ||
|
|
e77ab26516 | ||
|
|
dbaf6c6ce2 | ||
|
|
5e295f9f1e | ||
|
|
8d3b655517 | ||
|
|
64cefd52c8 | ||
|
|
edb92ed0a5 | ||
|
|
a8513cc0fa | ||
|
|
20d4ce6632 | ||
|
|
d8c3ce30ca | ||
|
|
d894de0784 | ||
|
|
572bd19df6 | ||
|
|
4fd399eae9 | ||
|
|
f7f55710d1 | ||
|
|
18815b97ce | ||
|
|
c4fce32a6a | ||
|
|
9ed5f43ea1 | ||
|
|
232bce0a2d | ||
|
|
27f975f3c5 | ||
|
|
5b834b4396 | ||
|
|
52b46e2b3e | ||
|
|
044fb72da9 | ||
|
|
0cf911bcdd | ||
|
|
829512dd13 | ||
|
|
fa886c71b8 | ||
|
|
21191bdc50 | ||
|
|
1bf2fe16a2 | ||
|
|
c35543af92 | ||
|
|
9bb71bd066 | ||
|
|
f24e4f291d | ||
|
|
32ab9a9d32 | ||
|
|
8b520dec48 | ||
|
|
70c539ac4d | ||
|
|
610651066a | ||
|
|
aaa750dbbc | ||
|
|
a518ee83cc | ||
|
|
de84b5113c | ||
|
|
2ea7847d4f | ||
|
|
0650fca1cf | ||
|
|
1b5bd0d379 | ||
|
|
5b6f796606 | ||
|
|
9d6a755486 | ||
|
|
9470654394 | ||
|
|
28feadd6c5 | ||
|
|
af3ed04b7f | ||
|
|
2da99673cd | ||
|
|
476adcb029 | ||
|
|
b2c8f87276 | ||
|
|
bd4e132709 | ||
|
|
fa8fcf8761 | ||
|
|
8e92b53d9f | ||
|
|
6f90bd3db0 | ||
|
|
a261d8b754 | ||
|
|
9643b7ed1b | ||
|
|
ec191d51bc | ||
|
|
a5452e4b15 | ||
|
|
8522802f85 | ||
|
|
6f2e3afe07 | ||
|
|
70dfb41d95 | ||
|
|
34f04828c5 | ||
|
|
a78799973d | ||
|
|
1797148951 | ||
|
|
67caa89591 | ||
|
|
e3a88e9f5b | ||
|
|
e9910c9b95 | ||
|
|
45e058bdc1 | ||
|
|
9af5404921 | ||
|
|
5c4ca1b699 | ||
|
|
b6827736db | ||
|
|
aada3f3979 |
101
CHANGES
101
CHANGES
@@ -3021,3 +3021,104 @@
|
||||
* 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
|
||||
|
||||
[9.0.10]
|
||||
* Only enable LdapServer input fields if feature is enabled
|
||||
* Require display name to not be empty when changed from the profile view
|
||||
* access control: fix spacing
|
||||
* storage: pass limits object to backend
|
||||
|
||||
[9.0.11]
|
||||
* mail: fix count indicator when loading
|
||||
* mailinglist: fix search on name
|
||||
* backup site: fix migration with mixed formats
|
||||
|
||||
[9.0.12]
|
||||
* eventlog: always fetch enough event logs to fill the screen
|
||||
* mail: check for outbound ipv6 connectivity
|
||||
* store actual appId not oidc clientId for log in events
|
||||
* Add english labels for eventlog filtering
|
||||
* mail: when deferred, show reason
|
||||
* mail: prefer ipv4 for outbound mail
|
||||
|
||||
[9.0.13]
|
||||
* Fix issue where footer/name can break templates
|
||||
* rsync: bump empty dir limit to 80k
|
||||
* nginx: do not log query params
|
||||
* Fetch mailbox usage in the background to not delay mailbox listing
|
||||
* cloudron-support: add --check-services and add it to troubleshoot
|
||||
* Do not poll services if they are in recoveryMode
|
||||
* restore/import: fix issue where prefix was empty
|
||||
|
||||
[9.0.14]
|
||||
* Also use a temporary SSH identity file for optimized ssh remote rm -rf
|
||||
* app search: title is optional manifest
|
||||
* network: detect default ipv6 interface when no ipv4 interface
|
||||
* mail status: fix rbl display
|
||||
* platform: show any container upgrade errors in the UI
|
||||
* users: make remove 2fa separate dialog
|
||||
* mandatory 2fa: show undismissable dialog and warning
|
||||
* restore: validate ipv6 config
|
||||
* location: use the domain where app is installed as default
|
||||
* s3: remove leading slash in CopySource
|
||||
* gcs: fix copy operation
|
||||
* restore: fix crash when trying to mount fs volumes
|
||||
* restore: teardown pseudo backup site
|
||||
* oidc: add separate jwks key route for cloudflare access
|
||||
|
||||
[9.0.15]
|
||||
* sshfs: Use unique temporary ssh key file for each ssh remote operation
|
||||
|
||||
[9.0.16]
|
||||
* Update mongodb to 7.0.28 (also fixes mongobleed)
|
||||
* docker: do not use auth for appstore images
|
||||
* backup: add synology C2
|
||||
* mail: update haraka to 3.1.2
|
||||
* csp/robots: add common patterns
|
||||
|
||||
[9.0.17]
|
||||
* Update mongodb to 7.0.28 (also fixes mongobleed)
|
||||
* UI: add favorites for list views
|
||||
* UI: add collapsible sidebar
|
||||
* docker: do not use auth for appstore images
|
||||
* backup: add synology C2
|
||||
* mail: update haraka to 3.1.2
|
||||
* csp/robots: add common patterns
|
||||
|
||||
[9.0.18]
|
||||
* ami & cloud images: fix setup
|
||||
|
||||
|
||||
99
dashboard/TRANSLATIONS.md
Normal file
99
dashboard/TRANSLATIONS.md
Normal 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 you’re 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 ..."
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> OpenID Error</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.errorMessage = `<%- errorMessage %>`;
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
errorMessage: errorMessage,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> OpenID Access Denied</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.submitUrl = '<%- submitUrl %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
submitUrl: submitUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,13 +4,14 @@
|
||||
<title><%= name %> Login</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.note = '<%- note %>';
|
||||
window.cloudron.submitUrl = '<%- submitUrl %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
iconUrl: iconUrl,
|
||||
name: name,
|
||||
note: note,
|
||||
submitUrl: submitUrl,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
715
dashboard/package-lock.json
generated
715
dashboard/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,26 +7,26 @@
|
||||
},
|
||||
"type": "module",
|
||||
"dependencies": {
|
||||
"@cloudron/pankow": "^3.5.5",
|
||||
"@cloudron/pankow": "^3.6.4",
|
||||
"@fontsource/inter": "^5.2.8",
|
||||
"@fortawesome/fontawesome-free": "^7.1.0",
|
||||
"@vitejs/plugin-vue": "^6.0.1",
|
||||
"@vitejs/plugin-vue": "^6.0.2",
|
||||
"@xterm/addon-attach": "^0.11.0",
|
||||
"@xterm/addon-fit": "^0.10.0",
|
||||
"@xterm/xterm": "^5.5.0",
|
||||
"anser": "^2.3.2",
|
||||
"anser": "^2.3.3",
|
||||
"async": "^3.2.6",
|
||||
"chart.js": "^4.5.1",
|
||||
"chartjs-plugin-annotation": "^3.1.0",
|
||||
"eslint": "^9.38.0",
|
||||
"eslint-plugin-vue": "^10.5.1",
|
||||
"marked": "^16.4.1",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-plugin-vue": "^10.6.2",
|
||||
"marked": "^17.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.6.0",
|
||||
"vite": "^7.1.10",
|
||||
"vite": "^7.2.7",
|
||||
"vite-plugin-singlefile": "^2.3.0",
|
||||
"vue": "^3.5.22",
|
||||
"vue-i18n": "^11.1.12",
|
||||
"vue": "^3.5.25",
|
||||
"vue-i18n": "^11.2.2",
|
||||
"vue-router": "^4.6.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<title><%= name %> Password Reset</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -4,12 +4,13 @@
|
||||
<title><%= name %> Login</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.iconUrl = '<%- iconUrl %>';
|
||||
window.cloudron.loginUrl = '<%- loginUrl %>';
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron.apiOrigin = `<%= apiOrigin %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
iconUrl: iconUrl,
|
||||
loginUrl: loginUrl,
|
||||
language: language,
|
||||
apiOrigin: apiOrigin
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -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.",
|
||||
@@ -36,9 +36,6 @@
|
||||
"username": "Brugernavn",
|
||||
"displayName": "Vis navn",
|
||||
"actions": "Foranstaltninger",
|
||||
"table": {
|
||||
"date": "Dato"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Genstart",
|
||||
"logs": "Logfiler"
|
||||
@@ -240,7 +237,6 @@
|
||||
"newPasswordRepeat": "Gentag ny adgangskode"
|
||||
},
|
||||
"enable2FA": {
|
||||
"description": "Din Cloudron-administrator har krævet, at alle medlemmer skal aktivere to-faktor-autentifikation. Du vil ikke kunne få adgang til instrumentbrættet, før du aktiverer 2FA.",
|
||||
"authenticatorAppDescription": "Brug Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP-autenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) eller en lignende TOTP-app til at scanne hemmeligheden.",
|
||||
"title": "Aktiver to-faktor-autentifikation",
|
||||
"token": "Token",
|
||||
@@ -259,15 +255,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",
|
||||
@@ -330,16 +324,13 @@
|
||||
"backupNow": "Backup nu"
|
||||
},
|
||||
"backupDetails": {
|
||||
"list": "Referencer til sikkerhedskopier af {{ appCount }} apps",
|
||||
"title": "Oplysninger om sikkerhedskopiering",
|
||||
"id": "Id",
|
||||
"date": "Dato",
|
||||
"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 +366,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 +530,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"
|
||||
@@ -592,7 +581,6 @@
|
||||
"setupAction": "Oprettelse af konto",
|
||||
"subscription": "Abonnement",
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "Annulleret og slutter den",
|
||||
"subscriptionChangeAction": "Ændre abonnement",
|
||||
"subscriptionReactivateAction": "Genaktivere abonnementet",
|
||||
"emailNotVerified": "E-mail endnu ikke bekræftet"
|
||||
@@ -636,9 +624,7 @@
|
||||
"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",
|
||||
"editTitle": "Konfigurer {{ domain }}",
|
||||
"domain": "Domæne",
|
||||
@@ -704,11 +690,7 @@
|
||||
"title": "Synkronisering af DNS",
|
||||
"description": "Dette vil reprovisionere app- og e-mail-DNS-poster på tværs af alle domæner.",
|
||||
"syncAction": "Synkronisering af DNS"
|
||||
},
|
||||
"domainWellKnown": {
|
||||
"title": "Well-Known locations på {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Indstil well-known lokationer"
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"markAllAsRead": "Markér alle som læst",
|
||||
@@ -883,7 +865,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 +888,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",
|
||||
@@ -1062,7 +1039,6 @@
|
||||
"description": "Sikkerhedskopier er komplette snapshots af appen. Du kan bruge app-backups til at gendanne eller klone denne app.",
|
||||
"downloadBackupTooltip": "Download Sikkerhedskopi",
|
||||
"title": "Sikkerhedskopiering",
|
||||
"time": "Oprettet på",
|
||||
"downloadConfigTooltip": "Download Backup-konfiguration",
|
||||
"cloneTooltip": "Klon fra denne sikkerhedskopi",
|
||||
"restoreTooltip": "Gendan til denne sikkerhedskopi",
|
||||
@@ -1167,9 +1143,7 @@
|
||||
"saveAction": "Gem"
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"txtPlaceholder": "Lad den være tom for at tillade alle robotter at indeksere denne app",
|
||||
"disableIndexingAction": "Deaktivere indeksering"
|
||||
"title": "Robots.txt"
|
||||
},
|
||||
"hstsPreload": "Aktiver HSTS-forudindlæsning for dette websted og alle underdomæner"
|
||||
},
|
||||
@@ -1180,7 +1154,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"
|
||||
@@ -1253,7 +1226,6 @@
|
||||
"description": "Kontakt din serveradministrator for at få et nyt invitationslink.",
|
||||
"title": "Ugyldigt eller udløbet inviteringslink"
|
||||
},
|
||||
"welcomeTo": "Velkommen til",
|
||||
"description": "Opret venligst din konto",
|
||||
"username": "Brugernavn",
|
||||
"fullName": "Fuldt navn",
|
||||
@@ -1276,7 +1248,6 @@
|
||||
"welcomeTo": "Velkommen til <%= cloudronName %>!",
|
||||
"salutation": "Hej <%= user %>,",
|
||||
"inviteLinkAction": "Kom i gang",
|
||||
"expireNote": "Bemærk venligst, at linket til invitationen udløber om 7 dage.",
|
||||
"inviteLinkActionText": "Følg linket for at komme i gang: <%- inviteLink %>",
|
||||
"subject": "Velkommen til <%= cloudron %>"
|
||||
},
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"title": "Du hast bisher noch keinen Zugriff auf Apps."
|
||||
},
|
||||
"noApps": {
|
||||
"description": "Installiere welche aus dem <a href=\"{{ appStoreLink }}\">App Store</a>",
|
||||
"description": "Installiere welche aus dem <a href=\"{{ appStoreLink }}\">App Store</a>.",
|
||||
"title": "Es sind noch keine Apps installiert!"
|
||||
},
|
||||
"searchPlaceholder": "Suche Apps",
|
||||
@@ -39,17 +39,19 @@
|
||||
"remove": "Entfernen",
|
||||
"edit": "Bearbeiten",
|
||||
"add": "Hinzufügen",
|
||||
"next": "Weiter"
|
||||
"next": "Weiter",
|
||||
"configure": "Konfigurieren",
|
||||
"restart": "Neu starten",
|
||||
"reset": "Zurücksetzen"
|
||||
},
|
||||
"table": {
|
||||
"date": "Datum",
|
||||
"version": "Version"
|
||||
},
|
||||
"actions": "Aktionen",
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Jetzt neustarten",
|
||||
"description": "Einen Neustart verwenden, um Sicherheitsupdates anzuwenden oder wenn ein unerwartetes Verhalten festgestellt wurde. Alle Anwendungen und Dienste, die derzeit auf dieser Cloudron-Instanz laufen, werden automatisch gestartet, wenn der Neustart abgeschlossen ist.",
|
||||
"title": "Den Server wirklich neustarten?"
|
||||
"description": "Alle Apps und Dienste werden automatisch neu gestartet.<br/><br/>Server jetzt neustarten?",
|
||||
"title": "Server neu starten"
|
||||
},
|
||||
"searchPlaceholder": "Suche",
|
||||
"multiselect": {
|
||||
@@ -61,30 +63,33 @@
|
||||
"users": "User",
|
||||
"groups": "Gruppen"
|
||||
},
|
||||
"loadingPlaceholder": "Laden"
|
||||
"loadingPlaceholder": "Laden",
|
||||
"platform": {
|
||||
"startupFailed": "Plattform-Start fehlgeschlagen"
|
||||
}
|
||||
},
|
||||
"network": {
|
||||
"title": "Netzwerk",
|
||||
"dyndns": {
|
||||
"title": "Dynamischer DNS",
|
||||
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft."
|
||||
"description": "DNS-Einträge mit der sich ändernden öffentlichen IP-Adresse synchron halten. Nützlich, wenn Cloudron in einem Netzwerk mit einer häufig wechselnden IP läuft, z. B. bei einer Heimverbindung."
|
||||
},
|
||||
"configureIp": {
|
||||
"title": "IPv4-Anbieter konfigurieren",
|
||||
"title": "IPv4 konfigurieren",
|
||||
"providerGenericDescription": "Die öffentliche IP-Adresse des Servers wird automatisch erkannt."
|
||||
},
|
||||
"firewall": {
|
||||
"configure": {
|
||||
"title": "Konfiguration der Firewall",
|
||||
"blocklistPlaceholder": "Mehrere IP-Adressen oder Subnetze jeweils in eine neue Zeile",
|
||||
"description": "Die hier aufgelisteten IP-Adressen werden durch die Firewall geblockt. Sie können keine Verbindung zum Server herstellen. Auch nicht zum Mailserver, zum Dashboard und zu allen anderen Anwendungen. Vorsicht: Fehlkonfiguration kann den Server unerreichbar machen."
|
||||
"description": "Die hier aufgelisteten IP-Adressen werden durch die Firewall geblockt. Sie können keine Verbindung zum Server herstellen. Auch nicht zum Mailserver, zum Dashboard und zu allen anderen Anwendungen. Fehlkonfiguration kann den Server unerreichbar machen."
|
||||
},
|
||||
"title": "Firewall",
|
||||
"blockedIpRanges": "Gesperrte IPs und Bereiche",
|
||||
"blocklist": "{{ blockCount }} IP(s) sind gesperrt"
|
||||
},
|
||||
"ip": {
|
||||
"description": "Diese IPv4-Adresse wird beim Einrichten von DNS A Einträgen verwendet.",
|
||||
"description": "IPv4-Adresse für das Einrichten von DNS A Einträgen.",
|
||||
"provider": "Anbieter",
|
||||
"interface": "Name der Netzwerkschnittstelle",
|
||||
"configure": "Konfigurieren",
|
||||
@@ -97,7 +102,7 @@
|
||||
"title": "IPv6 konfigurieren"
|
||||
},
|
||||
"ipv6": {
|
||||
"description": "Diese IPv6-Adresse wird beim Einrichten von AAAA DNS-Einträge verwendet.",
|
||||
"description": "Diese IPv6-Adresse wird beim Einrichten von DNS AAAA Einträgen verwendet.",
|
||||
"title": "IPv6",
|
||||
"address": "IPv6 Adresse"
|
||||
},
|
||||
@@ -105,7 +110,7 @@
|
||||
"address": "IPv4 Adresse"
|
||||
},
|
||||
"trustedIps": {
|
||||
"description": "HTTP header, von übereinstimmenden IP-Adressen, wird vertraut",
|
||||
"description": "HTTP header, von übereinstimmenden IP-Adressen, wird vertraut.",
|
||||
"summary": "{{ trustCount }} IPs vertrauen",
|
||||
"title": "Konfiguriere vertrauenswürdige IPs"
|
||||
},
|
||||
@@ -115,16 +120,16 @@
|
||||
"title": "Einstellungen",
|
||||
"language": {
|
||||
"title": "Sprache",
|
||||
"description": "Legt die Standardsprache für Cloudron und System-E-Mails fest (z. B. Einladungen, Passwortzurücksetzungen). Benutzer können die Sprache des Dashboards in ihrem Profil überschreiben."
|
||||
"description": "Standardsprache für Cloudron und System-E-Mails (z. B. Einladungen, Passwortzurücksetzungen). Benutzer können die Sprache des Dashboards in ihrem Profil überschreiben."
|
||||
},
|
||||
"updates": {
|
||||
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
|
||||
"title": "Aktualisierungen",
|
||||
"stopUpdateAction": "Aktualisierung abbrechen",
|
||||
"updateAvailableAction": "Aktualisierung verfügbar",
|
||||
"description": "Platform and App Updates werden automatisch, basierend auf dem Zeitplan in der <a href=\"/#/system-locale\">Systemzeitzone</a> erstellt.",
|
||||
"description": "Plattform und App-Aktualisierungen werden automatisch, basierend auf dem Zeitplan in der <a href=\"/#/system-settings\">Systemzeitzone</a> ausgeführt.",
|
||||
"disabled": "Deaktiviert",
|
||||
"schedule": "Zeitplan",
|
||||
"schedule": "Aktualisierungszeitplan",
|
||||
"onLatest": "neueste"
|
||||
},
|
||||
"appstoreAccount": {
|
||||
@@ -135,13 +140,12 @@
|
||||
"setupAction": "Konto einrichten",
|
||||
"subscription": "Abonnement-Typ",
|
||||
"subscriptionReactivateAction": "Abonnement reaktivieren",
|
||||
"subscriptionEndsAt": "Gekündigt - endet am",
|
||||
"emailNotVerified": "E-Mail noch nicht verifiziert",
|
||||
"account": "Konto",
|
||||
"unlinkAction": "Konto trennen",
|
||||
"unlinkDialog": {
|
||||
"title": "Cloudron.io-Konto trennen",
|
||||
"description": "Dies wird das Cloudron vom aktuellen Cloudron.io-Konto trennen. Es kann dann mit einem anderen Konto <a href=\"https://docs.cloudron.io/appstore/#account-change\" target=\"_blank\">verknüpft</a> werden."
|
||||
"description": "Trennen Sie dieses Cloudron vom aktuellen Cloudron.io-Konto. Es kann dann mit einem anderen Konto <a href=\"https://docs.cloudron.io/appstore/#account-change\" target=\"_blank\">verknüpft</a> werden."
|
||||
}
|
||||
},
|
||||
"updateScheduleDialog": {
|
||||
@@ -154,17 +158,18 @@
|
||||
"title": "Automatische Aktualisierung konfigurieren"
|
||||
},
|
||||
"timezone": {
|
||||
"description": "Die konfigurierte Zeitzone ist <b>{{ timeZone }}</b>. Diese Einstellung wird für die Planung von Sicherungs- und Aktualisierungsaufgaben verwendet.",
|
||||
"description": "Dient dazu, Datensicherungen und Updates zu planen. UI-Zeitstempel folgen immer der Zeitzone des Browsers.",
|
||||
"title": "Systemzeitzone"
|
||||
},
|
||||
"updateDialog": {
|
||||
"title": "Cloudron aktualsieren auf",
|
||||
"title": "Cloudron aktualisieren",
|
||||
"blockingApps": "Die folgenden Anwendungen blockieren die Aktualisierung, weil sie laufende Vorgänge haben:",
|
||||
"blockingAppsInfo": "Warten, bis die oben genannten Vorgänge abgeschlossen sind.",
|
||||
"unstableWarning": "Dieses Update ist eine Vorabversion und gilt noch nicht als stabil. Vorsicht: Aktualisierung auf eigene Gefahr.",
|
||||
"changes": "Änderungen",
|
||||
"skipBackupCheckbox": "Backup überspringen",
|
||||
"updateAction": "Aktualisierung"
|
||||
"updateAction": "Aktualisierung",
|
||||
"updateAvailable": "Cloudron {{ newVersion }} ist verfügbar"
|
||||
},
|
||||
"registryConfig": {
|
||||
"provider": "Docker Registry Anbieter",
|
||||
@@ -177,8 +182,8 @@
|
||||
"bindPassword": "Bind Passwort (optional)",
|
||||
"bindUsername": "Bind DN/Username (optional)",
|
||||
"configureAction": "Einrichten",
|
||||
"syncAction": "Synchronisieren",
|
||||
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden",
|
||||
"syncAction": "Jetzt synchronisieren",
|
||||
"autocreateUsersOnLogin": "Benutzer beim Login automatisch erstellen",
|
||||
"auth": "Authentifizierung",
|
||||
"groupnameField": "Gruppennamen Feld",
|
||||
"groupFilter": "Gruppenfilter",
|
||||
@@ -190,15 +195,16 @@
|
||||
"acceptSelfSignedCert": "Selbst signiertes Zertifikat akzeptieren",
|
||||
"server": "Server URL",
|
||||
"provider": "Anbieter",
|
||||
"noopInfo": "LDAP Authentifizierung ist nicht konfiguriert.",
|
||||
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Die Synchronisierung läuft automatisch, kann aber auch manuell gestartet werden.",
|
||||
"noopInfo": "Kein externes Verzeichnis konfiguriert.",
|
||||
"description": "Synchronisieren und Authentifizieren von Benutzern und Gruppen von einem externen LDAP- oder Active Directory-Server. Die Synchronisierung erfolgt alle 4 Stunden automatisch.",
|
||||
"title": "Verbinde ein externes Verzeichnis",
|
||||
"disableWarning": "Die Authentifizierungsmethode von allen Usern wird auf die lokale Datenbank zurückgesetzt."
|
||||
},
|
||||
"settings": {
|
||||
"saveAction": "Speichern",
|
||||
"require2FACheckbox": "User müssen Zwei-Faktor-Authentifizierung (2FA) aktivieren",
|
||||
"allowProfileEditCheckbox": "Erlaube Usern ihren Namen und E-Mail-Adresse zu ändern"
|
||||
"allowProfileEditCheckbox": "Erlaube Usern ihren Namen und E-Mail-Adresse zu ändern",
|
||||
"title": "Einstellungen"
|
||||
},
|
||||
"groups": {
|
||||
"externalLdapTooltip": "Aus externem LDAP Verzeichnis",
|
||||
@@ -237,80 +243,83 @@
|
||||
"description": "Der folgende Link zum Passwort wiederherstellen wurde an {{ email }} gesendet:",
|
||||
"title": "Passwort zurücksetzen für {{ username }}",
|
||||
"reset2FAAction": "2FA zurücksetzen",
|
||||
"sendAction": "Mail senden",
|
||||
"sendAction": "E-Mail senden",
|
||||
"descriptionEmail": "Link zum Zurücksetzen des Passworts senden",
|
||||
"descriptionLink": "Link zum Zurücksetzen des Passworts kopieren"
|
||||
},
|
||||
"deleteGroupDialog": {
|
||||
"deleteAction": "Löschen",
|
||||
"description": "Diese Gruppe hat {{ memberCount }} Mitglied(er). Möchten Sie diese Gruppe wirklich entfernen?",
|
||||
"title": "Gruppe {{ name }} löschen"
|
||||
"description": "Diese Gruppe hat {{ memberCount }} Mitglied(er). Gruppe \"{{ name }}\" entfernen?",
|
||||
"title": "Gruppe löschen"
|
||||
},
|
||||
"editGroupDialog": {
|
||||
"externalLdapWarning": "Die Gruppe wird in einem externen LDAP-Server verwaltet.",
|
||||
"title": "Gruppe {{ name }} bearbeiten"
|
||||
"title": "Gruppe bearbeiten"
|
||||
},
|
||||
"group": {
|
||||
"addGroupAction": "Gruppe hinzufügen",
|
||||
"addGroupAction": "Hinzufügen",
|
||||
"users": "User",
|
||||
"name": "Name"
|
||||
"name": "Name",
|
||||
"allowedApps": "Zugelassene Apps"
|
||||
},
|
||||
"addGroupDialog": {
|
||||
"title": "Gruppe hinzufügen"
|
||||
},
|
||||
"editUserDialog": {
|
||||
"externalLdapWarning": "User wird in einem externen LDAP-Server verwaltet.",
|
||||
"title": "User {{ username }} bearbeiten"
|
||||
"title": "User bearbeiten"
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
"deleteAction": "Löschen",
|
||||
"description": "Gelöschte User können nicht mehr auf das Dashboard zugreifen und sich nicht in eine der Anwendungen einloggen. Hinweis: Userdaten innerhalb der Anwendungen werden nicht gelöscht.",
|
||||
"title": "User {{ username }} löschen"
|
||||
"description": "Gelöschte User können nicht mehr auf das Dashboard zugreifen und sich nicht in eine der Anwendungen einloggen. Hinweis: Userdaten innerhalb der Anwendungen werden nicht gelöscht.<br/><br/>User \"{{ username }}\" löschen?",
|
||||
"title": "User löschen"
|
||||
},
|
||||
"user": {
|
||||
"activeCheckbox": "User ist aktiv",
|
||||
"recoveryEmail": "E-Mail-Adresse zur Passwortwiederherstellung",
|
||||
"primaryEmail": "Primäre E-Mail-Adresse",
|
||||
"displayName": "Anzeigename",
|
||||
"usernamePlaceholder": "Optional. Kann während der Registrierung gewählt werden",
|
||||
"noGroups": "Keine Gruppen verfügbar.",
|
||||
"usernamePlaceholder": "Optional. Kann während der Registrierung gewählt werden.",
|
||||
"noGroups": "Keine Gruppen verfügbar",
|
||||
"groups": "Gruppen",
|
||||
"role": "Rolle",
|
||||
"username": "Username",
|
||||
"fullName": "Vollständiger Name",
|
||||
"fallbackEmailPlaceholder": "Falls nicht gesetzt wird die Primäre E-Mail benutzt",
|
||||
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden"
|
||||
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden."
|
||||
},
|
||||
"addUserDialog": {
|
||||
"addUserAction": "User hinzufügen",
|
||||
"addUserAction": "Hinzufügen",
|
||||
"sendInviteCheckbox": "Einladungsmail versenden",
|
||||
"title": "User hinzufügen"
|
||||
},
|
||||
"invitationDialog": {
|
||||
"title": "{{ username }} einladen",
|
||||
"title": "User einladen",
|
||||
"description": "Der folgende Einladungslink wurde an {{ email }} gesendet:",
|
||||
"sendAction": "Mail senden",
|
||||
"descriptionLink": "Link zur Einladung kopieren",
|
||||
"descriptionEmail": "Einladungslink senden"
|
||||
"sendAction": "E-Mail senden",
|
||||
"descriptionLink": "Einladungslink",
|
||||
"descriptionEmail": "Einladungslink senden",
|
||||
"context": "User \"{{ username }}\" einladen"
|
||||
},
|
||||
"setGhostDialog": {
|
||||
"password": "Temporäres Passwort",
|
||||
"setPassword": "Passwort setzen",
|
||||
"title": "Erstelle Passwort um {{ username }} zu personifizieren",
|
||||
"description": "Setze ein temporäres Passwort um als sich als dieser user in Apps und Dashboard anzumelden. Dieses Passwort ist für 6 Stunden gültig.",
|
||||
"generatePassword": "Generiere Passwort"
|
||||
"title": "Als anderer User ausgeben",
|
||||
"description": "Setze ein temporäres Passwort um sich als dieser User in Apps und Dashboard anzumelden. Dieses Passwort ist für 6 Stunden gültig.",
|
||||
"generatePassword": "Generiere Passwort",
|
||||
"context": "Sich als User \"{{ username }}\" ausgeben"
|
||||
},
|
||||
"exposedLdap": {
|
||||
"secret": {
|
||||
"description": "Alle LDAP-Anfragen müssen mit diesem Secret und dem Benutzer-DN <i>{{ userDN }}</i> authentifiziert werden.",
|
||||
"description": "Authentifizieren Sie Abfragen mit dieser User-DN <i>{{ userDN }}</i> und diesem Passwort.",
|
||||
"label": "Bind Passwort",
|
||||
"url": "Server URL"
|
||||
},
|
||||
"description": "Der LDAP-Server ermöglicht externen Apps, Benutzer gegen das Cloudron-Benutzerverzeichnis zu authentifizieren.",
|
||||
"ipRestriction": {
|
||||
"description": "Der Verzeichnisserver muss auf bestimmte IPs oder Bereiche beschränkt werden. Zeilen, die mit <code>#</code> beginnen werden als Kommentare gewertet.",
|
||||
"label": "Zugriff beschränken",
|
||||
"placeholder": "Zeilen separierte IP Adresse oder Subnetz"
|
||||
"description": "Zugriff auf Verzeichnisserver auf bestimmte IPs oder Bereiche beschränken",
|
||||
"label": "Erlaubte IP-Bereich(e)",
|
||||
"placeholder": "IP-Adressen oder Subnetze, zeilenweise angegeben. Zeilen, die mit <code>#</code> beginnen, werden als Kommentare behandelt."
|
||||
},
|
||||
"cloudflarePortWarning": "Cloudflare Proxying für die Dashboarddomäne muss deaktiviert sein um den LDAP Server zu erreichen",
|
||||
"enable": "LDAP-Server aktivieren",
|
||||
@@ -320,7 +329,11 @@
|
||||
"invitationNotification": {
|
||||
"body": "Email gesendet an {{ email }}"
|
||||
},
|
||||
"title": "Users"
|
||||
"title": "Users",
|
||||
"2FAResetDialog": {
|
||||
"title": "2FA zurücksetzen",
|
||||
"description": "Die bestehende 2FA-Einrichtung für den User '{{ username }}' entfernen?"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
"title": "Profil",
|
||||
@@ -333,8 +346,8 @@
|
||||
"enable": "Aktivieren",
|
||||
"token": "Token",
|
||||
"authenticatorAppDescription": "Bitte eines der folgenden Tools verwenden, um den Barcode zu scannen: Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>). Vergleichbare TOTP Apps sollten auch funktionieren.",
|
||||
"description": "Die Benutzung dieser Cloudron-Instanz verlangt von allen Usern eine Zwei-Faktor-Authentifizierung. Hinweis: 2FA aktivieren.",
|
||||
"title": "Aktiviere Zwei-Faktor-Authentifizierung"
|
||||
"title": "Aktiviere Zwei-Faktor-Authentifizierung",
|
||||
"mandatorySetup": "2FA ist erforderlich, um auf das Dashboard zuzugreifen. Bitte schließen Sie die Einrichtung ab, um fortzufahren."
|
||||
},
|
||||
"primaryEmail": "Primäre E-Mail-Adresse",
|
||||
"language": "Sprache",
|
||||
@@ -343,12 +356,12 @@
|
||||
"newPasswordRepeat": "Neues Passwort wiederholen",
|
||||
"newPassword": "Neues Passwort",
|
||||
"currentPassword": "Aktuelles Passwort",
|
||||
"title": "Ändere das Passwort"
|
||||
"title": "Passwort ändern"
|
||||
},
|
||||
"appPasswords": {
|
||||
"app": "Applikation",
|
||||
"name": "Name",
|
||||
"noPasswordsPlaceholder": "Es sind bislang keine App-Passwörter erstellt worden.",
|
||||
"noPasswordsPlaceholder": "Keine App-Passwörter",
|
||||
"description": "App-Passwörter sind eine Sicherheitsmaßnahme zum Schutz des Cloudron-User-Kontos. Sobald eingerichtet, kann die Anmeldung (zusätzlich) mit dem Usernamen und dem hier angezeigtem Passwort erfolgen. Hinweis: sinnvoll bei nicht vertrauenswürdigen mobilen Anwendungen oder Desktop-Clients.",
|
||||
"title": "App-Passwörter"
|
||||
},
|
||||
@@ -356,7 +369,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 +377,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",
|
||||
@@ -386,8 +397,8 @@
|
||||
"title": "Anmelde-Tokens"
|
||||
},
|
||||
"apiTokens": {
|
||||
"noTokensPlaceholder": "Es ist bislang kein API-Token erstellt worden.",
|
||||
"description": "Persönlichen Zugriffstoken zur Authentifizierung gegenüber der <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a> verwenden",
|
||||
"noTokensPlaceholder": "Keine API-Tokens",
|
||||
"description": "Persönlichen Zugriffstoken zur Authentifizierung gegenüber der <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a> verwenden.",
|
||||
"name": "Name",
|
||||
"title": "API-Tokens",
|
||||
"lastUsed": "Zuletzt Verwendet",
|
||||
@@ -403,10 +414,12 @@
|
||||
"body": "Email gesendet an {{ email }}"
|
||||
},
|
||||
"removeApiToken": {
|
||||
"title": "Token {{ name }} wirklich entfernen?"
|
||||
"title": "Token entfernen",
|
||||
"description": "API-Token \"{{ name }}\" entfernen?"
|
||||
},
|
||||
"removeAppPassword": {
|
||||
"title": "Dieses Password wirklich entfernen?"
|
||||
"title": "App-Passwort entfernen",
|
||||
"description": "App-Passwort \"{{ name }}\" entfernen?"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -416,7 +429,7 @@
|
||||
"maxMailSize": "Maximalgröße einer E-Mail",
|
||||
"location": "Domäne des Mail-Servers",
|
||||
"title": "Einstellungen",
|
||||
"spamFilterOverview": "{{ blacklistCount }} Adressen sind auf der Blockliste.",
|
||||
"spamFilterOverview": "{{ blacklistCount }} Adressen sind auf der Blockliste",
|
||||
"solrFts": "Volltextsuche",
|
||||
"aclOverview": "{{ dnsblZonesCount }} DNSBL Zonen",
|
||||
"acl": "Postfachberechtigungen",
|
||||
@@ -424,10 +437,11 @@
|
||||
},
|
||||
"domains": {
|
||||
"testEmailTooltip": "Test E-Mail senden",
|
||||
"stats": "{{ mailboxCount }} Mailbox(en) / in Gebrauch: {{ usage }}",
|
||||
"stats": "Postfächer: {{ mailboxCount }} / Nutzung: {{ usage }}",
|
||||
"disabled": "Deaktiviert",
|
||||
"outbound": "Nur ausgehend",
|
||||
"title": "Domains"
|
||||
"title": "Domains",
|
||||
"inbound": "Eingehend & Ausgehend"
|
||||
},
|
||||
"solrConfig": {
|
||||
"description": "Solr & Tika kann für schnelle Volltextsuche in Dovecot verwendet werden. Solr wird nur gestartet wenn der <a href=\"/#/services\" target=\"_blank\">E-Mail Dienst</a> mehr als 3GB Arbeitsspeicher zugewiesen hat."
|
||||
@@ -460,13 +474,14 @@
|
||||
"rcptTo": "Zu"
|
||||
},
|
||||
"changeDomainDialog": {
|
||||
"description": "Dies zieht den E-Mail Server auf die neue Domäne um."
|
||||
"description": "IMAP- und SMTP-Server auf die angegebene Domäne umziehen",
|
||||
"setAction": "Domäne festlegen"
|
||||
},
|
||||
"changeMailSizeDialog": {
|
||||
"description": "Das Ändern der maximalen E-Mail-Nachrichtengröße erfordert einen Neustart des Mailservers."
|
||||
"description": "Eingehende E-Mails, die größer als diese Größe sind, werden abgelehnt"
|
||||
},
|
||||
"spamFilterDialog": {
|
||||
"blacklisteAddresses": "E-Mail-Adressen auf der Blockliste",
|
||||
"blacklisteAddresses": "E-Mail-Adressen Blockliste",
|
||||
"blacklisteAddressesInfo": "Übereinstimmende Adressen landen im Spam-Ordner des Users. '*' und '?' Glob-Muster werden unterstützt.",
|
||||
"blacklisteAddressesPlaceholder": "Zeilengetrennte E-Mail-Adressmuster",
|
||||
"title": "Spam-Filterung",
|
||||
@@ -474,8 +489,8 @@
|
||||
"customRulesPlaceholder": "Benutzerdefinierte Spamassassin-Regeln"
|
||||
},
|
||||
"testMailDialog": {
|
||||
"title": "Test-E-Mail an {{ domain }} senden",
|
||||
"description": "Dies wird eine Test-E-Mail von <b>no-reply@{{ domain }}</b> an die unten angegebene Adresse senden.",
|
||||
"title": "Test-E-Mail senden",
|
||||
"description": "Sendet eine Test-E-Mail von <b>no-reply@{{ domain }}</b> an die angegebene Adresse.",
|
||||
"sendAction": "Senden"
|
||||
},
|
||||
"typeFilterHeader": "Alle Ereignisse",
|
||||
@@ -486,7 +501,7 @@
|
||||
"dnsblZonesPlaceholder": "Zonennamen (einer pro Zeile)"
|
||||
},
|
||||
"mailboxSharing": {
|
||||
"description": "Wenn diese Funktion aktiviert ist, können Benutzer ihre IMAP-Ordner für andere Benutzer freigeben.",
|
||||
"description": "Wenn diese Funktion aktiviert ist, können Benutzer ihre IMAP-Ordner für andere Benutzer freigeben",
|
||||
"title": "Teilen von Postfächern"
|
||||
},
|
||||
"changeVirtualAllMailDialog": {
|
||||
@@ -510,90 +525,93 @@
|
||||
"title": "Domänen",
|
||||
"renewCerts": {
|
||||
"renewAllAction": "Alle Zertifikate erneuern",
|
||||
"title": "Zertifikat erneuern",
|
||||
"title": "Zertifikate erneuern",
|
||||
"description": "Let's Encrypt Zertifikate werden automatisch erneuert. Diese Option verwenden, um sofort eine Erneuerung auszulösen."
|
||||
},
|
||||
"domainDialog": {
|
||||
"route53AccessKeyId": "Zugangsschlüssel-ID",
|
||||
"digitalOceanToken": "DigitalOcean-Token",
|
||||
"namecheapApiKey": "API-Schlüssel",
|
||||
"namecheapApiKey": "Namecheap API-Schlüssel",
|
||||
"namecheapInfo": "Die Server-IP-Adresse muss für diesen API-Schlüssel auf der Erlaubtliste stehen.",
|
||||
"fallbackCertCertificatePlaceholder": "Zertifikat",
|
||||
"nameComApiToken": "API-Token",
|
||||
"wildcardInfo": "Manuell A (IPv4) und AAAA (IPv6) DNS-Einträge für <b>{{ domain }}</b> einrichten, die auf diesen Server verweisen",
|
||||
"letsEncryptInfo": "Let's Encrypt erfordert, dass der Server auf Port 80 erreichbar ist",
|
||||
"advancedAction": "Erweiterte Einstellungen…",
|
||||
"zoneName": "Zonen-Namen (optional)",
|
||||
"zoneName": "Zonenname",
|
||||
"fallbackCertKeyPlaceholder": "Schlüssel",
|
||||
"route53SecretAccessKey": "Geheimer Zugangsschlüssel",
|
||||
"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.",
|
||||
"editTitle": "Domäne konfigurieren",
|
||||
"domain": "Domäne",
|
||||
"provider": "DNS-Anbieter",
|
||||
"gandiApiKey": "Gandi-API-Key",
|
||||
"goDaddyApiSecret": "API-Geheimnis",
|
||||
"goDaddyApiSecret": "GoDaddy API-Geheimnis",
|
||||
"cloudflareTokenType": "Token-Typ",
|
||||
"cloudflareTokenTypeApiToken": "API-Token",
|
||||
"namecheapUsername": "Namecheap Username",
|
||||
"manualInfo": "Alle DNS-Einträge müssen vor jeder Installation einer Anwendung manuell eingerichtet werden.",
|
||||
"manualInfo": "Alle DNS-Einträge müssen vor jeder Installation einer Anwendung manuell eingerichtet werden",
|
||||
"fallbackCert": "Notfallzertifikat (optional)",
|
||||
"fallbackCertCustomCert": "Benutzerdefiniertes Zertifikat",
|
||||
"fallbackCertCustomCertInfo": "Dieses <a href=\"{{ customCertLink }}\" target=\"_blank\">Wildcard-Zertifikat</a> wird für alle Anwendungen in dieser Domäne verwendet. Wenn es nicht angegeben wird, wird automatisch ein selbstsigniertes Zertifikat generiert.",
|
||||
"fallbackCertCustomCertInfo": "Stelle ein <a href=\"{{ customCertLink }}\" target=\"_blank\">Wildcard-Zertifikat</a> bereit, das für alle Apps auf dieser Domäne verwendet wird. Falls kein Zertifikat bereitgestellt wird, wird automatisch ein selbstsigniertes Zertifikat generiert.",
|
||||
"addTitle": "Domäne hinzufügen",
|
||||
"nameComUsername": "Name.com Username",
|
||||
"goDaddyApiKey": "API-Schlüssel",
|
||||
"goDaddyApiKey": "GoDaddy API-Schlüssel",
|
||||
"cloudflareEmail": "Cloudflare-E-Mail",
|
||||
"linodeToken": "Linode-Token",
|
||||
"mastodonHostname": "Mastodon Domain",
|
||||
"matrixHostname": "Matrix Domain",
|
||||
"netcupApiPassword": "API Passwort",
|
||||
"netcupApiKey": "API Key",
|
||||
"netcupCustomerNumber": "Kundennummer",
|
||||
"netcupApiPassword": "Netcup API Passwort",
|
||||
"netcupApiKey": "Netcup API Key",
|
||||
"netcupCustomerNumber": "Netcup Kundennummer",
|
||||
"vultrToken": "Vultr Token",
|
||||
"wellKnownDescription": "Die Werte werden verwendet, um auf <code>/.well-known/</code> URLs zu antworten. Beachten Sie, dass eine App auf der nackten Domain <code>{{ domain }}</code> verfügbar sein muss, damit dies funktioniert. Siehe die <a href=\"{{docsLink}}\" target=\"_blank\">Dokumentation</a> für weitere Informationen.",
|
||||
"hetznerToken": "Hetzner Token",
|
||||
"jitsiHostname": "Jitsi Domain",
|
||||
"cloudflareDefaultProxyStatus": "Proxying für neue DNS-Einträge aktivieren",
|
||||
"porkbunSecretapikey": "Geheimer API-Schlüssel",
|
||||
"porkbunApikey": "API-Schlüssel",
|
||||
"porkbunSecretapikey": "Porkbun Geheimer API-Schlüssel",
|
||||
"porkbunApikey": "Porkbun API-Schlüssel",
|
||||
"bunnyAccessKey": "Bunny Access Key",
|
||||
"deSecToken": "deSEC Token",
|
||||
"dnsimpleAccessToken": "Access Token",
|
||||
"ovhEndpoint": "Endpoint",
|
||||
"ovhConsumerKey": "Consumer Key",
|
||||
"ovhAppKey": "Application Key",
|
||||
"ovhAppSecret": "Application Secret",
|
||||
"ovhEndpoint": "OVH Endpoint",
|
||||
"ovhConsumerKey": "OVH Consumer Key",
|
||||
"ovhAppKey": "OVH Application Key",
|
||||
"ovhAppSecret": "OVH Application Secret",
|
||||
"gandiTokenType": "Tokentyp",
|
||||
"gandiTokenTypeApiKey": "API Schlüssel (veraltet)",
|
||||
"gandiTokenTypePAT": "Persönliches Zugriffstoken (PAT)",
|
||||
"customNameservers": "Domäne nutzt benutzerdefinierte (Vanity) Nameserver",
|
||||
"inwxPassword": "Password",
|
||||
"inwxUsername": "Username"
|
||||
"inwxPassword": "INWX Password",
|
||||
"inwxUsername": "INWX Username",
|
||||
"zoneNamePlaceholder": "Optional. Falls nicht angegeben, wird standardmäßig auf die Root-Domäne gesetzt."
|
||||
},
|
||||
"changeDashboardDomain": {
|
||||
"title": "Dashboard-Domäne",
|
||||
"description": "Dadurch wird das Dashboard in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
|
||||
"description": "Dashboard in die Subdomain \"my\" der ausgewählten Domäne verschieben",
|
||||
"changeAction": "Domäne ändern"
|
||||
},
|
||||
"domain": "Domäne",
|
||||
"provider": "Anbieter",
|
||||
"removeDialog": {
|
||||
"title": "Wirklich {{ domain }} entfernen?",
|
||||
"removeAction": "Entfernen"
|
||||
"title": "Domäne entfernen",
|
||||
"removeAction": "Entfernen",
|
||||
"description": "Domäne \"{{ domain }}\" entfernen?"
|
||||
},
|
||||
"syncDns": {
|
||||
"syncAction": "Synchronisiere DNS",
|
||||
"title": "Synchronisiere DNS",
|
||||
"description": "Hiermit werden all App und Email DNS Einträge über alle Domains neu erstellt."
|
||||
},
|
||||
"tooltipWellKnown": "Well-Known Pfade",
|
||||
"domainWellKnown": {
|
||||
"title": ".well-known Pfade von {{ domain }}"
|
||||
"description": "App und E-Mail DNS Einträge für alle Domains neu erstellt."
|
||||
},
|
||||
"emptyPlaceholder": "Keine Domänen",
|
||||
"noMatchesPlaceholder": "Keine passende Domäne"
|
||||
"noMatchesPlaceholder": "Keine passende Domäne",
|
||||
"description": "Durch das Hinzufügen einer Domäne können Sie Apps auf deren Subdomains installieren.",
|
||||
"wellknown": {
|
||||
"editAction": "Well-known URIs",
|
||||
"title": "Well-known URIs",
|
||||
"context": "Konfiguriere die Antwort auf \"https://{{ domain }}/.well-known/\" URLs",
|
||||
"description": "Diese Funktion erfordert eine auf der Root-Domäne installierte App. Siehe <a href=\"{{docsLink}}\" target=\"_blank\">Dokumentation</a> für mehr Info."
|
||||
}
|
||||
},
|
||||
"notifications": {
|
||||
"dismissTooltip": "Verwerfen",
|
||||
@@ -649,7 +667,7 @@
|
||||
"configureBackupStorage": {
|
||||
"uploadPartSize": "Größe der hochgeladenen Teile",
|
||||
"memoryLimit": "Speicherlimit",
|
||||
"encryptionDescription": "Vorsicht: Passphrase an einem sicheren Ort aufbewahren. Cloudron speichert dieses Passwort nicht. Backups können ohne die Passphrase nicht entschlüsselt werden",
|
||||
"encryptionDescription": "Passphrase an einem sicheren Ort aufbewahren. Cloudron speichert dieses Passwort nicht. Backups können ohne die Passphrase nicht entschlüsselt werden",
|
||||
"encryptionPassword": "Verschlüsselungspasswort",
|
||||
"s3LikeNote": "Bitte alle object expiration lifecycle Regeln entfernen, da dadurch rsync-Backups beschädigt werden.",
|
||||
"format": "Speicherformat",
|
||||
@@ -669,15 +687,14 @@
|
||||
"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",
|
||||
"uploadConcurrency": "Gleichzeitige Zugriffe beim Upload",
|
||||
"downloadConcurrencyDescription": "Anzahl der Dateien, die beim Wiederherstellen parallel heruntergeladen werden",
|
||||
"copyConcurrencyDescription": "Anzahl der Remote-Dateikopien, die parallel genutzt werden.",
|
||||
"copyConcurrency": "Gleichzeitige Zugriffe beim Kopieren",
|
||||
"uploadConcurrencyDescription": "Anzahl der Dateien, die parallel hochgeladen werden",
|
||||
"uploadConcurrency": "Gleichzeitige Uploads",
|
||||
"downloadConcurrencyDescription": "Anzahl der Dateien, die parallel heruntergeladen werden",
|
||||
"downloadConcurrency": "Gleichzeitiges Herunterladen",
|
||||
"uploadPartSizeDescription": "Paketgröße beim Hochladen. Bis zu 3 Pakete werden gleichzeitig hochgeladen. Dementsprechend wird auch Arbeitsspeicher benötigt.",
|
||||
"memoryLimitDescription": "Arbeitsspeicherlimit für die Datensicherung. Das Limit erhöhen, wenn die Datensicherung-Concurrency erhöht wird.",
|
||||
"memoryLimitDescription": "Arbeitsspeicherlimit für die Datensicherung",
|
||||
"server": "Server IP oder Hostname",
|
||||
"remoteDirectory": "Remote-Verzeichnis",
|
||||
"username": "Username",
|
||||
@@ -686,41 +703,50 @@
|
||||
"user": "User",
|
||||
"privateKey": "Privater Schlüssel",
|
||||
"diskPath": "Datenträger-Pfad",
|
||||
"cifsSealSupport": "Verschlüsselung verwenden. Erfordert mindestens SMB v3",
|
||||
"cifsSealSupport": "Seal Verschlüsselung verwenden (erfordert mindestens SMB v3)",
|
||||
"chown": "Entferntes Dateisystem unterstützt chown",
|
||||
"encryptFilenames": "Dateinamen verschlüsseln",
|
||||
"preserveAttributesLabel": "Dateiattribute erhalten",
|
||||
"name": "Name",
|
||||
"encryptionHint": "Hinweis zum Verschlüsselungspasswort",
|
||||
"usesEncryption": "Datensicherung verwendet Verschlüsselung",
|
||||
"useForUpdates": "Hier Backups der automatischen Updates speichern",
|
||||
"useForUpdates": "Datensicherungen der automatischen Updates speichern",
|
||||
"backupContents": {
|
||||
"title": "Inhalte der Datensicherung",
|
||||
"description": "Wählen Sie aus, was Sie auf dieser Website sichern möchten.",
|
||||
"everything": "Alles",
|
||||
"excludeSelected": "Ausgewählte ausschließen",
|
||||
"includeOnlySelected": "Nur ausgewählte einschließen"
|
||||
"includeOnlySelected": "Nur ausgewählte einschließen",
|
||||
"context": "Inhalte der Datensicherungsseite \"{{ name }}\" konfigurieren"
|
||||
},
|
||||
"automaticUpdates": {
|
||||
"title": "Backups von automatischen Updates",
|
||||
"title": "Datensicherungen von automatischen Updates",
|
||||
"description": "Eine Datensicherung wird immer erstellt, bevor automatische Updates angewendet werden. Wählen Sie aus, ob diese Datensicherungen auf dieser Site gespeichert werden sollen."
|
||||
},
|
||||
"useEncryption": "Backups verschlüsseln"
|
||||
"useEncryption": "Datensicherungen verschlüsseln",
|
||||
"regionHelperText": "Wenn leer, Standardmäßig auf \"us-east-1\" gesetzt",
|
||||
"prefixHelperText": "Datensicherungen werden in diesem Unterordner gespeichert"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"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"
|
||||
"title": "Sicherungszeitplan und Aufbewahrung konfigurieren",
|
||||
"schedule": {
|
||||
"context": "Zeitplan und die Aufbewahrungsdauer von \"{{ name }}\" konfigurieren",
|
||||
"description": "Legen Sie die Tage und Zeiten für Backups fest. Stellen Sie sicher, dass dieser Zeitplan sich nicht mit dem <a href=\"/#/system-update\">Aktualisierungszeitplan</a> überschneidet.",
|
||||
"title": "Datensicherungs Zeitplan"
|
||||
},
|
||||
"disable": "Automatische Datensicherung deaktivieren",
|
||||
"enable": "Automatische Datensicherung aktivieren"
|
||||
},
|
||||
"backupDetails": {
|
||||
"list": "Enthält Datensicherungen von {{ appCount }} Anwendungen",
|
||||
"version": "Version",
|
||||
"date": "Datum",
|
||||
"id": "Id",
|
||||
"title": "Backup-Details"
|
||||
"title": "Backup-Details",
|
||||
"size": "Größe",
|
||||
"duration": "Dauer"
|
||||
},
|
||||
"listing": {
|
||||
"backupNow": "Backup jetzt erstellen",
|
||||
@@ -746,7 +772,7 @@
|
||||
"title": "Backup bearbeiten",
|
||||
"preserved": {
|
||||
"tooltip": "Dadurch bleiben auch die Mail- und {{ appsLength }} App-Backups erhalten.",
|
||||
"description": "Backup unabhängig von der Aufbewahrungsrichtlinie beibehalten"
|
||||
"description": "Datensicherung dauerhaft behalten (von der Aufbewahrungsrichtlinie ausgenommen)"
|
||||
},
|
||||
"label": "Label",
|
||||
"remotePath": "Remote Pfad"
|
||||
@@ -756,13 +782,13 @@
|
||||
"info": "Info"
|
||||
},
|
||||
"deleteArchiveDialog": {
|
||||
"title": "Archiv von {{ appTitle }} ({{ fqdn }}) löschen",
|
||||
"description": "Nach dem Löschen wird die Datensicherung basierend der Aufbewahrungsrichtlinie bereinigt."
|
||||
"title": "Archiv löschen",
|
||||
"description": "Nach dem Löschen wird die Datensicherung basierend der Aufbewahrungsrichtlinie bereinigt.<br/><br/>\"{{ appTitle }} ({{ appFqdn }})\" löschen?"
|
||||
},
|
||||
"restoreArchiveDialog": {
|
||||
"restoreActionOverwrite": "Wiederherstelle und DNS überschreiben",
|
||||
"title": "Von Archiv wiederherstellen",
|
||||
"description": "Dies installiert {{ appId }} auf der angegebenen Domäne mit der Datensicherung vom {{ creationTime }}.",
|
||||
"description": "Stelle \"{{appId}}\" in der angegebenen Domäne aus der Datensicherung vom {{creationTime}} wieder her",
|
||||
"restoreAction": "Wiederherstellen"
|
||||
},
|
||||
"deleteArchive": {
|
||||
@@ -798,8 +824,8 @@
|
||||
"userManagementSelectUsers": "Nur folgenden Usern und Gruppen den Zugriff erlauben",
|
||||
"userManagementAllUsers": "Allen Usern dieser Cloudron-Instanz den Zugriff erlauben",
|
||||
"userManagementLeaveToApp": "Die User-Verwaltung der Anwendung überlassen",
|
||||
"userManagementMailbox": "Alle Nutzer mit einem Postfach auf diesem Cloudron haben Zugriff.",
|
||||
"userManagementNone": "Diese Anwendung verfügt über eine eigene User-Verwaltung. Diese Einstellung bestimmt die Sichtbarkeit der Anwendung im Dashboard.",
|
||||
"userManagementMailbox": "Benutzer mit einem <a href=\"/#/mailboxes\">Postfach</a> können sich mit der E-Mail ihres Postfachs und dem Cloudron-Passwort anmelden.",
|
||||
"userManagementNone": "Diese Anwendung verfügt über eine eigene User-Verwaltung.",
|
||||
"userManagement": "User-Verwaltung",
|
||||
"manualWarning": "Manuell A (IPv4) und AAAA (IPv6) DNS-Einträge für <b>{{ location }}</b> einrichten, die auf diesen Server verweisen.",
|
||||
"locationPlaceholder": "Leer lassen um Hauptdomäne zu benutzen",
|
||||
@@ -820,15 +846,15 @@
|
||||
},
|
||||
"services": {
|
||||
"title": "Dienste",
|
||||
"description": "Dienste stellen zentral Funktionen wie Datenbanken, E-Mail und Authentifizierung bereit. Hinweis: Alles sollte grün sein. Wenn nicht, den jeweiligen Dienst neu starten und ggf. das Speicherlimit erhöhen.",
|
||||
"description": "Dienste stellen zentral Funktionen wie Datenbanken, E-Mail und Authentifizierung bereit.",
|
||||
"service": "Dienst",
|
||||
"memoryLimit": "Speicherlimit",
|
||||
"memoryUsage": "Speichernutzung",
|
||||
"configure": {
|
||||
"title": "{{ name }} konfigurieren",
|
||||
"title": "Dienst 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."
|
||||
"description": "Dienst \"{{ name }}\" konfigurieren"
|
||||
},
|
||||
"restartActionTooltip": "Neustart"
|
||||
},
|
||||
@@ -855,7 +881,6 @@
|
||||
"welcomeTo": "Willkommen bei <%= cloudronName %>!",
|
||||
"subject": "Willkommen bei <%= cloudron %>",
|
||||
"inviteLinkActionText": "Öffne den folgenden Link, um dich anzumelden: <%- inviteLink %>",
|
||||
"expireNote": "Dieser Link ist 7 Tage gültig.",
|
||||
"invitor": "Diese Email wurde geschickt, weil Du von <%= invitor %> eingeladen wurdest.",
|
||||
"inviteLinkAction": "Starte hier",
|
||||
"salutation": "Hallo <%= user %>,"
|
||||
@@ -871,9 +896,11 @@
|
||||
"email": {
|
||||
"signature": {
|
||||
"htmlFormat": "HTML-Format",
|
||||
"title": "Signatur",
|
||||
"title": "E-Mail-Signatur",
|
||||
"description": "Der folgende Text wird an alle E-Mails angehängt, die von dieser Domäne ausgehen.",
|
||||
"plainTextFormat": "Textformat"
|
||||
"plainTextFormat": "Textformat",
|
||||
"customSignatureSet": "Benutzerdefinierte Signatur konfiguriert",
|
||||
"noSignatureSet": "Keine Signatur konfiguriert"
|
||||
},
|
||||
"outbound": {
|
||||
"mailRelay": {
|
||||
@@ -885,15 +912,15 @@
|
||||
"password": "Passwort",
|
||||
"spfDocInfo": "Cloudron richtet einen SPF-Eintrag nicht automatisch ein. Für die manuelle Einrichtung, bitte der <a href=\"{{ spfDocsLink }}\" target=\"_blank\">{{ name }} Anleitung</a> folgen."
|
||||
},
|
||||
"description": "Diesen E-Mail-Server (Smart-Host) verwenden, um die ausgehenden E-Mails der unter dieser Domäne installierten Anwendungen zu versenden.",
|
||||
"noopNonAdminDomainWarning": "Wenn E-Mail deaktiviert ist, können die Anwendungen, die unter der Domäne installiert wurden, keine E-Mails versenden.",
|
||||
"description": "Konfiguriere den ausgehenden E-Mail-Versand für diese Domäne",
|
||||
"noopNonAdminDomainWarning": "Von dieser Domäne wird keine E-Mail gesendet",
|
||||
"noopAdminDomainWarning": "Cloudron kann keine User-Einladungen, Passwort-Zurücksetzen und andere Benachrichtigungen senden, wenn E-Mail-Versand in der primären Domäne deaktiviert ist",
|
||||
"title": "E-Mail-Relay"
|
||||
},
|
||||
"incoming": {
|
||||
"catchall": {
|
||||
"title": "Catch-all",
|
||||
"description": "E-Mails, die an nicht vorhandene Adressen gesendet werden, werden an die folgenden Postfächer weitergeleitet.",
|
||||
"description": "E-Mails, die an nicht vorhandene Adressen gesendet werden, werden an die folgenden Postfächer weitergeleitet",
|
||||
"saveAction": "Speichern"
|
||||
},
|
||||
"title": "Eingehende E-Mail",
|
||||
@@ -901,7 +928,7 @@
|
||||
"port": "Port",
|
||||
"mailinglists": {
|
||||
"membersOnlyTooltip": "Senden an die Liste nur Mitgliedern erlaubt",
|
||||
"members": "Listen-Mitglieder",
|
||||
"members": "Mitglieder",
|
||||
"everyoneTooltip": "Senden an die Liste durch Nichtmitglieder erlaubt",
|
||||
"title": "Mailing-Listen",
|
||||
"name": "Name",
|
||||
@@ -912,11 +939,12 @@
|
||||
"title": "Postfächer",
|
||||
"name": "Name",
|
||||
"owner": "Besitzer*in",
|
||||
"aliases": "Alias",
|
||||
"aliases": "Aliasse",
|
||||
"usage": "Benutzung",
|
||||
"addAction": "Hinzufügen",
|
||||
"emptyPlaceholder": "Keine Postfächer",
|
||||
"noMatchesPlaceholder": "Keine passenden Postfächer"
|
||||
"noMatchesPlaceholder": "Keine passenden Postfächer",
|
||||
"stats": "Anzahl: {{ mailboxCount }} / Nutzung: {{ usage }}"
|
||||
},
|
||||
"outgointServerInfo": "Ausgehende E-Mails (SMTP)",
|
||||
"sieveServerInfo": "Sieve-Filter verwalten",
|
||||
@@ -925,11 +953,7 @@
|
||||
"incomingPasswordUsage": "Passwort des Besitzers der Mailbox",
|
||||
"incomingPasswordInfo": "Passwort",
|
||||
"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"
|
||||
"description": "Eingehende E-Mails für diese Domäne empfangen"
|
||||
},
|
||||
"smtpStatus": {
|
||||
"notBlacklisted": "Die IP-Adresse des Servers {{ ip }} ist <b>nicht</b> auf einer bekannten Blockliste.",
|
||||
@@ -938,13 +962,12 @@
|
||||
"outboundSmtp": "Ausgehend SMTP"
|
||||
},
|
||||
"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.",
|
||||
"description": "Cloudron wird E-Mails für \"{{ domain }}\" empfangen. Siehe die Dokumentation zu den <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">benötigten Ports</a>.",
|
||||
"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?",
|
||||
"title": "Eingehende E-Mail aktivieren",
|
||||
"setupDnsCheckbox": "DNS-Einträge für E-Mail jetzt einrichten",
|
||||
"setupDnsInfo": "Diese Option verwenden, um automatisch E-Mail-bezogene DNS-Einträge einzurichten. Es ist nützlich, diese Option deaktiviert zu lassen, um Mailboxen zu erstellen und <a href=\"{{ importEmailDocsLink }}\">E-Mails</a> vor der Inbetriebnahme zu importieren."
|
||||
"setupDnsInfo": "Automatische Mail-DNS-Einträge einrichten. Kann auch später <a href=\"/#/domains\">synchronisiert</a> werden, falls zuerst Postfächer <a href=\"{{ importEmailDocsLink }}\">importiert</a> werden sollen."
|
||||
},
|
||||
"dnsStatus": {
|
||||
"namecheapInfo": "Namecheap erfordert manuelle Schritte für MX-Einträge",
|
||||
@@ -958,10 +981,10 @@
|
||||
"recordNotSet": "Nicht gesetzt"
|
||||
},
|
||||
"addMailinglistDialog": {
|
||||
"title": "Mail-Liste hinzufügen",
|
||||
"members": "Listen-Mitglieder",
|
||||
"membersOnlyCheckbox": "Den Mailversand an diese Liste so einschränken, dass nur Mitglieder senden dürfen.",
|
||||
"name": "Name"
|
||||
"title": "Mailingliste hinzufügen",
|
||||
"members": "Mitglieder der Mailingliste",
|
||||
"membersOnlyCheckbox": "Mailversand so einschränken, dass nur Mitglieder senden dürfen",
|
||||
"name": "Name der Mailingliste"
|
||||
},
|
||||
"config": {
|
||||
"title": "E-Mail-Konfiguration für {{ domain }}",
|
||||
@@ -975,59 +998,65 @@
|
||||
},
|
||||
"addMailboxDialog": {
|
||||
"title": "Postfach hinzufügen",
|
||||
"name": "Name",
|
||||
"incomingDisabledWarning": "Eingehende E-Mail für diese Domäne ist nicht aktiviert."
|
||||
"name": "Postfachname",
|
||||
"incomingDisabledWarning": "Eingehende E-Mail für diese Domäne ist nicht aktiviert"
|
||||
},
|
||||
"editMailboxDialog": {
|
||||
"title": "Postfach {{ name }}@{{ domain }} bearbeiten",
|
||||
"owner": "Besitzer*in des Postfachs",
|
||||
"title": "Postfach bearbeiten",
|
||||
"owner": "Postfach Besitzer*in",
|
||||
"addAliasAction": "Ein Alias hinzufügen",
|
||||
"addAnotherAliasAction": "Ein weiteres Alias hinzufügen",
|
||||
"aliases": "Aliase",
|
||||
"noAliases": "Bislang wurde kein Alias konfiguriert.",
|
||||
"enableStorageQuota": "Speicherbegrenzung aktivieren"
|
||||
"noAliases": "Keine Aliase",
|
||||
"enableStorageQuota": "Speicherbegrenzung"
|
||||
},
|
||||
"deleteMailinglistDialog": {
|
||||
"description": "Die Mail-Liste <b>{{ name }}@{{ domain }}</b> wirklich löschen?",
|
||||
"description": "Mailingliste \"{{ name }}@{{ domain }}\" löschen?",
|
||||
"deleteAction": "Löschen",
|
||||
"title": "Die Mail-Liste {{ name }}@{{ domain }} löschen"
|
||||
"title": "Mailingliste löschen"
|
||||
},
|
||||
"disableEmailDialog": {
|
||||
"title": "E-Mail-Server für {{ domain }} deaktivieren?",
|
||||
"description": "Dadurch wird Cloudron so konfiguriert, dass es für <b>{{ domain }}</b> keine E-Mails mehr empfängt. Mailboxen und Listen, die mit dieser Domäne verbunden sind, werden nicht gelöscht.",
|
||||
"title": "Eingehende E-Mails deaktivieren",
|
||||
"description": "Cloudron wird für die Domäne \"{{ domain }}\" keine E-Mails mehr empfangen. Postfächer und Mailing-Listen auf dieser Domäne werden nicht gelöscht.",
|
||||
"disableAction": "Deaktvieren"
|
||||
},
|
||||
"deleteMailboxDialog": {
|
||||
"description": "Nach dem Löschen werden E-Mails an dieses Postfach zurückgeschickt. E-Mails in diesem Postfach nicht löschen, wenn sie archiviert werden sollen. Die zu archivierenden E-Mails befinden sich unter <code>/home/yellowtent/boxdata/mail/vmail</code> auf dem Server.",
|
||||
"description": "Nach dem Löschen werden E-Mails an dieses Postfach zurückgeschickt. E-Mails in diesem Postfach nicht löschen, wenn sie archiviert werden sollen. Die zu archivierenden E-Mails befinden sich unter <code>/home/yellowtent/boxdata/mail/vmail</code> auf dem Server.<br/><br/>Postfach \"{{ name }}@{{ domain }}\" löschen?",
|
||||
"deleteAction": "Löschen",
|
||||
"title": "Postfach {{ name }}@{{ domain }} löschen",
|
||||
"title": "Postfach löschen",
|
||||
"purgeMailboxCheckbox": "Alle E-Mails und Filter dieses Postfaches löschen"
|
||||
},
|
||||
"editMailinglistDialog": {
|
||||
"title": "Die Mail-Liste {{ name }}@{{ domain }} bearbeiten"
|
||||
"title": "Mailingliste bearbeiten"
|
||||
},
|
||||
"updateMailboxDialog": {
|
||||
"activeCheckbox": "Postfach ist aktiv",
|
||||
"enablePop3": "POP3 Zugriff aktivieren"
|
||||
"enablePop3": "POP3 Zugriff"
|
||||
},
|
||||
"updateMailinglistDialog": {
|
||||
"activeCheckbox": "Mailing-Liste ist aktiv"
|
||||
},
|
||||
"howToConnectInfoModal": "Konfigurieren von E-Mail-Programmen"
|
||||
"howToConnectInfoModal": "Konfigurieren von E-Mail-Programmen",
|
||||
"customFrom": {
|
||||
"title": "Benutzerdefinierte Absenderadresse zulassen",
|
||||
"description": "Authentifizierten Benutzern und Apps erlauben, beliebige Absenderadressen zu verwenden"
|
||||
}
|
||||
},
|
||||
"terminal": {
|
||||
"download": {
|
||||
"download": "Herunterladen"
|
||||
"download": "Datei herunterladen",
|
||||
"title": "Datei herunterladen",
|
||||
"description": "Gebe den Pfad zu einer Datei oder einem Verzeichnis ein, welche(s) aus dem Dateisystem der App heruntergeladen werden soll."
|
||||
},
|
||||
"scheduler": "Zeitplaner/Cron",
|
||||
"downloadAction": "Herunterladen",
|
||||
"downloadAction": "Datei herunterladen",
|
||||
"title": "Terminal",
|
||||
"uploadTo": "Hochladen nach {{ path }}"
|
||||
},
|
||||
"filemanager": {
|
||||
"newFileDialog": {
|
||||
"create": "Erstellen",
|
||||
"title": "Neue Datei"
|
||||
"title": "Neuer Dateiname"
|
||||
},
|
||||
"title": "Datei-Manager",
|
||||
"renameDialog": {
|
||||
@@ -1040,7 +1069,7 @@
|
||||
"reallyDelete": "Wirklich löschen?"
|
||||
},
|
||||
"newDirectoryDialog": {
|
||||
"title": "Neuer Ordner",
|
||||
"title": "Neuer Ordnername",
|
||||
"create": "Erstellen"
|
||||
},
|
||||
"toolbar": {
|
||||
@@ -1160,7 +1189,6 @@
|
||||
"errorPassword": "Das Passwort muss mindestens 8 und maximal 265 Zeichen haben",
|
||||
"setupAction": "Einrichtung",
|
||||
"errorPasswordNoMatch": "Passwörter stimmen nicht überein",
|
||||
"welcomeTo": "Willkommen bei",
|
||||
"passwordRepeat": "Passwort wiederholen",
|
||||
"description": "Konto einrichten",
|
||||
"noUsername": {
|
||||
@@ -1181,11 +1209,14 @@
|
||||
"visibleForSelected": "Nur für die folgenden User und Gruppen sichtbar",
|
||||
"descriptionSftp": "Steuert auch den SFTP-Zugriff.",
|
||||
"visibleForAllUsers": "Sichtbar für alle User auf dieser Cloudron-Instanz",
|
||||
"description": "Diese Anwendung ist für die Authentifizierung mit dem Cloudron-Userverzeichnis konfiguriert."
|
||||
"description": "Konfiguriere, wer sich anmelden darf und die App verwenden kann."
|
||||
},
|
||||
"operators": {
|
||||
"description": "Die Betreiber können diese Anwendung konfigurieren und pflegen.",
|
||||
"title": "Administratoren"
|
||||
},
|
||||
"dashboardVisibility": {
|
||||
"description": "Konfiguriere, wer diese App im Dashboard sehen kann."
|
||||
}
|
||||
},
|
||||
"logsActionTooltip": "Logfiles",
|
||||
@@ -1211,9 +1242,7 @@
|
||||
"title": "Content-Security-Policy"
|
||||
},
|
||||
"robots": {
|
||||
"title": "robots.txt",
|
||||
"txtPlaceholder": "Leer lassen, um allen Bots zu erlauben diese Anwendung in den Index aufzunehmen",
|
||||
"disableIndexingAction": "Indexierung deaktivieren"
|
||||
"title": "robots.txt"
|
||||
},
|
||||
"hstsPreload": "Aktivieren Sie den HSTS-Preload für diese Website und alle Subdomains"
|
||||
},
|
||||
@@ -1295,7 +1324,6 @@
|
||||
"backups": {
|
||||
"backups": {
|
||||
"title": "Backups",
|
||||
"time": "Erstellt am",
|
||||
"downloadConfigTooltip": "Konfiguration herunterladen",
|
||||
"description": "Backups erstellen komplette Abbilder der Anwendung. Ein Anwendungsbackup kann zum Wiederherstellen oder Klonen dieser Anwendung verwendet werden.",
|
||||
"importAction": "Backup importieren",
|
||||
@@ -1356,13 +1384,13 @@
|
||||
},
|
||||
"storageTabTitle": "Speicher",
|
||||
"location": {
|
||||
"noRedirections": "Es sind keine Weiterleitungsdomänen konfiguriert.",
|
||||
"noRedirections": "Keine Weiterleitungsdomänen",
|
||||
"location": "Standort",
|
||||
"saveAction": "Speichern",
|
||||
"locationPlaceholder": "Leer lassen, um die Haupt-Domäne zu verwenden",
|
||||
"redirections": "Weiterleitungen",
|
||||
"addRedirectionAction": "Eine Weiterleitung hinzufügen",
|
||||
"noAliases": "Kein Alias konfiguriert.",
|
||||
"noAliases": "Keine Aliasse",
|
||||
"addAliasAction": "Alias hinzufügen",
|
||||
"aliases": "Aliasse",
|
||||
"dnsoverwrite": "Einige DNS-Einträge existieren bereits. Mit dem Überschreiben einverstanden."
|
||||
@@ -1397,7 +1425,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 +1623,6 @@
|
||||
},
|
||||
"email": "E-Mail",
|
||||
"passwordToken": "Passwort/Token",
|
||||
"dialog": {
|
||||
"title": "Docker-Registry"
|
||||
},
|
||||
"emptyPlaceholder": "Keine Docker-Registries"
|
||||
},
|
||||
"dockerRegistres": {
|
||||
@@ -1609,10 +1633,6 @@
|
||||
"dashboard": {
|
||||
"title": "Dashboard"
|
||||
},
|
||||
"externallinks": {
|
||||
"label": "Externe Links",
|
||||
"description": "Verknüpfungen zu externen Diensten im Dashboard hinzufügen"
|
||||
},
|
||||
"server": {
|
||||
"title": "Server"
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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,17 @@
|
||||
},
|
||||
"action": {
|
||||
"logs": "Registros",
|
||||
"reboot": "Reiniciar"
|
||||
"reboot": "Reiniciar",
|
||||
"remove": "Borrar",
|
||||
"edit": "Editar",
|
||||
"add": "Añadir",
|
||||
"next": "Siguiente"
|
||||
},
|
||||
"table": {
|
||||
"date": "Fecha"
|
||||
"version": "Versión"
|
||||
},
|
||||
"actions": "Acciones",
|
||||
"displayName": "Nombre a mostrar",
|
||||
"displayName": "Nombre para mostrar",
|
||||
"username": "Nombre de Usuario",
|
||||
"dialog": {
|
||||
"yes": "Si",
|
||||
@@ -66,7 +71,8 @@
|
||||
"select": "Seleccionar"
|
||||
},
|
||||
"navbar": {
|
||||
"users": "Usuarios"
|
||||
"users": "Usuarios",
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"statusEnabled": "Habilitado",
|
||||
"loadingPlaceholder": "Cargando"
|
||||
@@ -87,13 +93,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 +116,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 +128,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 +149,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 +174,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 +197,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 +211,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 +236,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 +274,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 +285,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 +294,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 +313,37 @@
|
||||
"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",
|
||||
"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 +370,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 +400,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 +410,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 +434,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"
|
||||
@@ -400,7 +443,6 @@
|
||||
"enable": "Habilitar",
|
||||
"token": "Token",
|
||||
"authenticatorAppDescription": "Usar Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) o aplicación TOTP para escanear clave secreta.",
|
||||
"description": "Tu administrador de Cloudron ha solicitado a todos los miembros que habiliten la autenticación de dos factores. No podrá acceder al panel hasta que habilite 2FA.",
|
||||
"title": "Habilitar Autentificación de 2 Factores"
|
||||
},
|
||||
"disable2FA": {
|
||||
@@ -411,13 +453,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 +495,7 @@
|
||||
},
|
||||
"details": "Detalles",
|
||||
"time": "Hora",
|
||||
"title": "Registro de Eventos del Correo electrónico",
|
||||
"title": "Registro de Eventos",
|
||||
"mailFrom": "De",
|
||||
"rcptTo": "Para"
|
||||
},
|
||||
@@ -470,10 +512,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 +533,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 +562,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 +608,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 +617,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",
|
||||
@@ -587,18 +629,23 @@
|
||||
"settings": {
|
||||
"appstoreAccount": {
|
||||
"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 +658,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 +686,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 +712,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 +724,16 @@
|
||||
"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.",
|
||||
"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 +747,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",
|
||||
@@ -719,10 +765,8 @@
|
||||
"title": "Realmente quieres borrar {{ domain }}?",
|
||||
"removeAction": "Borrar"
|
||||
},
|
||||
"domainWellKnown": {
|
||||
"title": "Ubicaciones Well-known de {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Establece las ubicaciones Well-Known"
|
||||
"emptyPlaceholder": "Sin Dominios",
|
||||
"noMatchesPlaceholder": "No coincide ningún dominio"
|
||||
},
|
||||
"app": {
|
||||
"appInfo": {
|
||||
@@ -740,6 +784,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 +800,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 +825,21 @@
|
||||
"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",
|
||||
"time": "Creado en",
|
||||
"restoreTooltip": "Restaurar",
|
||||
"cloneTooltip": "Clonar",
|
||||
"downloadConfigTooltip": "Descargar configuración",
|
||||
"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 +847,19 @@
|
||||
},
|
||||
"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 +890,9 @@
|
||||
"resizeAction": "Redimensionar",
|
||||
"description": "Memoria máxima que la Aplicación puede usar",
|
||||
"title": "Límite de Memoria"
|
||||
},
|
||||
"devices": {
|
||||
"label": "Dispositivos"
|
||||
}
|
||||
},
|
||||
"accessControl": {
|
||||
@@ -899,11 +951,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 +999,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 +1037,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 +1065,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 +1073,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 +1094,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 +1130,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 +1250,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 +1273,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 +1296,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 +1306,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 +1315,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 +1351,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 +1359,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 +1372,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 +1407,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",
|
||||
@@ -1382,7 +1459,6 @@
|
||||
"errorPasswordNoMatch": "Las contraseñas no coinciden",
|
||||
"password": "Nueva contraseña",
|
||||
"setupAction": "Configurar",
|
||||
"welcomeTo": "Bienvenido a",
|
||||
"description": "Por favor, configura tu cuenta",
|
||||
"username": "Nombre de usuario",
|
||||
"passwordRepeat": "Repetir Contraseña",
|
||||
@@ -1402,7 +1478,6 @@
|
||||
},
|
||||
"welcomeEmail": {
|
||||
"welcomeTo": "Bienvenid@ a <%= cloudronName %>!",
|
||||
"expireNote": "Tenga en cuenta que el enlace de invitación caducará en 7 días.",
|
||||
"salutation": "Hola <%= user %>,",
|
||||
"inviteLinkAction": "Empezar",
|
||||
"invitor": "Recibió este correo electrónico porque fue invitado por <%= invitor%>.",
|
||||
@@ -1416,7 +1491,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 +1503,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 +1516,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 +1528,65 @@
|
||||
},
|
||||
"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"
|
||||
},
|
||||
"server": {
|
||||
"title": "Servidor"
|
||||
},
|
||||
"archives": {
|
||||
"listing": {
|
||||
"placeholder": "No hay aplicaciones archivadas"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@
|
||||
"username": "Nom d'utilisateur",
|
||||
"actions": "Actions",
|
||||
"displayName": "Nom affiché",
|
||||
"table": {
|
||||
"date": "Date"
|
||||
},
|
||||
"action": {
|
||||
"logs": "Journaux",
|
||||
"reboot": "Redémarrer"
|
||||
@@ -228,7 +225,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",
|
||||
@@ -238,7 +234,6 @@
|
||||
"title": "Modifier l'adresse email de récupération du mot de passe"
|
||||
},
|
||||
"enable2FA": {
|
||||
"description": "Votre administrateur Cloudron a demandé à tous les membres d'activer l'authentification à deux facteurs (2FA). Pour accéder au tableau de bord, veuillez l'activer.",
|
||||
"token": "Jeton",
|
||||
"title": "Activer l'authentification à deux facteurs (2FA)",
|
||||
"enable": "Activer",
|
||||
@@ -247,7 +242,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 +278,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 +313,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.",
|
||||
@@ -341,8 +332,7 @@
|
||||
"title": "Informations sur la sauvegarde",
|
||||
"id": "ID",
|
||||
"date": "Date",
|
||||
"version": "Version",
|
||||
"list": "Contient les sauvegardes de {{ appCount }} application(s)"
|
||||
"version": "Version"
|
||||
},
|
||||
"listing": {
|
||||
"title": "Liste",
|
||||
@@ -501,7 +491,6 @@
|
||||
"appstoreAccount": {
|
||||
"subscriptionReactivateAction": "Réactiver l'abonnement",
|
||||
"subscriptionChangeAction": "Modifier l'abonnement",
|
||||
"subscriptionEndsAt": "Prend fin le",
|
||||
"cloudronId": "ID Cloudron",
|
||||
"subscription": "Abonnement",
|
||||
"setupAction": "Créer un compte",
|
||||
@@ -628,8 +617,6 @@
|
||||
"title": "Politique de sécurité du contenu (CSP)"
|
||||
},
|
||||
"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 +694,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"
|
||||
@@ -750,7 +736,6 @@
|
||||
"restoreTooltip": "Restaurer depuis cette sauvegarde",
|
||||
"cloneTooltip": "Cloner depuis cette sauvegarde",
|
||||
"downloadConfigTooltip": "Télécharger le fichier de configuration de la sauvegarde",
|
||||
"time": "Créée le",
|
||||
"description": "Les sauvegardes sont des instantanés complets de l'application. Vous pouvez utiliser les sauvegardes pour restaurer ou cloner l'application.",
|
||||
"title": "Sauvegardes",
|
||||
"downloadBackupTooltip": "Télécharger la sauvegarde"
|
||||
@@ -955,7 +940,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 +960,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 +1003,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",
|
||||
@@ -1061,7 +1040,6 @@
|
||||
"editTitle": "Paramétrer {{ domain }}",
|
||||
"addTitle": "Ajouter un domaine",
|
||||
"vultrToken": "Token Vultr",
|
||||
"wellKnownDescription": "Les valeurs seront utilisées par Cloudron pour répondre aux URL <code>/.well-known/</code>. Notez qu'une application doit être disponible sur le domaine nu <code>{{ domaine }}</code> pour que cela fonctionne. Consultez la <a href=\"{{docsLink}}\" target=\"_blank\">documentation</a> pour plus d'informations.",
|
||||
"hetznerToken": "Token Hetzner",
|
||||
"jitsiHostname": "Emplacement de Jitsi",
|
||||
"cloudflareDefaultProxyStatus": "Activer le proxy pour les nouveaux enregistrements DNS",
|
||||
@@ -1090,11 +1068,7 @@
|
||||
},
|
||||
"provider": "Fournisseur",
|
||||
"domain": "Domaine",
|
||||
"title": "Domaines et Certificats",
|
||||
"domainWellKnown": {
|
||||
"title": "Emplacements Well-Known de {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Définir des emplacements Well-Known"
|
||||
"title": "Domaines et Certificats"
|
||||
},
|
||||
"branding": {
|
||||
"footer": {
|
||||
@@ -1114,7 +1088,6 @@
|
||||
},
|
||||
"welcomeEmail": {
|
||||
"inviteLinkActionText": "Cliquez sur le lien pour démarrer : <%- inviteLink %>",
|
||||
"expireNote": "Veuillez noter que le lien d'invitation expire dans 7 jours.",
|
||||
"invitor": "Vous recevez ce message car vous avez été invité par <%= invitor %>.",
|
||||
"inviteLinkAction": "Démarrez",
|
||||
"subject": "Bienvenue sur <%= cloudron %>",
|
||||
@@ -1282,7 +1255,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",
|
||||
@@ -1309,7 +1281,6 @@
|
||||
"fullName": "Nom complet",
|
||||
"username": "Nom d'utilisateur",
|
||||
"description": "Veuillez paramétrer votre compte",
|
||||
"welcomeTo": "Bienvenue sur",
|
||||
"noUsername": {
|
||||
"title": "Impossible de configurer le compte",
|
||||
"description": "Le compte ne peut pas être configuré sans nom d'utilisateur."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,9 +11,6 @@
|
||||
"logs": "Logs",
|
||||
"reboot": "Riavvia il server"
|
||||
},
|
||||
"table": {
|
||||
"date": "Data"
|
||||
},
|
||||
"actions": "Azioni",
|
||||
"displayName": "Nome visualizzato",
|
||||
"username": "Nome utente",
|
||||
@@ -61,7 +58,6 @@
|
||||
"welcomeEmail": {
|
||||
"subject": "Benvenuti in <%= cloudron %>",
|
||||
"inviteLinkActionText": "Segui questo link per iniziare: <%- inviteLink %>",
|
||||
"expireNote": "Tieni presente che il link di invito scadrà tra 7 giorni.",
|
||||
"invitor": "Hai ricevuto questa email perché sei stato invitato da <%= invitor %>.",
|
||||
"inviteLinkAction": "Iniziare",
|
||||
"salutation": "Ciao <%= user %>,",
|
||||
@@ -83,8 +79,7 @@
|
||||
"password": "Nuova Password",
|
||||
"fullName": "Nome e Cognome",
|
||||
"username": "Nome Utente",
|
||||
"description": "Per favore configura il tuo account",
|
||||
"welcomeTo": "Benvenuti"
|
||||
"description": "Per favore configura il tuo account"
|
||||
},
|
||||
"passwordReset": {
|
||||
"success": {
|
||||
@@ -136,9 +131,7 @@
|
||||
},
|
||||
"security": {
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"disableIndexingAction": "Disabilita indicizzazione",
|
||||
"txtPlaceholder": "Lascia vuoto per consentire a tutti i bot di indicizzare questa app"
|
||||
"title": "Robots.txt"
|
||||
},
|
||||
"csp": {
|
||||
"saveAction": "Salva",
|
||||
@@ -160,7 +153,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": {
|
||||
@@ -211,7 +203,6 @@
|
||||
"restoreTooltip": "Ripristina su questo backup",
|
||||
"cloneTooltip": "Clona da questo backup",
|
||||
"downloadConfigTooltip": "Scarica la configurazione di backup",
|
||||
"time": "Creato alle",
|
||||
"description": "I backup sono istantanee complete dell'app. Puoi utilizzare i backup delle app per ripristinare o clonare questa app.",
|
||||
"title": "Backup"
|
||||
}
|
||||
@@ -375,10 +366,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 +375,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 +528,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,12 +538,9 @@
|
||||
"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": {
|
||||
"list": "Riferimenti ai bakcup di {{ appCount }} applicazioni",
|
||||
"version": "Versione",
|
||||
"date": "Data",
|
||||
"title": "Dettagli Backup",
|
||||
@@ -587,14 +569,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",
|
||||
@@ -628,7 +608,6 @@
|
||||
"enable2FA": {
|
||||
"enable": "Abilita",
|
||||
"authenticatorAppDescription": "Usa Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) o una qualsiasi app TOTP per eseguire la scansione del codice segreto.",
|
||||
"description": "Il tuo amministratore Cloudron ha richiesto a tutti i membri di abilitare l'autenticazione a due fattori. Non sarai in grado di accedere alla dashboard finché non abiliti 2FA.",
|
||||
"title": "Abilita autenticazione a Due Fattori",
|
||||
"token": "Token"
|
||||
},
|
||||
@@ -828,7 +807,6 @@
|
||||
"appstoreAccount": {
|
||||
"subscriptionReactivateAction": "Riattiva Abbonamento",
|
||||
"subscriptionChangeAction": "Cambia Abbonamento",
|
||||
"subscriptionEndsAt": "Annullato e termina il",
|
||||
"cloudronId": "ID Cloudron",
|
||||
"subscription": "Abbonamento",
|
||||
"setupAction": "Imposta Account",
|
||||
@@ -1002,7 +980,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",
|
||||
|
||||
@@ -7,9 +7,6 @@
|
||||
"logs": "ログ",
|
||||
"reboot": "再起動"
|
||||
},
|
||||
"table": {
|
||||
"date": "日付"
|
||||
},
|
||||
"displayName": "表示名",
|
||||
"username": "ユーザー名",
|
||||
"dialog": {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -36,9 +36,6 @@
|
||||
"logs": "Logi",
|
||||
"reboot": "Restart"
|
||||
},
|
||||
"table": {
|
||||
"date": "Data"
|
||||
},
|
||||
"actions": "Akcje",
|
||||
"displayName": "Wyświetlana nazwa",
|
||||
"username": "Użytkownik",
|
||||
|
||||
@@ -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",
|
||||
"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…",
|
||||
@@ -35,11 +36,11 @@
|
||||
"done": "Concluído",
|
||||
"delete": "Eliminar"
|
||||
},
|
||||
"logout": "Terminar Sessão",
|
||||
"logout": "Terminar sessão",
|
||||
"username": "Nome de Utilizador",
|
||||
"actions": "Ações",
|
||||
"table": {
|
||||
"date": "Data"
|
||||
"version": "Versão"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Reiniciar",
|
||||
@@ -47,7 +48,10 @@
|
||||
"remove": "Remover",
|
||||
"edit": "Editar",
|
||||
"add": "Adicionar",
|
||||
"next": "Seguinte"
|
||||
"next": "Seguinte",
|
||||
"configure": "Configurar",
|
||||
"restart": "Reiniciar",
|
||||
"reset": "Reiniciar"
|
||||
},
|
||||
"searchPlaceholder": "Pesquisar",
|
||||
"multiselect": {
|
||||
@@ -59,7 +63,13 @@
|
||||
"groups": "Grupos"
|
||||
},
|
||||
"statusEnabled": "Ativado",
|
||||
"loadingPlaceholder": "A carregar"
|
||||
"loadingPlaceholder": "A carregar",
|
||||
"sidebar": {
|
||||
"collapseAction": "Ocultar barra lateral"
|
||||
},
|
||||
"platform": {
|
||||
"startupFailed": "O arranque da plataforma falhou"
|
||||
}
|
||||
},
|
||||
"appstore": {
|
||||
"category": {
|
||||
@@ -70,24 +80,25 @@
|
||||
"installDialog": {
|
||||
"lastUpdated": "Última atualização em {{ date }}",
|
||||
"locationPlaceholder": "Deixe em branco para utilizar o domínio de raiz",
|
||||
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores. Esta definição determina se a aplicação está ou não visível no painel do utilizador.",
|
||||
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores.",
|
||||
"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",
|
||||
"userManagementMailbox": "Todos os utilizadores com uma caixa de correio neste Cloudron têm acesso.",
|
||||
"userManagementMailbox": "Os utilizadores com uma <a href=\"/#/mailboxes\">caixa de correio</a> podem autenticar-se com o seu ''e-mail' e palavra-passe do Cloudron.",
|
||||
"userManagementLeaveToApp": "Deixar a gestão de utilizadores para a aplicação",
|
||||
"userManagementAllUsers": "Permitir todos os utilizadores deste Cloudron",
|
||||
"userManagementAllUsers": "Permitir todos os utilizadores neste Cloudron",
|
||||
"userManagementSelectUsers": "Permitir apenas os seguintes utilizadores e grupos",
|
||||
"errorUserManagementSelectAtLeastOne": "Selecione pelo menos um utilizador ou grupo",
|
||||
"users": "Utilizadores",
|
||||
"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>.",
|
||||
@@ -96,23 +107,23 @@
|
||||
},
|
||||
"profile": {
|
||||
"changeEmail": {
|
||||
"password": "Palavra-passe para confirmação",
|
||||
"password": "Confirmar com Palavra-passe",
|
||||
"email": "Novo Endereço de Correio Eletrónico",
|
||||
"title": "Alterar endereço de correio eletrónico principal"
|
||||
"title": "Alterar Endereço de Correio Eletrónico Principal"
|
||||
},
|
||||
"changePassword": {
|
||||
"title": "Alterar palavra-passe",
|
||||
"title": "Alterar Palavra-passe",
|
||||
"currentPassword": "Palavra-passe atual",
|
||||
"newPassword": "Nova palavra-passe",
|
||||
"newPasswordRepeat": "Repetir palavra-passe",
|
||||
"newPasswordRepeat": "Repetir nova palavra-passe",
|
||||
"errorPasswordsDontMatch": "As palavras-passe não coincidem"
|
||||
},
|
||||
"enable2FA": {
|
||||
"title": "Ativar Autenticação de Dois Fatores",
|
||||
"token": "Código",
|
||||
"enable": "Ativar",
|
||||
"description": "O seu administrador do Cloudron exigiu que todos os membros ativassem a autenticação de dois fatores. Você não poderá aceder ao painel até ativar a 2FA.",
|
||||
"authenticatorAppDescription": "Utilize o Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), autenticador FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou uma aplicação de TOTP similar para digitalizar o segredo."
|
||||
"authenticatorAppDescription": "Utilize o Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), autenticador FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou uma aplicação de TOTP similar para digitalizar o segredo.",
|
||||
"mandatorySetup": "É necessário a 2FA para aceder ao painel de controlo. Por favor, complete a configuração para continuar."
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "Códigos de API",
|
||||
@@ -123,14 +134,13 @@
|
||||
"readwrite": "Ler e Gravar",
|
||||
"name": "Nome",
|
||||
"description": "Utilize estes códigos de acesso pessoais para autenticar a <a target=\"_blank\" href=\"{{ apiDocsLink }}\">API do Cloudron</a>",
|
||||
"noTokensPlaceholder": "Sem Códigos da API criados",
|
||||
"noTokensPlaceholder": "Sem códigos da API",
|
||||
"allowedIpRanges": "IPs Permitidos",
|
||||
"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",
|
||||
"name": "Nome da palavra-passe",
|
||||
"title": "Adicionar Palavra-passe da Aplicação",
|
||||
"app": "Aplicação",
|
||||
"description": "Utilize a palavra-passe seguinte para se autenticar na aplicação:",
|
||||
"copyNow": "Por favor, copie a palavra-passe agora. Esta não será mostrada novamente por motivos de segurança."
|
||||
@@ -139,10 +149,9 @@
|
||||
"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)"
|
||||
"allowedIpRanges": "Intervalo(s) de IP permitido(s)"
|
||||
},
|
||||
"passwordResetNotification": {
|
||||
"body": "Mensagem enviada para {{ email }}"
|
||||
@@ -156,45 +165,50 @@
|
||||
"disable": "Desativar"
|
||||
},
|
||||
"changeFallbackEmail": {
|
||||
"title": "Alterar endereço de correio eletrónico da recuperação de palavra-passe"
|
||||
"title": "Alterar Endereço de Correio Eletrónico da Recuperação da Palavra-passe"
|
||||
},
|
||||
"loginTokens": {
|
||||
"title": "Códigos de Autenticação",
|
||||
"logoutAll": "Terminar Sessão de Todos",
|
||||
"logoutAll": "Terminar sessão de todos",
|
||||
"description": "Tem {{ webadminTokenCount}} código(s) da Web ativo(s) e {{ cliTokenCount }} código(s) de CLI."
|
||||
},
|
||||
"appPasswords": {
|
||||
"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",
|
||||
"changePasswordAction": "Alterar palavra-passe",
|
||||
"disable2FAAction": "Desativar 2FA",
|
||||
"enable2FAAction": "Ativar 2FA",
|
||||
"removeAppPassword": {
|
||||
"title": "Deseja remover a palavra-passe {{ name }}?"
|
||||
"title": "Remover Palavra-passe da Aplicação",
|
||||
"description": "Remover a palavra-passe da aplicação \"{{ name }}\"?"
|
||||
},
|
||||
"removeApiToken": {
|
||||
"title": "Deseja remover o código {{ name }}?"
|
||||
"title": "Deseja remover o código {{ name }}?",
|
||||
"description": "Remover o código da API \"{{ name }}\"?"
|
||||
},
|
||||
"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",
|
||||
"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."
|
||||
"label": "IPs e limites permitidos",
|
||||
"placeholder": "Endereço de IP ou sub-redes separados por linha. As linhas que comecem com <code>#</code> são tratadas como comentários.",
|
||||
"description": "Limite o acesso do Servidor de Diretoria para IPs ou intervalos específicos"
|
||||
},
|
||||
"secret": {
|
||||
"label": "Associar Palavra-passe",
|
||||
"label": "Associar palavra-passe",
|
||||
"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": "Autenticar consultas com o DN de utilizador <i>{{ userDN }}</i> e este segredo"
|
||||
},
|
||||
"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,31 +222,34 @@
|
||||
"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",
|
||||
"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",
|
||||
"username": "Nome de utilizador",
|
||||
"role": "Função",
|
||||
"groups": "Grupos",
|
||||
"noGroups": "Nenhum grupo disponível.",
|
||||
"displayName": "Nome a Exibir",
|
||||
"noGroups": "Nenhum grupo disponível",
|
||||
"displayName": "Nome a exibir",
|
||||
"primaryEmail": "E-mail principal",
|
||||
"usernamePlaceholder": "Opcional. Se não for fornecido, o utilizador pode escolher durante o registo",
|
||||
"usernamePlaceholder": "Opcional. Se não fornecido, o utilizador pode escolher durante o registo.",
|
||||
"activeCheckbox": "O utilizador está ativo",
|
||||
"displayNamePlaceholder": "Opcional. Se não fornecido, o utilizador pode fornecer durante o registo",
|
||||
"displayNamePlaceholder": "Opcional. Se não fornecido, o utilizador pode fornecer durante o registo.",
|
||||
"fallbackEmailPlaceholder": "Se não especificado, será utilizado o e-mail principal",
|
||||
"recoveryEmail": "Mensagem de recuperação da palavra-passe"
|
||||
},
|
||||
"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",
|
||||
@@ -240,37 +257,38 @@
|
||||
},
|
||||
"editUserDialog": {
|
||||
"externalLdapWarning": "Este utilizador é sincronizado a partir da diretoria LDAP externa.",
|
||||
"title": "Editar utilizador {{ username }}"
|
||||
"title": "Editar Utilizador"
|
||||
},
|
||||
"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",
|
||||
"title": "Convidar {{ username }}",
|
||||
"sendAction": "Enviar Mensagem",
|
||||
"descriptionLink": "Copiar hiperligação de convite",
|
||||
"description": "A seguinte hiperligação de convite foi enviada para {{ email }}:"
|
||||
"title": "Convidar Utilizador",
|
||||
"sendAction": "Enviar mensagem",
|
||||
"descriptionLink": "Hiperligação de convite",
|
||||
"description": "A seguinte hiperligação de convite foi enviada para {{ email }}:",
|
||||
"context": "Convidar utilizador \"{{ username }}\""
|
||||
},
|
||||
"externalLdap": {
|
||||
"autocreateUsersOnLogin": "Criar utilizadores automaticamente ao iniciar a sessão",
|
||||
"provider": "Fornecedor",
|
||||
"server": "URL do Servidor",
|
||||
"filter": "Filtro",
|
||||
"usernameField": "Campo do Nome do Utilizador",
|
||||
"syncGroups": "Sincronizar Grupos",
|
||||
"usernameField": "Campo do nome do utilizador",
|
||||
"syncGroups": "Sincronizar grupos",
|
||||
"auth": "Autenticar",
|
||||
"syncAction": "Sincronizar",
|
||||
"syncAction": "Sincronizar agora",
|
||||
"configureAction": "Configurar",
|
||||
"noopInfo": "A autenticação LDAP não está configurada.",
|
||||
"noopInfo": "Nenhuma diretoria externa 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.",
|
||||
"bindPassword": "Vincular Palavra-passe (opcional)",
|
||||
"description": "Sincronize e autentique os utilizadores e os grupos de um servidor LDAP ou Active Directory externa. A sincronização é executada periodicamente a cada 4 horas.",
|
||||
"bindPassword": "Associar palavra-passe (opcional)",
|
||||
"disableWarning": "A fonte de autenticação de todos os utilizadores existentes será reiniciada para se autenticar na base de dados da palavra-passe atual.",
|
||||
"baseDn": "Base DN",
|
||||
"bindUsername": "Vincular DN/Nome de utilizador (opcional)",
|
||||
@@ -278,9 +296,9 @@
|
||||
"groupBaseDn": "Base DN do Grupo"
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
"title": "Eliminar utilizador {{ username }}",
|
||||
"title": "Eliminar Utilizador",
|
||||
"deleteAction": "Eliminar",
|
||||
"description": "Depois da eliminação, o utilizador não poderá aceder ao painel ou iniciar a sessão em quaisquer aplicações. Note que não são removidos quaisquer dados do utilizador dentro das aplicações."
|
||||
"description": "Depois da eliminação, o utilizador não poderá aceder ao painel ou iniciar a sessão em quaisquer aplicações. Note que não são removidos quaisquer dados do utilizador dentro das aplicações. <br/><br/>Eliminar utilizador \"{{ username }}\"?"
|
||||
},
|
||||
"externalLdapDialog": {
|
||||
"title": "Configurar LDAP"
|
||||
@@ -293,16 +311,17 @@
|
||||
"mailmanager": "Gestor de E-mails e Utilizadores"
|
||||
},
|
||||
"setGhostDialog": {
|
||||
"password": "Palavra-passe Temporária",
|
||||
"setPassword": "Definir Palavra-passe",
|
||||
"password": "Palavra-passe temporária",
|
||||
"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"
|
||||
@@ -310,21 +329,26 @@
|
||||
"group": {
|
||||
"name": "Nome",
|
||||
"users": "Utilizadores",
|
||||
"addGroupAction": "Adicionar Grupo"
|
||||
"addGroupAction": "Adicionar",
|
||||
"allowedApps": "Aplicações permitidas"
|
||||
},
|
||||
"editGroupDialog": {
|
||||
"title": "Editar grupo {{ name }}",
|
||||
"title": "Editar Grupo",
|
||||
"externalLdapWarning": "Este grupo é sincronizado a partir da diretoria LDAP externa."
|
||||
},
|
||||
"addUserDialog": {
|
||||
"title": "Adicionar Utilizador",
|
||||
"addUserAction": "Adicionar Utilizador",
|
||||
"sendInviteCheckbox": "Enviar agora uma mensagem de convite"
|
||||
"addUserAction": "Adicionar",
|
||||
"sendInviteCheckbox": "Enviar mensagem de convite"
|
||||
},
|
||||
"invitationNotification": {
|
||||
"body": "Mensagem enviada para {{ email }}"
|
||||
},
|
||||
"title": "Utilizadores e Grupos"
|
||||
"title": "Utilizadores",
|
||||
"2FAResetDialog": {
|
||||
"title": "Reiniciar 2FA do Utilizador",
|
||||
"description": "Remover a configuração existente de 2FA para o utilizador \"{{ username }}\"?"
|
||||
}
|
||||
},
|
||||
"login": {
|
||||
"2faToken": "Código 2FA",
|
||||
@@ -492,7 +516,6 @@
|
||||
"title": "Eliminar Arquivo de {{appTitle}} ({{fqdn}})"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"schedule": "Agendar",
|
||||
"days": "Dias",
|
||||
"hours": "Horas",
|
||||
"retentionPolicy": "Política de Retenção",
|
||||
@@ -503,10 +526,10 @@
|
||||
"contents": "Conteúdos",
|
||||
"version": "Versão",
|
||||
"noApps": "Sem Aplicações",
|
||||
"appCount": "{{ appCount }} aplicações",
|
||||
"backupNow": "Copiar Agora",
|
||||
"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 +539,8 @@
|
||||
"id": "Id.",
|
||||
"date": "Data",
|
||||
"version": "Versão",
|
||||
"list": "Referencia as cópias de segurança de {{ appCount }} aplicações"
|
||||
"size": "Tamanho",
|
||||
"duration": "Duração"
|
||||
}
|
||||
},
|
||||
"passwordReset": {
|
||||
@@ -756,35 +780,100 @@
|
||||
"checkIntegrity": "Verificar Integridade"
|
||||
},
|
||||
"import": {
|
||||
"title": "Importar da Cópia de Segurança Externa"
|
||||
"title": "Importar da Cópia de Segurança Externa",
|
||||
"description": "Importar a aplicação de uma cópia de segurança externa."
|
||||
},
|
||||
"auto": {
|
||||
"title": "Cópias de segurança automáticas"
|
||||
}
|
||||
},
|
||||
"repair": {
|
||||
"taskError": {
|
||||
"description": "Se uma instalação, configuração, atualização, restauração ou cópia de segurança resultou num erro, pode tentar novamente a tarefa.",
|
||||
"retryAction": "Repetir Tarefa {{ task }}"
|
||||
"description": "Repetir uma instalação falhada, configuração, atualização, restauro, ou tarefa de cópia de segurança.",
|
||||
"retryAction": "Repetir tarefa {{ task }}",
|
||||
"title": "Erro de tarefa"
|
||||
},
|
||||
"recovery": {
|
||||
"title": "Modo de Recuperação"
|
||||
"title": "Modo de Recuperação",
|
||||
"restartAction": "Reiniciar",
|
||||
"disableAction": "Desativar modo de recuperação",
|
||||
"enableAction": "Ativar modo de recuperação"
|
||||
},
|
||||
"restart": {
|
||||
"title": "Reiniciar",
|
||||
"description": "Se a aplicação não responder, tente reinstalar a mesma."
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
"info": {
|
||||
"customAppUpdateInfo": "A atualização automática não está disponível para as aplicações personalizadas.",
|
||||
"installedAt": "Instalado às",
|
||||
"lastUpdated": "Última Atualização",
|
||||
"packageVersion": "Versão do Pacote",
|
||||
"description": "Título e Versão da Aplicação"
|
||||
"installedAt": "Instalado",
|
||||
"lastUpdated": "Última atualização",
|
||||
"packageVersion": "Versão do pacote",
|
||||
"description": "Título e Versão da Aplicação",
|
||||
"appId": "Id. da Aplicação"
|
||||
},
|
||||
"auto": {
|
||||
"description": "As atualizações da aplicação são aplicadas periodicamente, com base no <a href=\"/#/system-update\">agendamento da atualização</a>",
|
||||
"title": "Atualizações automáticas"
|
||||
},
|
||||
"updates": {
|
||||
"description": "Cloudron procura automaticamente por atualizações na 'Loja de Aplicações'. Você também podes procurar manualmente."
|
||||
}
|
||||
},
|
||||
"security": {
|
||||
"hstsPreload": "Ativar pré-carregamento de HSTS para este site e todos os subdomínios"
|
||||
"hstsPreload": "Ativar Pré-carregamento de HSTS (incluindo os subdomínios)",
|
||||
"csp": {
|
||||
"title": "Política de Segurança de Conteúdo",
|
||||
"saveAction": "Guardar"
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"description": "Por predefinição, os robôs podem indexar esta aplicação."
|
||||
}
|
||||
},
|
||||
"forumAction": "Fórum",
|
||||
"resources": {
|
||||
"devices": {
|
||||
"label": "Dispositivos"
|
||||
}
|
||||
},
|
||||
"email": {
|
||||
"inbox": {
|
||||
"title": "Mensagens a receber",
|
||||
"enable": "Utilize Cloudron Mail para receber mensagens",
|
||||
"disable": "Não configurar caixa de entrada"
|
||||
},
|
||||
"from": {
|
||||
"title": "Correio dos endereços",
|
||||
"mailboxPlaceholder": "Nome da caixa de correio",
|
||||
"saveAction": "Guardar",
|
||||
"enable": "Utilize Cloudron Mail para enviar mensagens",
|
||||
"displayName": "De nome"
|
||||
},
|
||||
"configuration": {
|
||||
"title": "Correio a enviar"
|
||||
}
|
||||
},
|
||||
"graphs": {
|
||||
"period": {
|
||||
"1h": "1 hora",
|
||||
"12h": "12 horas",
|
||||
"24h": "24 horas",
|
||||
"7d": "7 dias",
|
||||
"30d": "30 dias",
|
||||
"6h": "6 horas"
|
||||
},
|
||||
"diskIOTotal": "Total de leitura: {{ read }} Total de gravação: {{ write }}",
|
||||
"networkIOTotal": "Total de a receber: {{ inbound }} Total de a enviar: {{ outbound }}"
|
||||
},
|
||||
"storage": {
|
||||
"mounts": {
|
||||
"permissions": {
|
||||
"readWrite": "Ler e Gravar",
|
||||
"label": "Permissões"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"logs": {
|
||||
@@ -823,10 +912,19 @@
|
||||
"name": "Nome",
|
||||
"id": "Id. do Cliente",
|
||||
"secret": "Segredo do Cliente",
|
||||
"signingAlgorithm": "Algoritmo de Assinatura"
|
||||
"signingAlgorithm": "Algoritmo de Assinatura",
|
||||
"loginRedirectUriPlaceholder": "URLs separados por vírgulas"
|
||||
},
|
||||
"env": {
|
||||
"discoveryUrl": "URL de Descobrir"
|
||||
},
|
||||
"clientCredentials": {
|
||||
"description": "Copiar as credenciais para o cliente \"{{ clientName }}\"",
|
||||
"title": "Credenciais de cliente"
|
||||
},
|
||||
"clients": {
|
||||
"title": "Clientes de OpenID",
|
||||
"empty": "Sem clientes de OpenID"
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
@@ -866,7 +964,6 @@
|
||||
"errorPassword": "A palavra-passe deve ter pelo menos 8 carateres",
|
||||
"errorPasswordNoMatch": "As palavra-passe não coincidem",
|
||||
"setupAction": "Configurar",
|
||||
"welcomeTo": "Bem-vindo ao",
|
||||
"description": "Por favor, configure a sua conta",
|
||||
"username": "Nome de utilizador",
|
||||
"success": {
|
||||
@@ -875,7 +972,8 @@
|
||||
},
|
||||
"noUsername": {
|
||||
"title": "Não é possível configurar a conta"
|
||||
}
|
||||
},
|
||||
"welcome": "Bem-vindo"
|
||||
},
|
||||
"passwordResetEmail": {
|
||||
"salutation": "Olá <%= user %>,",
|
||||
@@ -883,7 +981,14 @@
|
||||
},
|
||||
"backup": {
|
||||
"target": {
|
||||
"label": "Site da Cópia de Segurança"
|
||||
"label": "Site",
|
||||
"size": "Tamanho",
|
||||
"fileCount": "Ficheiros"
|
||||
},
|
||||
"sites": {
|
||||
"title": "Sites de Cópias de Segurança",
|
||||
"emptyPlaceholder": "Sem ''sites'' de cópia de segurança",
|
||||
"lastRun": "Última execução"
|
||||
}
|
||||
},
|
||||
"filemanager": {
|
||||
@@ -892,5 +997,21 @@
|
||||
"download": "Transferir"
|
||||
}
|
||||
}
|
||||
},
|
||||
"dockerRegistries": {
|
||||
"server": "Endereço do servidor",
|
||||
"provider": "Provedor",
|
||||
"username": "Nome de utilizador",
|
||||
"email": "E-mail",
|
||||
"passwordToken": "Palavra-passe/Código"
|
||||
},
|
||||
"appearance": {
|
||||
"title": "Aparência"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Painel"
|
||||
},
|
||||
"server": {
|
||||
"title": "Servidor"
|
||||
}
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -10,9 +10,6 @@
|
||||
"yes": "ඔව්"
|
||||
},
|
||||
"username": "පරිශීලක නාමය",
|
||||
"table": {
|
||||
"date": "දිනය"
|
||||
},
|
||||
"searchPlaceholder": "සොයන්න",
|
||||
"multiselect": {
|
||||
"select": "තෝරන්න"
|
||||
|
||||
@@ -8,14 +8,15 @@
|
||||
"title": "App của tôi",
|
||||
"noApps": {
|
||||
"title": "Chưa có app cài đặt!",
|
||||
"description": "Cài đặt một vài app nhé? Hãy xem trong <a href=\"{{ appStoreLink }}\">Cửa hàng App</a>"
|
||||
"description": "Cài đặt một vài app nhé? Hãy xem trong <a href=\"{{ appStoreLink }}\">Cửa hàng App</a>."
|
||||
},
|
||||
"auth": {
|
||||
"email": "Đăng nhập bằng email",
|
||||
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
|
||||
"nosso": "Đăng nhập bằng tài khoản riêng",
|
||||
"openid": "Đăng nhập bằng Cloudron OpenID"
|
||||
}
|
||||
},
|
||||
"noMatchesPlaceholder": "Không có app tương ứng"
|
||||
},
|
||||
"main": {
|
||||
"logout": "Thoát",
|
||||
@@ -32,16 +33,23 @@
|
||||
"username": "Tên đăng nhập",
|
||||
"displayName": "Tên hiển thị",
|
||||
"table": {
|
||||
"date": "Ngày"
|
||||
"version": "Phiên bản"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Khởi động lại",
|
||||
"logs": "Log"
|
||||
"logs": "Log",
|
||||
"remove": "Xóa",
|
||||
"edit": "Chỉnh sửa",
|
||||
"add": "Thêm",
|
||||
"next": "Kế tiếp",
|
||||
"configure": "Cấu hình",
|
||||
"restart": "Khởi động lại",
|
||||
"reset": "Đặt lại"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"title": "Chắc chắn muốn khởi động lại server?",
|
||||
"title": "Khởi động lại server",
|
||||
"rebootAction": "Khởi động lại ngay",
|
||||
"description": "Sử dụng chức năng này cho bản cập nhật an ninh hay khi hệ thống gặp trục trặc ngoài ý muốn. Tất cả app và dịch vụ đang chạy trên Cloudron sẽ tự động chạy lại sau khi khởi động lại hoàn thành."
|
||||
"description": "Tất cả app và dịch vụ sẽ tự động khởi động lại.<br/><br/>Khởi động lại máy chủ ngay bây giờ?"
|
||||
},
|
||||
"actions": "Thao tác",
|
||||
"offline": "Cloudron đang offline. Đang kết nối lại…",
|
||||
@@ -52,9 +60,13 @@
|
||||
},
|
||||
"statusEnabled": "Đã bật",
|
||||
"navbar": {
|
||||
"users": "Người dùng"
|
||||
"users": "Người dùng",
|
||||
"groups": "Nhóm"
|
||||
},
|
||||
"loadingPlaceholder": "Đang tải"
|
||||
"loadingPlaceholder": "Đang tải",
|
||||
"platform": {
|
||||
"startupFailed": "Khởi động nền tảng không thành công"
|
||||
}
|
||||
},
|
||||
"appstore": {
|
||||
"title": "Cửa hàng App",
|
||||
@@ -71,27 +83,28 @@
|
||||
"locationPlaceholder": "Để trống để dùng tên miền gốc",
|
||||
"manualWarning": "Cài đặt thủ công bản ghi DNS A (IPv4) và AAAA (IPv6) cho <b>{{ location }}</b> chỉ về máy chủ này",
|
||||
"userManagement": "Quản lý người dùng",
|
||||
"userManagementMailbox": "Tất cả người dùng với hộp thư trên Cloudron này có quyền truy cập app.",
|
||||
"userManagementMailbox": "Tất cả người dùng với một <a href=\"/#/mailboxes\">hộp thư</a> có thể đăng nhập bằng email hộp thư và mật khẩu Cloudron.",
|
||||
"userManagementLeaveToApp": "Để app quản lý người dùng",
|
||||
"userManagementAllUsers": "Cho phép tất cả người dùng trên Cloudron truy cập",
|
||||
"errorUserManagementSelectAtLeastOne": "Chọn ít nhất một người dùng hay nhóm",
|
||||
"users": "Người dùng",
|
||||
"groups": "Nhóm",
|
||||
"userManagementNone": "App này có phần quản lý người dùng riêng. Cài đặt này điều chỉnh app có hiển thị hay không trên bảng dashboard của người dùng.",
|
||||
"userManagementNone": "App này có phần quản lý người dùng riêng.",
|
||||
"userManagementSelectUsers": "Chỉ cho phép người dùng và nhóm sau",
|
||||
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
|
||||
"cloudflarePortWarning": "Cần tắt proxy Cloudflare để tên miền app này có thể truy cập được vào cổng",
|
||||
"portReadOnly": "chỉ-đọc"
|
||||
"portReadOnly": "chỉ-đọc",
|
||||
"ephemeralPortWarning": "Sử dụng cổng ngẫu nhiên có thể gây ra xung đột không lường trước được."
|
||||
},
|
||||
"appNotFoundDialog": {
|
||||
"title": "Không tìm thấy app",
|
||||
"description": "Không có app <b>{{ appId }}</b> với phiên bản <b>{{ version }}</b>."
|
||||
},
|
||||
"searchPlaceholder": "Tìm kiếm app thay thế cho Github, Dropbox, Slack, Trello, …"
|
||||
"searchPlaceholder": "Tìm kiếm app thay thế cho GitHub, Dropbox, Slack, Trello, …"
|
||||
},
|
||||
"users": {
|
||||
"editUserDialog": {
|
||||
"title": "Chỉnh sửa người dùng {{ username }}",
|
||||
"title": "Chỉnh sửa người dùng",
|
||||
"externalLdapWarning": "Người dùng này được đồng bộ từ thư mục LDAP ngoài."
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
@@ -136,8 +149,8 @@
|
||||
"acceptSelfSignedCert": "Chấp nhận chứng chỉ số tự ký",
|
||||
"server": "URL server",
|
||||
"provider": "Nhà cung cấp",
|
||||
"noopInfo": "Xác thực LDAP chưa được thiết lập.",
|
||||
"description": "Cài đặt này đồng bộ và xác thực người dùng và nhóm từ một server LDAP hay ActiveDirectory bên ngoài. Sự đồng bộ hóa này được chạy theo chu kỳ nhưng cũng có thể được khởi động bằng tay.",
|
||||
"noopInfo": "Không có thư mục ngoài nào được thiết lập",
|
||||
"description": "Đồng bộ hóa và cho phép người dùng và nhóm từ một server LDAP hay Active Directory bên ngoài. Quá trình đồng bộ được chạy định kỳ mỗi 4 tiếng.",
|
||||
"title": "Kết nối thư mục ngoài",
|
||||
"disableWarning": "Nguồn mã xác minh cho tất cả người dùng hiện hữu sẽ được cài đặt lại dựa trên cơ sở dữ liệu mật khẩu nội bộ trên server."
|
||||
},
|
||||
@@ -151,37 +164,42 @@
|
||||
"empty": "Không tìm thấy người dùng",
|
||||
"groups": "Nhóm",
|
||||
"user": "Người dùng",
|
||||
"invitationTooltip": "Mời Người dùng",
|
||||
"invitationTooltip": "Mời",
|
||||
"setGhostTooltip": "Nhập vai",
|
||||
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư"
|
||||
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư",
|
||||
"noMatchesPlaceholder": "Không có người dùng tương ứng",
|
||||
"emptyPlaceholder": "Không có người dùng"
|
||||
},
|
||||
"settings": {
|
||||
"saveAction": "Lưu",
|
||||
"require2FACheckbox": "Yêu cầu người dùng cài đặt Mã xác minh 2 bước",
|
||||
"allowProfileEditCheckbox": "Cho phép người dùng chỉnh sửa tên và email"
|
||||
"allowProfileEditCheckbox": "Cho phép người dùng chỉnh sửa tên và email",
|
||||
"title": "Cài đặt"
|
||||
},
|
||||
"groups": {
|
||||
"externalLdapTooltip": "Từ thư mục LDAP ngoài",
|
||||
"users": "Người dùng",
|
||||
"name": "Tên",
|
||||
"emptyPlaceholder": "Chưa có nhóm nào cả"
|
||||
"emptyPlaceholder": "Chưa có nhóm",
|
||||
"noMatchesPlaceholder": "Không có nhóm tương ứng"
|
||||
},
|
||||
"editGroupDialog": {
|
||||
"title": "Chỉnh sửa nhóm {{ name }}",
|
||||
"title": "Chỉnh sửa nhóm",
|
||||
"externalLdapWarning": "Nhóm này được đồng bộ từ thư mục LDAP ngoài."
|
||||
},
|
||||
"group": {
|
||||
"addGroupAction": "Thêm nhóm",
|
||||
"addGroupAction": "Thêm",
|
||||
"users": "Người dùng",
|
||||
"name": "Tên"
|
||||
"name": "Tên",
|
||||
"allowedApps": "App được cấp phép"
|
||||
},
|
||||
"addGroupDialog": {
|
||||
"title": "Thêm nhóm"
|
||||
},
|
||||
"deleteGroupDialog": {
|
||||
"description": "Nhóm này vẫn còn {{ memberCount }} thành viên. Bạn có chắc nhóm hiện đang không được sử dụng?",
|
||||
"description": "Nhóm này vẫn còn {{ memberCount }} thành viên. <br/><br/>Xóa nhóm \"{{ name }}\"?",
|
||||
"deleteAction": "Xoá",
|
||||
"title": "Xoá nhóm {{ name }}"
|
||||
"title": "Xoá nhóm"
|
||||
},
|
||||
"passwordResetDialog": {
|
||||
"title": "Đặt lại mật khẩu cho {{ username }}",
|
||||
@@ -217,8 +235,8 @@
|
||||
},
|
||||
"setGhostDialog": {
|
||||
"generatePassword": "Tạo mật khẩu",
|
||||
"title": "Tạo mật khẩu để nhập vai người dùng {{ username }}",
|
||||
"description": "Cài đặt một mật khẩu tạm thời để đăng nhập vào thay mặt người dùng trong các app hoặc dashboard. Mật khẩu tạm thời chỉ có hiệu lực trong vòng 6 tiếng.",
|
||||
"title": "Nhập vai người dùng",
|
||||
"description": "Đặt một mật khẩu tạm thời để đăng nhập vào thay mặt người dùng trong các app hoặc dashboard. Mật khẩu tạm thời chỉ có hiệu lực trong vòng 6 tiếng.",
|
||||
"password": "Mật khẩu",
|
||||
"setPassword": "Cài mật khẩu"
|
||||
},
|
||||
@@ -226,11 +244,15 @@
|
||||
"body": "Email đã được gửi đến {{ email }}"
|
||||
},
|
||||
"invitationDialog": {
|
||||
"title": "Mời {{ username }}",
|
||||
"title": "Mời người dùng",
|
||||
"description": "Đường link mời sau đây đã được gửi đến {{ email }}:",
|
||||
"sendAction": "Gửi mail",
|
||||
"descriptionLink": "Sao chép đường link mời",
|
||||
"descriptionEmail": "Gửi link mời"
|
||||
"descriptionLink": "Đường link mời",
|
||||
"descriptionEmail": "Gửi link mời",
|
||||
"context": "Mời người dùng \"{{ username }}\""
|
||||
},
|
||||
"2FAResetDialog": {
|
||||
"description": "Xóa bảo mật 2 Bước cho người dùng “{{ username }}”?"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
@@ -248,25 +270,23 @@
|
||||
"title": "Tắt chế độ Xác minh 2 Bước"
|
||||
},
|
||||
"enable2FA": {
|
||||
"description": "Admin Cloudron của bạn yêu cầu tất cả thành viên phải bật chế độ xác minh hai bước. Bạn không thể truy cập dashboard cho đến khi bật chế độ này.",
|
||||
"title": "Bật chế độ Xác minh 2 Bước",
|
||||
"token": "Mã",
|
||||
"authenticatorAppDescription": "Dùng Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) hoặc một app TOTP tương tự để quét mã.",
|
||||
"enable": "Bật"
|
||||
"enable": "Bật",
|
||||
"mandatorySetup": "Cần có bảo mật 2 Bước để truy cập bảng điều khiển. Vui lòng hoàn thành cài đặt này để tiếp tục thao tác."
|
||||
},
|
||||
"createAppPassword": {
|
||||
"title": "Tạo mật khẩu app",
|
||||
"title": "Thêm 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:"
|
||||
},
|
||||
"createApiToken": {
|
||||
"title": "Tạo mã API",
|
||||
"title": "Thêm 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"
|
||||
@@ -277,14 +297,14 @@
|
||||
"appPasswords": {
|
||||
"app": "App",
|
||||
"name": "Tên",
|
||||
"noPasswordsPlaceholder": "Không có mật khẩu app được tạo",
|
||||
"noPasswordsPlaceholder": "Không có mật khẩu app",
|
||||
"title": "Mật khẩu app",
|
||||
"description": "Mật khẩu app là một biện pháp an ninh giúp bảo vệ tài khoản người dùng Cloudron của bạn. Khi bạn cần truy cập một app trong Cloudron từ một app điện thoại hay client không đáng tin cậy, bạn có thể đăng nhập bằng tên đăng nhập và mật khẩu app thay thế ở đây."
|
||||
},
|
||||
"apiTokens": {
|
||||
"title": "Mã API",
|
||||
"description": "Dùng những mã truy cập cá nhân này để xác minh cho <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>",
|
||||
"noTokensPlaceholder": "Không có mã API được tạo",
|
||||
"noTokensPlaceholder": "Không có mã API",
|
||||
"name": "Tên",
|
||||
"lastUsed": "Lần dùng cuối",
|
||||
"neverUsed": "chưa từng dùng",
|
||||
@@ -301,12 +321,12 @@
|
||||
},
|
||||
"changeEmail": {
|
||||
"title": "Thay đổi email chính",
|
||||
"email": "Thêm địa chỉ mail mới",
|
||||
"password": "Mật khẩu để xác nhận"
|
||||
"email": "Thêm email mới",
|
||||
"password": "Xác nhận bằng mật khẩu"
|
||||
},
|
||||
"disable2FAAction": "Tắt xác minh hai bước",
|
||||
"changeFallbackEmail": {
|
||||
"title": "Thay đổi email khôi phục mật khẩu"
|
||||
"title": "Đổi email khôi phục mật khẩu"
|
||||
},
|
||||
"changePasswordAction": "Đổi mật khẩu",
|
||||
"title": "Hồ sơ",
|
||||
@@ -314,10 +334,12 @@
|
||||
"body": "Email đã được gửi đến {{ email }}"
|
||||
},
|
||||
"removeApiToken": {
|
||||
"title": "Chắc chắn xóa mã token {{ name }}?"
|
||||
"title": "Xóa mã token API",
|
||||
"description": "Xóa mã token API \"{{ name }}\" ?"
|
||||
},
|
||||
"removeAppPassword": {
|
||||
"title": "Chắc chắn xóa mật khẩu {{ name }}?"
|
||||
"title": "Xóa mật khẩu app",
|
||||
"description": "Xóa mật khẩu app \"{{ name }}\" ?"
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -330,7 +352,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,12 +395,9 @@
|
||||
"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": {
|
||||
"list": "Tham chiếu sao lưu của {{ appCount }} app",
|
||||
"version": "Phiên bản",
|
||||
"date": "Thời gian",
|
||||
"id": "ID",
|
||||
@@ -387,20 +405,20 @@
|
||||
},
|
||||
"listing": {
|
||||
"backupNow": "Sao lưu ngay bây giờ",
|
||||
"cleanupBackups": "Dọn sạch bản sao lưu",
|
||||
"tooltipDownloadBackupConfig": "Tải xuống cấu hình bản sao lưu",
|
||||
"cleanupBackups": "Xóa bản sao lưu",
|
||||
"tooltipDownloadBackupConfig": "Tải xuống cấu hình",
|
||||
"appCount": "{{ appCount }} app",
|
||||
"noApps": "Không có app nào cả",
|
||||
"version": "Phiên bản",
|
||||
"contents": "Nội dung",
|
||||
"noBackups": "Chưa có bản sao lưu nào được tạo.",
|
||||
"title": "Danh sách",
|
||||
"noBackups": "Không có bản sao lưu",
|
||||
"title": "Bản sao lưu hệ thống",
|
||||
"tooltipPreservedBackup": "Bản sao này sẽ được giữ lại"
|
||||
},
|
||||
"schedule": {
|
||||
"retentionPolicy": "Thời gian lưu giữ",
|
||||
"schedule": "Lịch sao lưu",
|
||||
"title": "Lịch sao lưu và thời gian lưu giữ"
|
||||
"title": "Lịch sao lưu & thời gian lưu giữ"
|
||||
},
|
||||
"backupEdit": {
|
||||
"preserved": {
|
||||
@@ -455,7 +473,6 @@
|
||||
"password": "Mật khẩu mới",
|
||||
"fullName": "Họ tên",
|
||||
"description": "Xin cài đặt tài khoản của bạn",
|
||||
"welcomeTo": "Chào mừng đến",
|
||||
"noUsername": {
|
||||
"description": "Tài khoản không thể được tạo khi thiếu tên đăng nhập.",
|
||||
"title": "Không thể tạo tài khoản"
|
||||
@@ -466,7 +483,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 +599,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"
|
||||
@@ -817,7 +829,6 @@
|
||||
"appstoreAccount": {
|
||||
"subscriptionReactivateAction": "Kích hoạt lại gói đăng ký",
|
||||
"subscriptionChangeAction": "Quản lý gói đăng ký",
|
||||
"subscriptionEndsAt": "Đã huỷ đăng ký và kết thúc vào",
|
||||
"cloudronId": "Mã Cloudron ID",
|
||||
"subscription": "Gói đăng ký",
|
||||
"setupAction": "Cài đặt tài khoản",
|
||||
@@ -831,7 +842,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,10 +1024,8 @@
|
||||
"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.",
|
||||
"vultrToken": "Mật mã Vultr",
|
||||
"jitsiHostname": "Vị trí Jitsi",
|
||||
"hetznerToken": "Mật mã Hetzner",
|
||||
@@ -1055,11 +1063,7 @@
|
||||
"title": "Đồng bộ DNS",
|
||||
"description": "Lựa chọn này sẽ cấp lại các bản ghi DNS cho app và email cho tất cả tên miền.",
|
||||
"syncAction": "Đồng bộ DNS"
|
||||
},
|
||||
"domainWellKnown": {
|
||||
"title": "Những vị trí Well-Known của {{ domain }}"
|
||||
},
|
||||
"tooltipWellKnown": "Cài đặt những vị trí Well-Known"
|
||||
}
|
||||
},
|
||||
"app": {
|
||||
"appInfo": {
|
||||
@@ -1108,7 +1112,6 @@
|
||||
"restoreTooltip": "Khôi phục app trở về bản sao lưu này",
|
||||
"cloneTooltip": "Nhân bản app từ bản sao lưu này",
|
||||
"downloadConfigTooltip": "Tải xuống cấu hình bản sao lưu",
|
||||
"time": "Tạo ra lúc",
|
||||
"description": "Bản sao lưu là những bản chụp snapshot hoàn chỉnh của app. Bạn có thể dùng các bản sao lưu để khôi phục hoặc nhân bản app này.",
|
||||
"title": "Bản sao lưu",
|
||||
"downloadBackupTooltip": "Tải bản sao lưu"
|
||||
@@ -1126,8 +1129,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 +1286,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"
|
||||
},
|
||||
@@ -1384,7 +1384,6 @@
|
||||
"inviteLinkAction": "Bắt đầu tạo tải khoản",
|
||||
"subject": "Chào mừng đến <%= cloudron %>",
|
||||
"inviteLinkActionText": "Bấm theo link để bắt đầu: <%- inviteLink %>",
|
||||
"expireNote": "Link mời sẽ hết hạn trong 7 ngày.",
|
||||
"invitor": "Bạn nhận được mail này vì <%= invitor %> đã mời bạn tham gia.",
|
||||
"salutation": "Xin chào <%= user %>,",
|
||||
"welcomeTo": "Chào mừng đến <%= cloudronName %>!"
|
||||
|
||||
@@ -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": "停用双因素验证",
|
||||
@@ -61,7 +59,6 @@
|
||||
"title": "启用双因素验证",
|
||||
"token": "动态验证码",
|
||||
"enable": "启用",
|
||||
"description": "您的 Cloudron 管理员要求所有用户启用双因素验证,在启用之前您无法使用控制面板。",
|
||||
"authenticatorAppDescription": "使用 Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP authenticator (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) 或类似的动态验证码 App 来扫描。"
|
||||
},
|
||||
"appPasswords": {
|
||||
@@ -114,16 +111,13 @@
|
||||
"title": "备份详情",
|
||||
"id": "Id",
|
||||
"date": "日期",
|
||||
"version": "版本",
|
||||
"list": "备份了下列 {{ appCount }} 个应用"
|
||||
"version": "版本"
|
||||
},
|
||||
"configureBackupSchedule": {
|
||||
"title": "配置备份计划和保留时间",
|
||||
"scheduleDescription": "选择 Cloudron 备份的日期和时间。请注意这个安排不要和 <a href=\"/#/settings\">升级计划</a> 重合。",
|
||||
"hours": "小时",
|
||||
"days": "星期",
|
||||
"retentionPolicy": "保留时间",
|
||||
"schedule": "备份计划"
|
||||
"retentionPolicy": "保留时间"
|
||||
},
|
||||
"configureBackupStorage": {
|
||||
"title": "配置备份的存储",
|
||||
@@ -155,7 +149,6 @@
|
||||
"memoryLimitDescription": "备份任务的内存限制。如果您增加了并发值,请调整内存上限。",
|
||||
"copyConcurrency": "并发数",
|
||||
"copyConcurrencyDescription": "当备份时同时复制几个文件。",
|
||||
"copyConcurrencyDigitalOceanNote": "DigitalOcean Spaces 的上限为 20。",
|
||||
"s3LikeNote": "请不要在 S3 存储桶上设置 lifecycle 规则,因为这会导致 rsync 备份损坏。",
|
||||
"server": "服务器 IP 或 Hostname",
|
||||
"cifsSealSupport": "使用 seal 加密。需要 SMB v3 以上版本",
|
||||
@@ -190,9 +183,6 @@
|
||||
"username": "用户名",
|
||||
"displayName": "昵称",
|
||||
"actions": "操作",
|
||||
"table": {
|
||||
"date": "日期"
|
||||
},
|
||||
"action": {
|
||||
"reboot": "重启",
|
||||
"logs": "日志"
|
||||
@@ -530,7 +520,6 @@
|
||||
"setupAction": "设置账户",
|
||||
"subscription": "订阅",
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "已取消并将终止于",
|
||||
"subscriptionChangeAction": "更改订阅",
|
||||
"subscriptionReactivateAction": "重新激活订阅"
|
||||
},
|
||||
@@ -632,7 +621,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 +779,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 +803,6 @@
|
||||
"description": "下列文本会被附在所有从本域名发出的邮件的末尾。",
|
||||
"title": "签名"
|
||||
},
|
||||
"masquerading": {
|
||||
"description": "Masquerading 允许用户和应用在发送邮件时,在发件人一栏使用任意的用户名。",
|
||||
"title": "Masquerading"
|
||||
},
|
||||
"outbound": {
|
||||
"mailRelay": {
|
||||
"spfDocInfo": "Cloudron 无法自动设置 SPF 记录。请按照 <a href=\"{{ spfDocsLink }}\" target=\"_blank\">{{ name }} 文档</a> 手动设置。",
|
||||
@@ -989,9 +972,7 @@
|
||||
"description": "使用此设置来覆盖应用自带的 CSP header"
|
||||
},
|
||||
"robots": {
|
||||
"title": "Robots.txt",
|
||||
"txtPlaceholder": "留空以允许所有 bots 爬取此应用",
|
||||
"disableIndexingAction": "禁止爬取"
|
||||
"title": "Robots.txt"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
@@ -1040,7 +1021,6 @@
|
||||
"importAction": "导入备份",
|
||||
"title": "备份",
|
||||
"description": "备份是应用的完整快照。你可以使用应用的备份来恢复或者克隆该应用。",
|
||||
"time": "创建于",
|
||||
"downloadConfigTooltip": "下载备份的配置文件",
|
||||
"cloneTooltip": "由此备份克隆"
|
||||
},
|
||||
@@ -1067,7 +1047,6 @@
|
||||
},
|
||||
"importBackupDialog": {
|
||||
"title": "导入备份",
|
||||
"description": "从上次备份到当前状态之间产生的所有数据都会丢失。我们建议在导入数据之前为当前数据创建一个手动备份。",
|
||||
"uploadAction": "上传备份配置文件",
|
||||
"importAction": "导入"
|
||||
},
|
||||
@@ -1112,7 +1091,6 @@
|
||||
"welcomeEmail": {
|
||||
"salutation": "<%= user %> 你好,",
|
||||
"inviteLinkAction": "开始",
|
||||
"expireNote": "请注意,邀请链接会在 7 天内失效。",
|
||||
"invitor": "您收到了 <%= invitor %> 的邀请注册邮件。",
|
||||
"inviteLinkActionText": "使用这个链接来开始注册:<%- inviteLink %>",
|
||||
"subject": "欢迎来到 <%= cloudron %>",
|
||||
@@ -1149,7 +1127,6 @@
|
||||
"title": "账户已就绪",
|
||||
"openDashboardAction": "打开控制面板"
|
||||
},
|
||||
"welcomeTo": "欢迎来到",
|
||||
"description": "请设置你的账户",
|
||||
"username": "用户名",
|
||||
"password": "新密码",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Cloudron Restore</title>
|
||||
<title>Restore Cloudron</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<title>Cloudron Domain Setup</title>
|
||||
<title>Domain Setup</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app" style="overflow: hidden; height: 100%;"></div>
|
||||
|
||||
@@ -4,10 +4,11 @@
|
||||
<title><%= name %> Account Setup</title>
|
||||
|
||||
<script>
|
||||
window.cloudron = {};
|
||||
window.cloudron.name = '<%= name %>';
|
||||
window.cloudron.footer = `<%- footer -%>`;
|
||||
window.cloudron.language = `<%= language %>`;
|
||||
window.cloudron = <%- JSON.stringify({
|
||||
name: name,
|
||||
footer: footer,
|
||||
language: language
|
||||
}) %>;
|
||||
</script>
|
||||
|
||||
</head>
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
<script setup>
|
||||
|
||||
import { onMounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, fetcher, SideBar } from '@cloudron/pankow';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, provide } from 'vue';
|
||||
import { Notification, fetcher } from '@cloudron/pankow';
|
||||
import { setLanguage } from './i18n.js';
|
||||
import { API_ORIGIN, TOKEN_TYPES } from './constants.js';
|
||||
import { redirectIfNeeded } from './utils.js';
|
||||
@@ -11,7 +15,9 @@ import DashboardModel from './models/DashboardModel.js';
|
||||
import BrandingModel from './models/BrandingModel.js';
|
||||
import Headerbar from './components/Headerbar.vue';
|
||||
import SubscriptionRequiredDialog from './components/SubscriptionRequiredDialog.vue';
|
||||
import RequestErrorDialog from './components/RequestErrorDialog.vue';
|
||||
import OfflineOverlay from './components/OfflineOverlay.vue';
|
||||
import SideBar from './components/SideBar.vue';
|
||||
import AppsView from './views/AppsView.vue';
|
||||
import AppConfigureView from './views/AppConfigureView.vue';
|
||||
import AppearanceView from './views/AppearanceView.vue';
|
||||
@@ -72,6 +78,174 @@ const VIEWS = Object.freeze({
|
||||
VOLUMES: '#/volumes',
|
||||
});
|
||||
|
||||
const menuItems = ref([{
|
||||
label: t('apps.title'),
|
||||
icon: 'fa fa-grip fa-fw',
|
||||
route: VIEWS.APPS,
|
||||
active: () => view.value === VIEWS.APPS || view.value === VIEWS.APP,
|
||||
}, {
|
||||
label: t('appstore.title'),
|
||||
icon: 'fa fa-cloud-download-alt fa-fw',
|
||||
route: VIEWS.APPSTORE,
|
||||
active: () => view.value === VIEWS.APPSTORE,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
label: t('domains.title'),
|
||||
icon: 'fa fa-globe fa-fw',
|
||||
route: VIEWS.DOMAINS,
|
||||
active: () => view.value === VIEWS.DOMAINS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('users.title'),
|
||||
icon: 'fa fa-users-gear fa-fw',
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
active: () => view.value === VIEWS.APPS || view.value === VIEWS.APP,
|
||||
childItems: [{
|
||||
label: t('main.navbar.users'),
|
||||
icon: 'fa fa-user fa-fw',
|
||||
route: VIEWS.USERS,
|
||||
active: () => view.value === VIEWS.USERS,
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
}, {
|
||||
label: t('main.navbar.groups'),
|
||||
icon: 'fa fa-users fa-fw',
|
||||
route: VIEWS.GROUPS,
|
||||
active: () => view.value === VIEWS.GROUPS,
|
||||
visible: () => profile.value.isAtLeastUserManager,
|
||||
}, {
|
||||
label: 'LDAP',
|
||||
icon: 'fa fa-fw fa-users-rays',
|
||||
route: VIEWS.LDAP,
|
||||
active: () => view.value === VIEWS.LDAP,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: 'OpenID',
|
||||
icon: 'fa fa-fw fa-brands fa-openid',
|
||||
route: VIEWS.OPENID,
|
||||
active: () => view.value === VIEWS.OPENID,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('userdirectory.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.USER_DIRECTORY_SETTINGS,
|
||||
active: () => view.value === VIEWS.USER_DIRECTORY_SETTINGS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}],
|
||||
}, {
|
||||
label: t('emails.title'),
|
||||
icon: 'fa fa-envelope fa-fw',
|
||||
visible: () => profile.value.isAtLeastMailManager,
|
||||
childItems: [{
|
||||
label: 'Domains',
|
||||
icon: 'fa fa-fw fa-globe',
|
||||
route: VIEWS.EMAIL_DOMAINS,
|
||||
active: () => view.value === VIEWS.EMAIL_DOMAINS || view.value === VIEWS.EMAIL_DOMAIN,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('email.incoming.mailboxes.title'),
|
||||
icon: 'fa fa-fw fa-inbox',
|
||||
route: VIEWS.MAILBOXES,
|
||||
active: () => view.value === VIEWS.MAILBOXES,
|
||||
}, {
|
||||
label: t('email.incoming.mailinglists.title'),
|
||||
icon: 'fa fa-fw-solid fa-envelopes-bulk',
|
||||
route: VIEWS.MAILINGLISTS,
|
||||
active: () => view.value === VIEWS.MAILINGLISTS,
|
||||
}, {
|
||||
label: t('emails.eventlog.title'),
|
||||
icon: 'fa fa-fw fa-list-alt',
|
||||
route: VIEWS.EMAIL_EVENTLOG,
|
||||
active: () => view.value === VIEWS.EMAIL_EVENTLOG,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('emails.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.EMAIL_SETTINGS,
|
||||
active: () => view.value === VIEWS.EMAIL_SETTINGS,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}]
|
||||
}, {
|
||||
label: t('network.title'),
|
||||
icon: 'fas fa-network-wired fa-fw',
|
||||
route: VIEWS.NETWORK,
|
||||
active: () => view.value === VIEWS.NETWORK,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('volumes.title'),
|
||||
icon: 'fa fa-hdd fa-fw',
|
||||
route: VIEWS.VOLUMES,
|
||||
active: () => view.value === VIEWS.VOLUMES,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('backups.title'),
|
||||
icon: 'fa fa-archive fa-fw',
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
childItems: [{
|
||||
label: t('backups.sites.title'),
|
||||
icon: 'fa fa-fw fa-hard-drive',
|
||||
route: VIEWS.BACKUP_SITES,
|
||||
active: () => view.value === VIEWS.BACKUP_SITES,
|
||||
}, {
|
||||
label: t('backups.archives.title'),
|
||||
icon: 'fa fa-fw fa-grip',
|
||||
route: VIEWS.APP_ARCHIVE,
|
||||
active: () => view.value === VIEWS.APP_ARCHIVE,
|
||||
}]
|
||||
}, {
|
||||
label: t('appearance.title'),
|
||||
icon: 'fa fa-pen-ruler fa-fw',
|
||||
route: VIEWS.APPEARANCE,
|
||||
active: () => view.value === VIEWS.APPEARANCE,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('system.title'),
|
||||
icon: 'fa fa-server fa-fw',
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
childItems: [{
|
||||
label: 'Docker',
|
||||
icon: 'fa-brands fa-fw fa-docker',
|
||||
route: VIEWS.DOCKER,
|
||||
active: () => view.value === VIEWS.DOCKER,
|
||||
}, {
|
||||
label: t('services.title'),
|
||||
icon: 'fa fa-diagram-project fa-fw',
|
||||
route: VIEWS.SERVICES,
|
||||
active: () => view.value === VIEWS.SERVICES,
|
||||
}, {
|
||||
label: t('eventlog.title'),
|
||||
icon: 'fa fa-list-alt fa-fw',
|
||||
route: VIEWS.SYSTEM_EVENTLOG,
|
||||
active: () => view.value === VIEWS.SYSTEM_EVENTLOG,
|
||||
}, {
|
||||
label: t('settings.updates.title'),
|
||||
icon: 'fa fa-fw fa-square-up-right',
|
||||
route: VIEWS.SYSTEM_UPDATE,
|
||||
active: () => view.value === VIEWS.SYSTEM_UPDATE,
|
||||
}, {
|
||||
label: t('system.settings.title'),
|
||||
icon: 'fa fa-fw fa-screwdriver-wrench',
|
||||
route: VIEWS.SYSTEM_SETTINGS,
|
||||
active: () => view.value === VIEWS.SYSTEM_SETTINGS,
|
||||
}]
|
||||
}, {
|
||||
separator: true,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('server.title'),
|
||||
icon: 'fa fa-fw fa-microchip',
|
||||
route: VIEWS.SERVER,
|
||||
active: () => view.value === VIEWS.SERVER,
|
||||
visible: () => profile.value.isAtLeastAdmin,
|
||||
}, {
|
||||
label: t('settings.appstoreAccount.title'),
|
||||
icon: 'fa fa-fw fa-crown',
|
||||
route: VIEWS.CLOUDRON_ACCOUNT,
|
||||
active: () => view.value === VIEWS.CLOUDRON_ACCOUNT,
|
||||
visible: () => profile.value.isAtLeastOwner,
|
||||
}]);
|
||||
|
||||
const offlineOverlay = useTemplateRef('offlineOverlay');
|
||||
|
||||
fetcher.globalOptions.errorHook = (error) => {
|
||||
@@ -100,7 +274,6 @@ const dashboardModel = DashboardModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
const provisionModel = ProvisionModel.create();
|
||||
|
||||
const sidebar = useTemplateRef('sidebar');
|
||||
const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
|
||||
const ready = ref(false);
|
||||
const view = ref('');
|
||||
@@ -113,24 +286,8 @@ const config = ref({});
|
||||
const avatarUrl = ref('');
|
||||
const features = ref({});
|
||||
|
||||
function onSidebarClose() {
|
||||
sidebar.value.close();
|
||||
}
|
||||
|
||||
const SIDEBAR_GROUPS = Object.freeze({
|
||||
BACKUP: 'backup',
|
||||
EMAIL: 'email',
|
||||
SYSTEM: 'system',
|
||||
USERS: 'users'
|
||||
});
|
||||
|
||||
const activeSidebarGroups = ref({});
|
||||
function onToggleGroup(group) {
|
||||
activeSidebarGroups.value[group] = !activeSidebarGroups.value[group];
|
||||
}
|
||||
|
||||
function onHashChange() {
|
||||
const v = location.hash;
|
||||
const v = window.location.hash.split('?')[0];
|
||||
|
||||
if (v === VIEWS.APPS) {
|
||||
view.value = VIEWS.APPS;
|
||||
@@ -209,13 +366,13 @@ ProfileModel.onChange(ProfileModel.KEYS.AVATAR, (value) => {
|
||||
|
||||
async function refreshProfile() {
|
||||
const [error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
profile.value = result;
|
||||
}
|
||||
|
||||
async function refreshConfigAndFeatures() {
|
||||
const [error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
const currentVersion = localStorage.getItem('version');
|
||||
if (currentVersion === null) {
|
||||
@@ -236,16 +393,24 @@ async function onOnline() {
|
||||
await refreshConfigAndFeatures(); // reload dashboard if needed after an update
|
||||
}
|
||||
|
||||
const isMobile = ref(window.innerWidth <= 576);
|
||||
function checkForMobile() {
|
||||
isMobile.value = window.innerWidth <= 576;
|
||||
}
|
||||
|
||||
provide('subscriptionRequiredDialog', subscriptionRequiredDialog);
|
||||
provide('features', features);
|
||||
provide('profile', profile);
|
||||
provide('refreshProfile', refreshProfile);
|
||||
provide('refreshFeatures', refreshConfigAndFeatures);
|
||||
provide('dashboardDomain', dashboardDomain);
|
||||
provide('isMobile', isMobile);
|
||||
|
||||
onMounted(async () => {
|
||||
window.addEventListener('resize', checkForMobile);
|
||||
|
||||
const [error, result] = await provisionModel.status();
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
if (redirectIfNeeded(result, 'dashboard')) return; // redirected to some other view...
|
||||
|
||||
@@ -266,9 +431,8 @@ 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`;
|
||||
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.href = VIEWS.PROFILE;
|
||||
|
||||
window.addEventListener('hashchange', onHashChange);
|
||||
onHashChange();
|
||||
@@ -278,6 +442,10 @@ onMounted(async () => {
|
||||
ready.value = true;
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', checkForMobile);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -285,71 +453,10 @@ onMounted(async () => {
|
||||
<Notification />
|
||||
<OfflineOverlay ref="offlineOverlay" @online="onOnline()" :href="'https://docs.cloudron.io/troubleshooting/'" :label="$t('main.offline')" />
|
||||
<SubscriptionRequiredDialog ref="subscriptionRequiredDialog"/>
|
||||
<RequestErrorDialog/>
|
||||
|
||||
<div v-if="ready" style="display: flex; flex-direction: row; overflow: hidden; height: 100%;">
|
||||
<SideBar v-if="profile.isAtLeastUserManager" ref="sidebar">
|
||||
<a href="#/" class="sidebar-logo" @click="onSidebarClose()">
|
||||
<img :src="avatarUrl" :alt="(config.cloudronName || 'Cloudron') + ' icon'" width="40" height="40"/> {{ config.cloudronName || 'Cloudron' }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPS || view === VIEWS.APP }" :href="VIEWS.APPS" @click="onSidebarClose()"><i class="fa fa-grip fa-fw"></i> {{ $t('apps.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPSTORE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPSTORE" @click="onSidebarClose()"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ $t('appstore.title') }}</a>
|
||||
<hr/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOMAINS }" v-show="profile.isAtLeastAdmin" :href="VIEWS.DOMAINS" @click="onSidebarClose()"><i class="fa fa-globe fa-fw"></i> {{ $t('domains.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastUserManager" @click="onToggleGroup(SIDEBAR_GROUPS.USERS)"><i class="fa fa-users-gear fa-fw"></i> {{ $t('users.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.USERS] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.USERS]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.USERS }" v-show="profile.isAtLeastUserManager" :href="VIEWS.USERS" @click="onSidebarClose()"><i class="fa fa-user fa-fw"></i> {{ $t('main.navbar.users') }}</a>
|
||||
<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>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastMailManager" @click="onToggleGroup(SIDEBAR_GROUPS.EMAIL)"><i class="fa fa-envelope fa-fw"></i> {{ $t('emails.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.EMAIL] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.EMAIL]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.EMAIL_DOMAINS || view === VIEWS.EMAIL_DOMAIN }" v-show="profile.isAtLeastAdmin" :href="VIEWS.EMAIL_DOMAINS" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
|
||||
<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>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.NETWORK }" v-show="profile.isAtLeastAdmin" :href="VIEWS.NETWORK" @click="onSidebarClose()"><i class="fas fa-network-wired fa-fw"></i> {{ $t('network.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.VOLUMES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.VOLUMES" @click="onSidebarClose()"><i class="fa fa-hdd fa-fw"></i> {{ $t('volumes.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.BACKUP)"><i class="fa fa-archive fa-fw"></i> {{ $t('backups.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.BACKUP] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.BACKUP]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.BACKUP_SITES }" :href="VIEWS.BACKUP_SITES" @click="onSidebarClose()"><i class="fa fa-fw fa-hard-drive"></i> {{ $t('backups.sites.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APP_ARCHIVE }" :href="VIEWS.APP_ARCHIVE" @click="onSidebarClose()"><i class="fa fa-fw fa-grip"></i> {{ $t('backups.archives.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.APPEARANCE }" v-show="profile.isAtLeastAdmin" :href="VIEWS.APPEARANCE" @click="onSidebarClose()"><i class="fa fa-pen-ruler fa-fw"></i> {{ $t('appearance.title') }}</a>
|
||||
|
||||
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup(SIDEBAR_GROUPS.SYSTEM)"><i class="fa fa-server fa-fw"></i> {{ $t('system.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM] }" style="margin-left: 6px;"></i></div>
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="activeSidebarGroups[SIDEBAR_GROUPS.SYSTEM]">
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.DOCKER }" :href="VIEWS.DOCKER" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> Docker</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVICES }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVICES" @click="onSidebarClose()"><i class="fa fa-diagram-project fa-fw"></i> {{ $t('services.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_EVENTLOG }" :href="VIEWS.SYSTEM_EVENTLOG" @click="onSidebarClose()"><i class="fa fa-list-alt fa-fw"></i> {{ $t('eventlog.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_UPDATE }" :href="VIEWS.SYSTEM_UPDATE" @click="onSidebarClose()"><i class="fa fa-fw fa-square-up-right"></i> {{ $t('settings.updates.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SYSTEM_SETTINGS }" :href="VIEWS.SYSTEM_SETTINGS" @click="onSidebarClose()"><i class="fa fa-fw fa-screwdriver-wrench"></i> {{ $t('system.settings.title') }}</a>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<hr v-show="profile.isAtLeastAdmin"/>
|
||||
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.SERVER }" v-show="profile.isAtLeastAdmin" :href="VIEWS.SERVER" @click="onSidebarClose()"><i class="fa fa-microchip fa-fw"></i> {{ $t('server.title') }}</a>
|
||||
<a class="sidebar-item" :class="{ active: view === VIEWS.CLOUDRON_ACCOUNT }" v-show="profile.isAtLeastOwner" :href="VIEWS.CLOUDRON_ACCOUNT" @click="onSidebarClose()"><i class="fa fa-crown fa-fw"></i> {{ $t('settings.appstoreAccount.title') }}</a>
|
||||
</div>
|
||||
</SideBar>
|
||||
<SideBar v-if="profile.isAtLeastUserManager" :items="menuItems" :cloudron-name="config.cloudronName" :cloudron-avatar-url="avatarUrl"/>
|
||||
|
||||
<div style="flex-grow: 1; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<Headerbar :config="config" :subscription="subscription"/>
|
||||
@@ -390,112 +497,3 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.pankow-sidebar {
|
||||
background-color: var(--navbar-background);
|
||||
padding: 22px 10px 10px 10px;
|
||||
margin-right: 20px;
|
||||
/* width is optimized for english */
|
||||
min-width: 250px;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
margin-right: 10px;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-logo:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
text-decoration: none;
|
||||
padding-left: 10px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
overflow: auto;
|
||||
padding-top: 25px;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-list:hover {
|
||||
scrollbar-color: var(--color-neutral-border) transparent;
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
color: var(--pankow-text-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-item i {
|
||||
opacity: 0.5;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background-color: #e9ecef;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-item:hover {
|
||||
background-color: var(--card-background);
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item.active i ,
|
||||
.sidebar-item:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-group {
|
||||
padding-left: 20px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
/* we need height to auto so we animate max-height. needs to be bigger than we need */
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-enter-active,
|
||||
.sidebar-item-group-animation-leave-active {
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-leave-to,
|
||||
.sidebar-item-group-animation-enter-from {
|
||||
transform: translateX(-100px);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
|
||||
</style>
|
||||
|
||||
@@ -1,54 +1,75 @@
|
||||
<script setup>
|
||||
|
||||
import { onMounted } from 'vue';
|
||||
import { computed } from 'vue';
|
||||
import { FormGroup, Radiobutton, MultiSelect } from '@cloudron/pankow';
|
||||
import { ACL_OPTIONS } from '../constants.js';
|
||||
|
||||
const props = defineProps([ 'users', 'groups', '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 optionalSso = !!props.manifest.optionalSso;
|
||||
const cloudronAuth = !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']) && !props.hideOptionalSsoOption;
|
||||
|
||||
onMounted(async () => {
|
||||
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>
|
||||
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
</div>
|
||||
<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>
|
||||
</FormGroup>
|
||||
|
||||
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED">
|
||||
<div style="margin-left: 20px; display: flex; gap: 10px;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="username" :search-threshold="20" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
</div>
|
||||
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
|
||||
|
||||
<div>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
|
||||
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
|
||||
</div>
|
||||
|
||||
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED" style="margin-top: 12px; margin-left: 20px; display: flex; gap: 10px;">
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="username" :search-threshold="20" />
|
||||
</div>
|
||||
<div>
|
||||
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
111
dashboard/src/components/ActionBar.vue
Normal file
111
dashboard/src/components/ActionBar.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup>
|
||||
|
||||
import { computed, useTemplateRef,ref } from 'vue';
|
||||
import { Menu, Button, ButtonGroup } from '@cloudron/pankow';
|
||||
|
||||
const props = defineProps({
|
||||
actions: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
},
|
||||
});
|
||||
|
||||
const quickActions = computed(() => {
|
||||
const visibleActions = props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator);
|
||||
if (visibleActions.length <= 2) return visibleActions;
|
||||
|
||||
return visibleActions.filter(a => a.quickAction);
|
||||
});
|
||||
|
||||
const visibleActionCount = computed(() => {
|
||||
return props.actions.filter(a => !(typeof a.visible !== 'undefined' && !a.visible) && !a.separator).length;
|
||||
});
|
||||
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
const menuElement = useTemplateRef('menuElement');
|
||||
function onMenu(event) {
|
||||
isMenuOpen.value = true;
|
||||
menuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="action-bar" :class="{ 'is-menu-open': isMenuOpen }">
|
||||
<Menu ref="menuElement" :model="actions" @close="isMenuOpen = false" />
|
||||
<ButtonGroup class="quick-action-group">
|
||||
<Button tool v-for="quickAction in quickActions" :key="quickAction" :icon="quickAction.icon" @click="quickAction.action()" :href="quickAction.href || null" :target="quickAction.target || null" v-tooltip.top="quickAction.label"/>
|
||||
<Button tool @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0 && visibleActionCount !== quickActions.length"/>
|
||||
</ButtonGroup>
|
||||
<Button tool :plain="isMenuOpen ? null : true" secondary @click.capture="onMenu($event)" icon="fa-solid fa-ellipsis" v-if="visibleActionCount > 0" class="menu-action" :class="{ 'hide-on-touch': visibleActionCount === quickActions.length }"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.action-bar {
|
||||
display: flex;
|
||||
gap: 5px;
|
||||
justify-content: end;
|
||||
min-height: 31px;
|
||||
align-items: center;
|
||||
min-width: 55px;
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-action-group {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.action-bar .quick-action-group .pankow-button {
|
||||
background-color: white;
|
||||
color: var(--pankow-color-text);
|
||||
border: 1px solid transparent;
|
||||
}
|
||||
|
||||
.action-bar .quick-action-group .pankow-button:hover {
|
||||
color: var(--pankow-color-primary);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.action-bar .quick-action-group .pankow-button {
|
||||
background: var(--pankow-color-background);
|
||||
color: var(--pankow-color-text);
|
||||
}
|
||||
}
|
||||
|
||||
.hide-on-touch {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (hover: hover) {
|
||||
.hide-on-touch {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.menu-action {
|
||||
display: block;
|
||||
}
|
||||
|
||||
/* cover tables and backupsite view for now */
|
||||
div:hover > div > div > .menu-action,
|
||||
tr:hover .menu-action {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.quick-action-group {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* cover tables and backupsite view for now */
|
||||
div:hover > div > div > .quick-action-group,
|
||||
tr:hover .quick-action-group {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -5,10 +5,11 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, computed, useTemplateRef } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import { TOKEN_TYPES } from '../constants.js';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
import TokensModel from '../models/TokensModel.js';
|
||||
|
||||
@@ -27,6 +28,15 @@ const columns = {
|
||||
label: t('profile.apiTokens.name'),
|
||||
sort: true
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
lastUsedTime: {
|
||||
label: t('profile.apiTokens.lastUsed'),
|
||||
sort(a, b) {
|
||||
@@ -35,36 +45,28 @@ const columns = {
|
||||
return moment(a).isBefore(b) ? 1 : -1;
|
||||
}
|
||||
},
|
||||
scope: {
|
||||
label: t('profile.apiTokens.scope'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
actions: {
|
||||
width: '55px',
|
||||
},
|
||||
allowedIpRanges: {
|
||||
label: t('profile.apiTokens.allowedIpRanges'),
|
||||
hideMobile: true,
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(apiToken, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(apiToken) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRevokeToken.bind(null, apiToken),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!tokenName.value) return false;
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) return false;
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (!(tokenScope.value === 'r' || tokenScope.value === 'rw')) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refreshApiTokens() {
|
||||
const [error, tokens] = await tokensModel.list();
|
||||
@@ -74,7 +76,7 @@ async function refreshApiTokens() {
|
||||
}
|
||||
|
||||
async function onSubmitAddApiToken(){
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
const scope = { '*': tokenScope.value };
|
||||
const allowedIpRanges = tokenAllowedIpRanges.value;
|
||||
@@ -96,15 +98,18 @@ function onReset() {
|
||||
tokenScope.value = 'rw';
|
||||
tokenAllowedIpRanges.value = '';
|
||||
tokenAllowedIpRangesError.value = '';
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -123,14 +128,14 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createApiToken.title')"
|
||||
:confirm-label="addedToken ? '' : $t('profile.createApiToken.generateToken')"
|
||||
:confirm-label="addedToken ? '' : $t('main.action.add')"
|
||||
:confirm-active="isFormValid"
|
||||
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()"
|
||||
@@ -138,8 +143,8 @@ onMounted(async () => {
|
||||
<div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="!addedToken">
|
||||
<form @submit.prevent="onSubmitAddApiToken()" autocomplete="off">
|
||||
<input style="display: none" type="submit" :disabled="!isValid"/>
|
||||
<form @submit.prevent="onSubmitAddApiToken()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="apiTokenName">{{ $t('profile.createApiToken.name') }}</label>
|
||||
<TextInput id="apiTokenName" v-model="tokenName" required/>
|
||||
@@ -154,7 +159,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>
|
||||
@@ -188,13 +194,11 @@ onMounted(async () => {
|
||||
<span v-else>{{ $t('profile.apiTokens.readonly') }}</span>
|
||||
</template>
|
||||
<template #allowedIpRanges="apiToken">
|
||||
<span v-if="apiToken.allowedIpRanges !== ''" v-tooltip="apiToken.allowedIpRanges">{{ apiToken.allowedIpRanges }}</span>
|
||||
<span v-if="apiToken.allowedIpRanges !== ''">{{ apiToken.allowedIpRanges }}</span>
|
||||
<span v-else>{{ '*' }}</span>
|
||||
</template>
|
||||
<template #actions="apiToken">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(apiToken, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(apiToken)" />
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue';
|
||||
import { Dialog, FormGroup, TextInput, PasswordInput, Checkbox } from '@cloudron/pankow';
|
||||
import { s3like } from '../utils.js';
|
||||
import { s3like, mountlike, parseFullBackupPath } 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';
|
||||
@@ -10,102 +10,132 @@ import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LIN
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const backupConfigInput = useTemplateRef('backupConfigInput');
|
||||
const appId = ref('');
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const providerConfig = ref({});
|
||||
const provider = ref('');
|
||||
const remotePath = ref('');
|
||||
const fullPath = ref('');
|
||||
const format = ref('');
|
||||
const encrypted = ref(false);
|
||||
const encryptionPasswordHint = ref('');
|
||||
const encryptionPassword = ref('');
|
||||
const encryptedFilenames = ref(false);
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
formError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
let backupPath = remotePath.value;
|
||||
const backupConfig = {};
|
||||
const config = {};
|
||||
|
||||
const { prefix, remotePath } = parseFullBackupPath(fullPath.value);
|
||||
|
||||
// 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;
|
||||
config.prefix = prefix;
|
||||
|
||||
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 (provider.value === 'synology-c2-objectstorage') {
|
||||
config.region = 'us-east-1';
|
||||
config.signatureVersion = 'v4';
|
||||
}
|
||||
} else if (mountlike(provider.value)) {
|
||||
config.prefix = 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 = prefix;
|
||||
} else if (provider.value === 'gcs') {
|
||||
config.bucket = providerConfig.value.bucket;
|
||||
config.projectId = providerConfig.value.projectId;
|
||||
config.credentials = providerConfig.value.credentials;
|
||||
config.prefix = prefix;
|
||||
}
|
||||
|
||||
const data = {
|
||||
format: format.value,
|
||||
provider: provider.value,
|
||||
config: backupConfig,
|
||||
remotePath: backupPath
|
||||
config,
|
||||
remotePath
|
||||
};
|
||||
|
||||
if (encrypted.value) {
|
||||
@@ -166,22 +196,65 @@ function onBackupConfigChanged(event) {
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(result.target.result); // 'provider', 'config', 'limits', 'format', 'remotePath', 'encrypted', 'encryptedFilenames'
|
||||
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
|
||||
data.remotePath = `${data.config.backupDir}/${data.remotePath}`;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Unable to parse backup config', e);
|
||||
return;
|
||||
}
|
||||
|
||||
provider.value = data.provider;
|
||||
remotePath.value = data.remotePath;
|
||||
providerConfig.value = data.config;
|
||||
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
|
||||
fullPath.value = data.config.prefix ? `${data.config.backupDir}/${data.config.prefix}/${data.remotePath}` : `${data.config.backupDir}/${data.remotePath}`;
|
||||
} else if (data.provider === 'mountpoint') {
|
||||
fullPath.value = data.config.prefix ? `${data.config.mountPoint}/${data.config.prefix}/${data.remotePath}` : `${data.config.mountPoint}/${data.remotePath}`;
|
||||
} else {
|
||||
fullPath.value = data.config.prefix ? `${data.config.prefix}/${data.remotePath}` : data.remotePath;
|
||||
}
|
||||
format.value = data.format;
|
||||
encrypted.value = !!data.encrypted;
|
||||
encryptionPasswordHint.value = data.encryptionPasswordHint || '';
|
||||
encryptionPassword.value = '';
|
||||
encryptedFilenames.value = data.encryptedFilenames;
|
||||
|
||||
providerConfig.value = {};
|
||||
for (const [key, value] of Object.entries(data.config)) {
|
||||
switch (key) {
|
||||
case 'noHardlinks':
|
||||
case 'chown':
|
||||
case 'preserveAttributes':
|
||||
// not really used for importing
|
||||
break;
|
||||
case 'projectId':
|
||||
case 'credentials':
|
||||
// gcs fields which should be set by user by uploading json
|
||||
break;
|
||||
case 'mountOptions': // providerConfig uses a flattened format of config.mountOptions
|
||||
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 = '';
|
||||
break;
|
||||
case 'accessKeyId': // s3
|
||||
case 'secretAccessKey': // s3
|
||||
case 'bucket': // s3, gcs
|
||||
case 'prefix': // s3, gcs
|
||||
case 'signatureVersion': // s3
|
||||
case 'endpoint': // s3
|
||||
case 'region': // s3
|
||||
case 'acceptSelfSignedCerts': // s3
|
||||
case 's3ForcePathStyle': // s3
|
||||
providerConfig.value[key] = value;
|
||||
break;
|
||||
default:
|
||||
console.log('unhandled key when importing config file:', key);
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
};
|
||||
|
||||
reader.readAsText(event.target.files[0]);
|
||||
@@ -191,6 +264,10 @@ function onUploadBackupConfig() {
|
||||
backupConfigInput.value.click();
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (providerConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
async open(id) {
|
||||
appId.value = id;
|
||||
@@ -198,13 +275,15 @@ defineExpose({
|
||||
formError.value = {};
|
||||
provider.value = '';
|
||||
providerConfig.value = {};
|
||||
remotePath.value = '';
|
||||
fullPath.value = '';
|
||||
encrypted.value = false;
|
||||
encryptionPassword.value = '';
|
||||
encryptedFilenames.value = false;
|
||||
encryptionPasswordHint.value = '';
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -216,7 +295,7 @@ defineExpose({
|
||||
|
||||
<Dialog ref="dialog" :title="$t('app.importBackupDialog.title')"
|
||||
:confirm-label="$t('app.importBackupDialog.importAction')"
|
||||
:confirm-active="!busy"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@@ -224,7 +303,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"/>
|
||||
@@ -233,14 +315,14 @@ defineExpose({
|
||||
</button>
|
||||
</p>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<!-- remotePath contains the prefix as well -->
|
||||
<FormGroup>
|
||||
<label for="inputRemotePath">{{ $t('app.importBackupDialog.remotePath') }} <sup><a href="https://docs.cloudron.io/backups/#import-app-backup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="inputRemotePath" v-model="remotePath" required />
|
||||
<TextInput id="inputRemotePath" v-model="fullPath" required />
|
||||
</FormGroup>
|
||||
|
||||
<BackupProviderForm ref="form"
|
||||
|
||||
@@ -2,21 +2,28 @@
|
||||
|
||||
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
|
||||
import AccessControl from './AccessControl.vue';
|
||||
import PortBindings from './PortBindings.vue';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import AppstoreModel from '../models/AppstoreModel.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({
|
||||
LOADING: Symbol('loading'),
|
||||
DETAILS: Symbol('details'),
|
||||
INSTALL: Symbol('install'),
|
||||
});
|
||||
|
||||
const appstoreModel = AppstoreModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
const domainsModel = DomainsModel.create();
|
||||
const usersModel = UsersModel.create();
|
||||
const groupsModel = GroupsModel.create();
|
||||
|
||||
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
@@ -75,6 +82,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);
|
||||
@@ -153,9 +162,8 @@ async function onSubmit(overwriteDns) {
|
||||
formError.value.port = match ? parseInt(match[1]) : null;
|
||||
} else if (error.status === 409 && error.body.message.indexOf('primary location') !== -1) {
|
||||
formError.value.location = error.body.message;
|
||||
} else if (error.status === 412) {
|
||||
formError.value.generic = error.body.message;
|
||||
} else {
|
||||
formError.value.generic = error.body?.message || `Error installing app. Status code: ${error.status} . ${error.body}`;
|
||||
console.error('Failed to install:', error);
|
||||
}
|
||||
}
|
||||
@@ -165,6 +173,14 @@ function onClose() {
|
||||
}
|
||||
|
||||
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 screenshotsContainer = useTemplateRef('screenshotsContainer');
|
||||
@@ -185,10 +201,32 @@ function onScreenshotNext() {
|
||||
elem.scrollIntoView({ behavior: 'smooth', inline: 'start', block: 'nearest' });
|
||||
}
|
||||
|
||||
async function getApp(id, version = '') {
|
||||
const [error, result] = await appstoreModel.get(id, version);
|
||||
if (error) {
|
||||
console.error(error);
|
||||
return null;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
open: async function(a, appCountExceeded, domainList) {
|
||||
open: async function(appId, version, appCountExceeded, domainList) {
|
||||
busy.value = false;
|
||||
step.value = STEP.DETAILS;
|
||||
step.value = STEP.LOADING;
|
||||
formError.value = {};
|
||||
|
||||
// give it some time to fetch before showing loading
|
||||
const openTimer = setTimeout(dialog.value.open, 200);
|
||||
|
||||
const a = await getApp(appId, version);
|
||||
if (!a) {
|
||||
clearTimeout(openTimer);
|
||||
dialog.value.close();
|
||||
throw new Error('app not found');
|
||||
}
|
||||
|
||||
app.value = a;
|
||||
appMaxCountExceeded.value = appCountExceeded;
|
||||
manifest.value = a.manifest;
|
||||
@@ -229,8 +267,7 @@ defineExpose({
|
||||
}
|
||||
|
||||
currentScreenshotPos = 0;
|
||||
|
||||
dialog.value.open();
|
||||
step.value = STEP.DETAILS;
|
||||
},
|
||||
close() {
|
||||
dialog.value.close();
|
||||
@@ -240,8 +277,11 @@ defineExpose({
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="true" style="width: unset; min-width: min(450px, 95%)">
|
||||
<div class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<Dialog ref="dialogHandle" @close="onClose()" :show-x="step !== STEP.LOADING" style="width: unset;" :style="{ 'min-width': step !== STEP.LOADING ? 'min(450px, 95%)' : 'unset' }">
|
||||
<div v-if="step === STEP.LOADING" class="app-install-dialog-body">
|
||||
<Spinner class="pankow-spinner-large"/>
|
||||
</div>
|
||||
<div v-else class="app-install-dialog-body" :class="{ 'step-detail': step === STEP.DETAILS, 'step-install': step === STEP.INSTALL }">
|
||||
<div class="app-install-header">
|
||||
<div class="summary" v-if="app.manifest">
|
||||
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="app.iconUrl" />{{ manifest.title }}</div>
|
||||
@@ -298,7 +338,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>
|
||||
|
||||
@@ -5,9 +5,10 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import moment from 'moment-timezone';
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Menu, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardButton, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
|
||||
import { prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import ActionBar from './ActionBar.vue';
|
||||
import Section from './Section.vue';
|
||||
import AppPasswordsModel from '../models/AppPasswordsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
@@ -29,7 +30,7 @@ const columns = {
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('main.table.date'),
|
||||
label: t('main.table.created'),
|
||||
hideMobile: true,
|
||||
sort(a, b) {
|
||||
if (!a) return 1;
|
||||
@@ -40,16 +41,12 @@ const columns = {
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(appPassword, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(appPassword) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
action: onRemove.bind(null, appPassword),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
// new dialog props
|
||||
@@ -77,22 +74,23 @@ async function refresh() {
|
||||
passwords.value = result;
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onReset() {
|
||||
setTimeout(() => {
|
||||
passwordName.value = '';
|
||||
identifier.value = '';
|
||||
addedPassword.value = '';
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}, 500);
|
||||
}
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (!passwordName.value) return false;
|
||||
if (!identifier.value) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
addedPassword.value = '';
|
||||
|
||||
@@ -108,10 +106,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;
|
||||
@@ -156,15 +156,14 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<Dialog ref="newDialog"
|
||||
:title="$t('profile.createAppPassword.title')"
|
||||
:confirm-active="addedPassword || isValid"
|
||||
:confirm-label="addedPassword ? '' : $t('profile.createAppPassword.generatePassword')"
|
||||
:confirm-active="addedPassword || isFormValid"
|
||||
: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()"
|
||||
@@ -172,8 +171,8 @@ onMounted(async () => {
|
||||
<div>
|
||||
<Transition name="slide-left" mode="out-in">
|
||||
<div v-if="!addedPassword">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<input style="display: none" type="submit" :disabled="!isValid"/>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<input style="display: none" type="submit"/>
|
||||
<FormGroup>
|
||||
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
|
||||
<TextInput id="passwordName" v-model="passwordName" required/>
|
||||
@@ -181,7 +180,7 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('profile.createAppPassword.app') }}</label>
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" />
|
||||
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
|
||||
</FormGroup>
|
||||
</form>
|
||||
</div>
|
||||
@@ -208,9 +207,7 @@ onMounted(async () => {
|
||||
<TableView :columns="columns" :model="passwords" :placeholder="$t('profile.appPasswords.noPasswordsPlaceholder')">
|
||||
<template #creationTime="password">{{ prettyLongDate(password.creationTime) }}</template>
|
||||
<template #actions="password">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(password, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(password)" />
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -191,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"
|
||||
|
||||
@@ -38,26 +38,29 @@ const accessRestriction = ref({
|
||||
groups: [],
|
||||
});
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (busy.value) return false;
|
||||
if (!upstreamUri.value) return false;
|
||||
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
let iconFile = 'src';
|
||||
function onIconChanged(file) {
|
||||
iconFile = file;
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
try {
|
||||
new URL(upstreamUri.value);
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
} catch (e) {
|
||||
isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
|
||||
const data = {
|
||||
@@ -98,8 +101,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;
|
||||
@@ -133,6 +137,8 @@ defineExpose({
|
||||
groups.value = result;
|
||||
|
||||
applinkDialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -145,17 +151,17 @@ defineExpose({
|
||||
alternate-style="danger"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-active="isValid"
|
||||
:confirm-label="mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
:confirm-busy="busy"
|
||||
@confirm="onSubmit()"
|
||||
@alternate="onRemove()"
|
||||
>
|
||||
<InputDialog ref="inputDialog" />
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!isValid" />
|
||||
<input style="display: none;" type="submit" />
|
||||
|
||||
<p class="has-error" v-show="error.generic">{{ error.generic }}</p>
|
||||
|
||||
@@ -172,12 +178,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,37 +0,0 @@
|
||||
<script setup>
|
||||
|
||||
import { inject, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup } from '@cloudron/pankow';
|
||||
import ApplinkDialog from './ApplinkDialog.vue';
|
||||
import Section from './Section.vue';
|
||||
import SettingsItem from './SettingsItem.vue';
|
||||
|
||||
const features = inject('features');
|
||||
|
||||
const applinkDialog = useTemplateRef('applinkDialog');
|
||||
|
||||
function onAddExternalLink() {
|
||||
applinkDialog.value.open();
|
||||
}
|
||||
|
||||
function onApplinkAdded() {
|
||||
window.location.href = '#/apps';
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
|
||||
|
||||
<Section :title="$t('dashboard.title')">
|
||||
<SettingsItem>
|
||||
<FormGroup>
|
||||
<label>{{ $t('externallinks.label') }}</label>
|
||||
<div>{{ $t('externallinks.description') }}</div>
|
||||
</FormGroup>
|
||||
<div style="display: flex; position: relative; align-items: center">
|
||||
<Button tool plain @click="onAddExternalLink()" :disabled="!features.branding">{{ $t('main.action.add') }}</Button>
|
||||
</div>
|
||||
</SettingsItem>
|
||||
</Section>
|
||||
</template>
|
||||
157
dashboard/src/components/BackupInfoDialog.vue
Normal file
157
dashboard/src/components/BackupInfoDialog.vue
Normal file
@@ -0,0 +1,157 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { ClipboardAction, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import BackupsModel from '../models/BackupsModel.js';
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupsModel = BackupsModel.create();
|
||||
|
||||
const busy = ref(true);
|
||||
|
||||
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 backup = ref({ contents: [], validStats: false });
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
defineExpose({
|
||||
async open(b) {
|
||||
backup.value = JSON.parse(JSON.stringify(b)); // make a copy
|
||||
backup.value.contents = [];
|
||||
backup.value.validStats = false; // old cloudron version had invalid stats
|
||||
busy.value = true;
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
if (backup.value.type === 'app') {
|
||||
backup.value.validStats = backup.value.stats?.upload && backup.value.stats?.copy;
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// amend detailed app info
|
||||
const appsById = {};
|
||||
|
||||
const [appsError, apps] = await appsModel.list();
|
||||
if (appsError) console.error('Failed to get apps list:', appsError);
|
||||
|
||||
(apps || []).forEach(function (app) {
|
||||
appsById[app.id] = app;
|
||||
});
|
||||
|
||||
for (const contentId of backup.value.dependsOn) {
|
||||
const match = contentId.match(/(mail|app)_(.*?)_.*/); // *? means non-greedy
|
||||
if (!match) continue;
|
||||
const [error, result] = await backupsModel.get(contentId);
|
||||
if (error) console.error(error);
|
||||
const content = { id: null, label: null, fqdn: null, stats: null };
|
||||
content.stats = result.stats;
|
||||
if (match[1] === 'mail') {
|
||||
content.id = 'mail';
|
||||
content.label = 'Mail Server';
|
||||
} else {
|
||||
const app = appsById[match[2]];
|
||||
if (app) {
|
||||
content.id = app.id;
|
||||
content.label = app.label;
|
||||
content.fqdn = app.fqdn;
|
||||
} else { // uninstalled app
|
||||
content.id = match[2];
|
||||
}
|
||||
}
|
||||
backup.value.contents.push(content);
|
||||
}
|
||||
|
||||
backup.value.validStats = backup.value.stats?.aggregatedUpload && backup.value.stats?.aggregatedCopy;
|
||||
busy.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('backups.backupDetails.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
|
||||
<div class="info-value">{{ backup.id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.label') }}</div>
|
||||
<div class="info-value">{{ backup.label || 'Not set'}}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
|
||||
<div class="info-value">
|
||||
<div>
|
||||
{{ backup.remotePath }}
|
||||
<ClipboardAction plain :value="backup.remotePath"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
|
||||
<div class="info-value">{{ prettyLongDate(backup.creationTime) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ backup.packageVersion }}</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="backup.validStats">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.size') }}</div>
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s) | {{ backup.appCount }} app(s) </div>
|
||||
<div v-else class="info-value">{{ prettyFileSize(backup.stats.upload.size) }} | {{ backup.stats.upload.fileCount }} file(s)</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="backup.validStats">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.duration') }}</div>
|
||||
<div v-if="backup.type === 'box'" class="info-value">{{ prettyDuration(backup.stats.aggregatedUpload.duration + backup.stats.aggregatedCopy.duration) }}</div>
|
||||
<div v-else class="info-value">{{ prettyDuration(backup.stats.upload.duration + backup.stats.copy.duration) }}</div>
|
||||
</div>
|
||||
|
||||
<hr style="margin: 15px 0" v-if="backup.type === 'box'"/>
|
||||
|
||||
<div v-if="backup.type === 'box'">
|
||||
<TableView :columns="backupContentTableColumns" :model="backup.contents" :busy="busy">
|
||||
<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>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -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';
|
||||
@@ -92,6 +92,10 @@ watch(provider, (newProvider) => {
|
||||
if (parseInt(providerConfig.value.downloadConcurrency) < 30) providerConfig.value.downloadConcurrency = 30;
|
||||
if (parseInt(providerConfig.value.syncConcurrency) < 20) providerConfig.value.syncConcurrency = 20;
|
||||
if (parseInt(providerConfig.value.copyConcurrency) < 500) providerConfig.value.downloadConcurrency = 500;
|
||||
} else if (newProvider === 'cifs') {
|
||||
providerConfig.value.mountOptionSeal = true;
|
||||
} else if (newProvider === 'sshfs') {
|
||||
providerConfig.value.mountOptionPort = 23;
|
||||
} else if (newProvider === 'gcs') {
|
||||
providerConfig.value.credentials = {
|
||||
client_email: '',
|
||||
@@ -100,6 +104,17 @@ watch(provider, (newProvider) => {
|
||||
}
|
||||
});
|
||||
|
||||
watch(format, (newFormat) => {
|
||||
if (newFormat === 'rsync') {
|
||||
if (provider.value === 'filesystem' || mountlike(provider.value)) providerConfig.value.useHardlinks = true;
|
||||
}
|
||||
});
|
||||
|
||||
watchEffect(() => {
|
||||
if (!providerConfig.value.mountOptionPrivateKey) return;
|
||||
providerConfig.value.mountOptionPrivateKey = providerConfig.value.mountOptionPrivateKey.replaceAll('\\n', '\n');
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await getBlockDevices();
|
||||
});
|
||||
@@ -120,18 +135,18 @@ onMounted(async () => {
|
||||
<FormGroup v-if="provider === 'mountpoint'">
|
||||
<label for="mountPointInput">{{ $t('backups.configureBackupStorage.mountPoint') }}</label>
|
||||
<TextInput id="mountPointInput" v-model="providerConfig.mountPoint" placeholder="/mnt/backups" required/>
|
||||
<div v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></div>
|
||||
<small class="helper-text" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- 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 +155,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,19 +193,19 @@ 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 -->
|
||||
<FormGroup v-if="provider === 'filesystem' && !importOnly">
|
||||
<label for="backupDirInput">{{ $t('backups.configureBackupStorage.localDirectory') }}</label>
|
||||
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="Directory for backups" required />
|
||||
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="/opt/backups" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- S3/Minio/SOS/GCS/UpCloud/B2/R2 -->
|
||||
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
|
||||
<!-- Endpoint - S3/Minio/SOS/GCS/UpCloud/B2/R2/C2 -->
|
||||
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2' || provider === 'synology-c2-objectstorage'">
|
||||
<label for="endpointInput">{{ $t('backups.configureBackupStorage.s3Endpoint') }}</label>
|
||||
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="URL" required />
|
||||
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="https://s3endpoint.example.com" required />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="provider === 'minio' || provider === 's3-v4-compat'" v-model="providerConfig.acceptSelfSignedCerts" :label="$t('backups.configureBackupStorage.acceptSelfSignedCerts')"/>
|
||||
@@ -200,12 +215,14 @@ onMounted(async () => {
|
||||
<TextInput id="bucketInput" v-model="providerConfig.bucket" required />
|
||||
</FormGroup>
|
||||
|
||||
<!-- when importing/restoring, the user enters a fullPath which contains the prefix -->
|
||||
<FormGroup v-if="provider !== 'filesystem' && !importOnly">
|
||||
<label for="prefixInput">{{ $t('backups.configureBackupStorage.prefix') }}</label>
|
||||
<TextInput id="prefixInput" v-model="providerConfig.prefix" placeholder="Prefix for backup file names" />
|
||||
<TextInput id="prefixInput" v-model="providerConfig.prefix" placeholder="my-backups" />
|
||||
<small class="helper-text">{{ $t('backups.configureBackupStorage.prefixHelperText') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<!-- S3/Minio/SOS/GCS -->
|
||||
<!-- Region Selector -->
|
||||
<FormGroup v-if="
|
||||
provider === 's3' ||
|
||||
provider === 'digitalocean-spaces' ||
|
||||
@@ -236,7 +253,8 @@ onMounted(async () => {
|
||||
|
||||
<FormGroup v-if="provider === 's3-v4-compat'">
|
||||
<label for="s3v4CompatRegionInput">{{ $t('backups.configureBackupStorage.region') }}</label>
|
||||
<TextInput id="s3v4CompatRegionInput" v-model="providerConfig.region" placeholder="Leave empty to use us-east-1 as default" />
|
||||
<TextInput id="s3v4CompatRegionInput" v-model="providerConfig.region" />
|
||||
<small class="helper-text">{{ $t('backups.configureBackupStorage.regionHelperText') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="s3like(provider)">
|
||||
@@ -253,7 +271,8 @@ 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();"/>
|
||||
<input style="display: none" :value="providerConfig.credentials.client_email" required /> <!-- for form validation -->
|
||||
<TextInput readonly style="cursor: pointer; 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>
|
||||
|
||||
@@ -16,7 +16,6 @@ const backupSitesModel = BackupSitesModel.create();
|
||||
const systemModel = SystemModel.create();
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
const form = useTemplateRef('form');
|
||||
const step = ref('storage');
|
||||
const newSiteId = ref('');
|
||||
const name = ref('');
|
||||
@@ -101,6 +100,9 @@ async function onSubmit() {
|
||||
} else if (provider.value === 'hetzner-objectstorage') {
|
||||
data.region = 'us-east-1';
|
||||
data.signatureVersion = 'v4';
|
||||
} else if (provider.value === 'synology-c2-objectstorage') {
|
||||
data.region = 'us-east-1';
|
||||
data.signatureVersion = 'v4';
|
||||
}
|
||||
} else if (mountlike(provider.value)) {
|
||||
data.prefix = providerConfig.value.prefix;
|
||||
@@ -227,10 +229,10 @@ function onCancel() {
|
||||
dialog.value.close();
|
||||
}
|
||||
|
||||
const isValid = ref(false);
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -289,7 +291,7 @@ defineExpose({
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
// checkValidity();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -299,9 +301,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" @change="checkValidity()">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" :disabled="!isValid"/>
|
||||
<input style="display: none;" type="submit"/>
|
||||
|
||||
<FormGroup>
|
||||
<label for="nameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
|
||||
@@ -378,7 +380,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 || !isValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
<Button primary :disabled="busy || !isFormValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</fieldset>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<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';
|
||||
import { s3like, mountlike, prettySiteLocation } from '../utils.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import SystemModel from '../models/SystemModel.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;
|
||||
|
||||
@@ -200,15 +205,7 @@ defineExpose({
|
||||
|
||||
<FormGroup v-if="site.provider && site.config">
|
||||
<label><i v-if="site.encrypted" class="fa-solid fa-lock"></i> Storage: <b>{{ site.provider }} ({{ site.format }}) </b></label>
|
||||
<div>
|
||||
<span v-if="site.provider === 'filesystem'">{{ site.config.backupDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'disk' || site.provider === 'ext4' || site.provider === 'xfs' || site.provider === 'mountpoint'">{{ site.config.mountOptions.diskPath || site.config.mountPoint }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'cifs' || site.provider === 'nfs' || site.provider === 'sshfs'">{{ site.config.mountOptions.host }}:{{ site.config.mountOptions.remoteDir }}{{ (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 's3'">{{ site.config.region + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'minio'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else-if="site.provider === 'gcs'">{{ site.config.endpoint + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
<span v-else>{{ regionName(site.provider, site.config.endpoint) + ' ' + site.config.bucket + (site.config.prefix ? `/${site.config.prefix}` : '') }}</span>
|
||||
</div>
|
||||
<div>{{ prettySiteLocation(site) }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="provider === 'sshfs'">
|
||||
@@ -249,13 +246,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 +266,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>
|
||||
|
||||
@@ -35,8 +35,20 @@ async function onSubmit() {
|
||||
if (includeExclude.value === 'everything') {
|
||||
contents = null;
|
||||
} else if (includeExclude.value === 'exclude') {
|
||||
if (contentExclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Exclude at least one content item or select Everything';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { exclude: contentExclude.value };
|
||||
} else if (includeExclude.value === 'include' && contentInclude.value.length) {
|
||||
} else if (includeExclude.value === 'include') {
|
||||
if (contentInclude.value.length === 0) {
|
||||
formError.value.includeExclude = 'Include at least one content item';
|
||||
busy.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
contents = { include: contentInclude.value };
|
||||
}
|
||||
|
||||
@@ -60,6 +72,9 @@ defineExpose({
|
||||
busy.value = false;
|
||||
site.value = t;
|
||||
provider.value = t.provider;
|
||||
includeExclude.value = 'everything';
|
||||
contentInclude.value = [];
|
||||
contentExclude.value = [];
|
||||
|
||||
enableForUpdates.value = !!t.enableForUpdates;
|
||||
|
||||
@@ -68,7 +83,7 @@ defineExpose({
|
||||
|
||||
contentOptions.value = [{
|
||||
id: 'box',
|
||||
label: 'Platform',
|
||||
label: 'System & email',
|
||||
}];
|
||||
|
||||
result.forEach(a => {
|
||||
@@ -86,8 +101,6 @@ defineExpose({
|
||||
includeExclude.value = 'include';
|
||||
contentInclude.value = t.contents.include;
|
||||
}
|
||||
} else {
|
||||
includeExclude.value = 'everything';
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
@@ -109,21 +122,24 @@ 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>
|
||||
<div class="error-label" v-if="formError.includeExclude">{{ formError.includeExclude }}</div>
|
||||
<div style="padding-top: 10px">
|
||||
<Radiobutton v-model="includeExclude" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')"/>
|
||||
<Radiobutton v-model="includeExclude" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" required :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')"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
<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';
|
||||
import { cronDays, cronHours, parseSchedule } from '../utils.js';
|
||||
|
||||
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
|
||||
const isConfigureValid = computed(() => {
|
||||
return !!days.value.length && !!hours.value.length;
|
||||
return scheduleType.value === 'never' || (days.value.length > 0 && hours.value.length > 0);
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -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,29 +63,24 @@ async function onSubmit() {
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
async open(site) {
|
||||
id.value = site.id;
|
||||
async open(s) {
|
||||
site.value = s;
|
||||
busy.value = false;
|
||||
formError.value = false;
|
||||
days.value = [];
|
||||
hours.value = [];
|
||||
|
||||
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;
|
||||
|
||||
const tmp = site.schedule.split(' ');
|
||||
const tmpHours = tmp[2].split(',');
|
||||
const tmpDays = tmp[5].split(',');
|
||||
|
||||
if (tmpDays[0] === '*') days.value = cronDays.map((day) => { return day.id; });
|
||||
else days.value = tmpDays.map((day) => { return parseInt(day, 10); });
|
||||
|
||||
if (tmpHours[0] === '*') hours.value = cronHours.map(h => h.id);
|
||||
else hours.value = tmpHours.map((hour) => { return parseInt(hour, 10); });
|
||||
scheduleType.value = 'pattern';
|
||||
const result = parseSchedule(site.value.schedule);
|
||||
days.value = result.days; // Array of cronDays.id
|
||||
hours.value = result.hours; // Array of cronHours.id
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
@@ -105,18 +100,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>
|
||||
|
||||
@@ -25,7 +25,7 @@ async function onNameSave(newName) {
|
||||
|
||||
const [error] = await brandingModel.setName(newName);
|
||||
savingName.value = false;
|
||||
if (error) return console.error(error);
|
||||
if (error) return window.cloudron.onError(error);
|
||||
|
||||
name.value = newName;
|
||||
}
|
||||
@@ -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;">
|
||||
@@ -87,7 +87,7 @@ onMounted(async () => {
|
||||
</div>
|
||||
|
||||
<SettingsItem>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave"/>
|
||||
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave" :maxlength="64"/>
|
||||
</SettingsItem>
|
||||
|
||||
<SettingsItem>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -13,14 +13,14 @@ const formError = ref({});
|
||||
const busy = ref (false);
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!password.value) return false;
|
||||
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -51,6 +51,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -68,15 +70,15 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;">
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.disable2FA.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -1,8 +1,8 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onUnmounted } from 'vue';
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
import { Button, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyDecimalSize } from '@cloudron/pankow/utils';
|
||||
import { prettyDecimalSize, prettyDate } from '@cloudron/pankow/utils';
|
||||
import { getColor } from '../utils.js';
|
||||
import SystemModel from '../models/SystemModel.js';
|
||||
|
||||
@@ -14,13 +14,15 @@ const props = defineProps({
|
||||
|
||||
const isExpanded = ref(false);
|
||||
const percent = ref(0);
|
||||
const contents = ref([]);
|
||||
const speed = ref(-1);
|
||||
const contents = ref([]); // cached
|
||||
const speed = ref(-1); // cached
|
||||
const ts = ref(0); // cached
|
||||
const highlight = ref(null);
|
||||
const showingCachedValue = ref(false);
|
||||
|
||||
let eventSource = null;
|
||||
|
||||
async function refresh() {
|
||||
async function getUsage() {
|
||||
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
|
||||
if (error) return console.error(error);
|
||||
|
||||
@@ -33,10 +35,17 @@ async function refresh() {
|
||||
|
||||
if (payload.type === 'done') {
|
||||
percent.value = 100;
|
||||
ts.value = Date.now();
|
||||
showingCachedValue.value = false;
|
||||
|
||||
contents.value.forEach((c, i) => c.color = getColor(contents.value.length, i));
|
||||
contents.value.sort((a, b) => b.usage - a.usage);
|
||||
|
||||
const raw = localStorage.getItem('diskUsageCache');
|
||||
const cache = raw ? JSON.parse(raw) : {};
|
||||
cache[props.filesystem.filesystem] = { contents: contents.value, speed: speed.value, ts: ts.value };
|
||||
localStorage.setItem('diskUsageCache', JSON.stringify(cache));
|
||||
|
||||
eventSource.close();
|
||||
} else if (payload.type === 'progress') {
|
||||
percent.value = payload.percent;
|
||||
@@ -64,9 +73,33 @@ async function onExpand() {
|
||||
|
||||
isExpanded.value = true;
|
||||
|
||||
refresh();
|
||||
getUsage();
|
||||
}
|
||||
|
||||
function loadFromCache() {
|
||||
const raw = localStorage.getItem('diskUsageCache');
|
||||
const cache = raw ? JSON.parse(raw) : {};
|
||||
const entry = cache[props.filesystem.filesystem];
|
||||
|
||||
if (!entry) return;
|
||||
|
||||
if (Date.now() - entry.ts < 60 * 60 * 1000) { // 1 hour old
|
||||
contents.value = entry.contents;
|
||||
speed.value = entry.speed;
|
||||
percent.value = 100;
|
||||
ts.value = entry.ts;
|
||||
isExpanded.value = true;
|
||||
showingCachedValue.value = true;
|
||||
} else {
|
||||
delete cache[props.filesystem.filesystem]; // remove obsolete entry
|
||||
localStorage.setItem('diskUsageCache', JSON.stringify(cache));
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadFromCache();
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
if (eventSource) eventSource.close();
|
||||
});
|
||||
@@ -77,10 +110,12 @@ onUnmounted(() => {
|
||||
<div class="disk-item">
|
||||
<div class="disk-item-title">
|
||||
<div>{{ filesystem.mountpoint }} <small class="text-muted">{{ filesystem.type }}</small></div>
|
||||
<Button v-if="isExpanded" small tool plain icon="fa-solid fa-rotate" :disabled="percent < 100" :loading="percent < 100" @click="refresh()"/>
|
||||
<Button v-if="isExpanded" small tool plain icon="fa-solid fa-rotate" :disabled="percent < 100" :loading="percent < 100" @click="getUsage()"/>
|
||||
</div>
|
||||
<div class="disk-item-size-and-speed">
|
||||
<div>{{ prettyDecimalSize(filesystem.available) }} free of {{ prettyDecimalSize(filesystem.size) }} total</div>
|
||||
<div>{{ prettyDecimalSize(filesystem.used) }} used of {{ prettyDecimalSize(filesystem.size) }} total
|
||||
<span v-if="showingCachedValue">(Last updated {{ prettyDate(ts) }})</span>
|
||||
</div>
|
||||
<div v-if="speed !== -1">I/O Rate: {{ prettyDecimalSize(speed * 1000 * 1000) }}/sec</div>
|
||||
</div>
|
||||
<div v-if="isExpanded" @mouseout="highlight = null">
|
||||
|
||||
@@ -5,7 +5,8 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef, inject } from 'vue';
|
||||
import { Button, Menu, TableView, InputDialog } from '@cloudron/pankow';
|
||||
import { Button, TableView, InputDialog } from '@cloudron/pankow';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import DockerRegistryDialog from '../components/DockerRegistryDialog.vue';
|
||||
import DockerRegistriesModel from '../models/DockerRegistriesModel.js';
|
||||
@@ -28,25 +29,23 @@ const columns = {
|
||||
label: t('dockerRegistries.username'),
|
||||
sort: true
|
||||
},
|
||||
actions: {}
|
||||
actions: {
|
||||
width: '100px',
|
||||
}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(registry, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(registry) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-pencil-alt',
|
||||
label: t('main.action.edit'),
|
||||
quickAction: true,
|
||||
action: onEditOrAdd.bind(null, registry),
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-trash-alt',
|
||||
label: t('main.action.remove'),
|
||||
quickAction: true,
|
||||
action: onRemove.bind(null, registry),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const features = inject('features');
|
||||
@@ -62,11 +61,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;
|
||||
@@ -93,7 +93,6 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<Section :title="$t('dockerRegistries.title')" :title-badge="!features.privateDockerRegistry ? 'Upgrade' : ''">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<InputDialog ref="inputDialog" />
|
||||
<DockerRegistryDialog ref="dialog" @success="refresh()"/>
|
||||
|
||||
@@ -106,9 +105,7 @@ onMounted(async () => {
|
||||
|
||||
<TableView :columns="columns" :model="registries" :busy="busy" :placeholder="$t('dockerRegistries.emptyPlaceholder')">
|
||||
<template #actions="registry">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(registry, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(registry)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -38,7 +37,7 @@ const password = ref('');
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog, TextInput, InputGroup, FormGroup, Checkbox, Button } from '@cloudron/pankow';
|
||||
import { ref, useTemplateRef, watchEffect } from 'vue';
|
||||
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';
|
||||
@@ -31,7 +31,7 @@ const dnsConfig = ref(DomainsModel.createEmptyConfig());
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -99,6 +99,10 @@ function onKeyFileChange() {
|
||||
keyFileName.value = file ? file.name : '';
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (dnsConfig.value.credentials) setTimeout(checkValidity, 100);
|
||||
});
|
||||
|
||||
defineExpose({
|
||||
open(d) {
|
||||
d = d ? JSON.parse(JSON.stringify(d)) : { config: {}, tlsConfig: { provider: 'letsencrypt-prod', wildcard: true } }; // make a copy
|
||||
@@ -131,10 +135,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"
|
||||
@@ -148,7 +152,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="domainInput">{{ $t('domains.domainDialog.domain') }}</label>
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing ? true : undefined" required />
|
||||
<TextInput id="domainInput" v-model="domain" placeholder="example.com" :readonly="editing" :required="!editing" />
|
||||
</FormGroup>
|
||||
|
||||
<DomainProviderForm v-model:provider="provider" v-model:dns-config="dnsConfig" v-model:tls-provider="tlsProvider" v-model:zone-name="zoneName" v-model:custom-nameservers="customNameservers" :domain="domain" :show-advanced="showAdvanced" />
|
||||
@@ -156,14 +160,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" />
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref } from 'vue';
|
||||
import { ref, watch } from 'vue';
|
||||
import { TextInput, InputGroup, MaskedInput, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
|
||||
import { ENDPOINTS_OVH } from '../constants.js';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -53,15 +53,6 @@ function needsPort80(dnsProvider, tlsProvider) {
|
||||
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
|
||||
}
|
||||
|
||||
function setDefaultTlsProvider(p) {
|
||||
// wildcard LE won't work without automated DNS
|
||||
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
|
||||
tlsProvider.value = 'letsencrypt-prod';
|
||||
} else {
|
||||
tlsProvider.value = 'letsencrypt-prod-wildcard';
|
||||
}
|
||||
}
|
||||
|
||||
function resetFields() {
|
||||
dnsConfig.value.accessKeyId = '';
|
||||
dnsConfig.value.accessKey = '';
|
||||
@@ -86,10 +77,16 @@ function resetFields() {
|
||||
dnsConfig.value.username = '';
|
||||
}
|
||||
|
||||
function onProviderChange(p) {
|
||||
setDefaultTlsProvider(p);
|
||||
resetFields(p);
|
||||
}
|
||||
watch(provider, (p) => {
|
||||
resetFields();
|
||||
|
||||
// wildcard LE won't work without automated DNS
|
||||
if (p === 'manual' || p === 'noop' || p === 'wildcard') {
|
||||
tlsProvider.value = 'letsencrypt-prod';
|
||||
} else {
|
||||
tlsProvider.value = 'letsencrypt-prod-wildcard';
|
||||
}
|
||||
}, { immediate: true });
|
||||
|
||||
const gcdnsFileParseError = ref('');
|
||||
function onGcdnsFileInputChange(event) {
|
||||
@@ -130,9 +127,13 @@ function onGcdnsFileInputChange(event) {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label for="providerInput">{{ $t('domains.domainDialog.provider') }} <sup><a href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect v-model="provider" @select="onProviderChange" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required />
|
||||
<SingleSelect v-model="provider" :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 +149,8 @@ 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();"/>
|
||||
<input style="display: none" :value="dnsConfig.credentials.client_email" required /> <!-- for form validation -->
|
||||
<TextInput readonly style="cursor: pointer; 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>
|
||||
@@ -310,19 +312,16 @@ function onGcdnsFileInputChange(event) {
|
||||
<FormGroup v-if="showAdvanced">
|
||||
<label for="zoneNameInput">{{ $t('domains.domainDialog.zoneName') }} <sup><a href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="zoneNameInput" v-model="zoneName" />
|
||||
<small class="helper-text">{{ $t('domains.domainDialog.zoneNamePlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
|
||||
<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>
|
||||
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name"/>
|
||||
<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" 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>
|
||||
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -9,10 +9,12 @@ const props = defineProps({
|
||||
helpUrl: { type: String, required: false },
|
||||
value: { type: String, required: true },
|
||||
disabled: { type: Boolean, default: false },
|
||||
required: { type: Boolean, default: false },
|
||||
saving: { type: Boolean, default: false },
|
||||
multiline: { type: Boolean, default: false },
|
||||
markdown: { type: Boolean, default: false },
|
||||
rows: { type: Number, default: 2 },
|
||||
maxlength: { type: Number, default: -1 },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['save']);
|
||||
@@ -41,6 +43,7 @@ function startEdit() {
|
||||
}
|
||||
|
||||
function save() {
|
||||
if (props.required && !draftValue.value) return;
|
||||
emit('save', draftValue.value);
|
||||
}
|
||||
|
||||
@@ -54,9 +57,9 @@ function cancel() {
|
||||
<FormGroup>
|
||||
<label>{{ label }} <sup v-if="helpUrl"><a :href="helpUrl" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div v-if="editing" style="display: flex; align-items: center; gap: 6px">
|
||||
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving"/>
|
||||
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving"></textarea>
|
||||
<Button tool @click="save" :disabled="saving">{{ $t('main.dialog.save') }}</Button>
|
||||
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"/>
|
||||
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"></textarea>
|
||||
<Button tool @click="save" :disabled="saving || (required && !draftValue)">{{ $t('main.dialog.save') }}</Button>
|
||||
<Button tool plain secondary @click="cancel" :disabled="saving">{{ $t('main.dialog.cancel') }}</Button>
|
||||
</div>
|
||||
<div v-else>
|
||||
|
||||
@@ -49,7 +49,7 @@ const autoCreate = ref(false);
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -258,7 +258,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy" v-if="provider !== 'noop'">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isFormValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<FormGroup :class="{ 'has-error': editError.url }">
|
||||
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import { isValidEmail } from '@cloudron/pankow/utils';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -15,15 +15,18 @@ const busy = ref (false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (email.value && !isValidEmail(email.value)) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (!isValidEmail(email.value)) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -56,6 +59,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,21 +78,21 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.email">
|
||||
<label>{{ $t('profile.changeEmail.email') }}</label>
|
||||
<EmailInput v-model="email" />
|
||||
<EmailInput v-model="email" required/>
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changeEmail.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required/>
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
@@ -369,9 +372,9 @@ 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('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
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 {
|
||||
|
||||
@@ -40,17 +40,27 @@ function renderTooltip(context) {
|
||||
return;
|
||||
}
|
||||
|
||||
const { title, body, labelColors } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
// datapoints are in sync with the indexing of body
|
||||
const { title, body, labelColors, dataPoints } = tooltip; // these were computed in the "callback" in tooltip configuration
|
||||
if (body) {
|
||||
const titleLines = title || [];
|
||||
const bodyLines = body.map(item => item.lines);
|
||||
const bodyLines = body.map(item => { return { label: item.lines }; });
|
||||
|
||||
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</div>`;
|
||||
|
||||
bodyLines.forEach(function(body, i) {
|
||||
const colors = labelColors[i];
|
||||
innerHtml += `<div style="color: ${colors.borderColor}" class="graphs-tooltip-item">${body}</div>`;
|
||||
// first amend the value so we know the dataPoints index, then sort and render
|
||||
bodyLines.forEach((body, i) => {
|
||||
body.value = dataPoints[i].parsed?.y || 0;
|
||||
body.color = labelColors[i].borderColor;
|
||||
});
|
||||
bodyLines.sort((a, b) => {
|
||||
return b.value - a.value;
|
||||
});
|
||||
bodyLines.slice(0, 5).forEach(body => {
|
||||
innerHtml += `<div style="color: ${body.color}" class="graphs-tooltip-item">${body.label}</div>`;
|
||||
});
|
||||
|
||||
if (bodyLines.length > 5) innerHtml += '<div class="graphs-tooltip-item graphs-tooltip-ellipsis">⋯</div>';
|
||||
|
||||
tooltipElem.value.innerHTML = innerHtml;
|
||||
}
|
||||
@@ -340,7 +350,7 @@ defineExpose({
|
||||
.graph {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 160px;
|
||||
height: 200px;
|
||||
}
|
||||
|
||||
.footer {
|
||||
@@ -369,8 +379,33 @@ defineExpose({
|
||||
border-right: 1px var(--pankow-color-primary) solid;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item {
|
||||
padding: 2px 0px;
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
padding: 2px;
|
||||
padding-left: 10px;
|
||||
padding-right: 10px;
|
||||
background: rgba(255,255,255,0.8);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.graphs-tooltip-item,
|
||||
.graphs-tooltip-title {
|
||||
background: var(--pankow-color-background);
|
||||
}
|
||||
}
|
||||
|
||||
.graphs-tooltip-title {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-item:last-of-type {
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
|
||||
.graphs-tooltip-ellipsis {
|
||||
font-size: 9px;
|
||||
padding-top: 0;
|
||||
padding-bottom: 5px !important;
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -19,9 +19,9 @@ const group = ref(null);
|
||||
const busy = ref(false);
|
||||
const formError = ref({});
|
||||
const name = ref('');
|
||||
const users = ref([]);
|
||||
const userIds = ref([]);
|
||||
const allUsers = ref([]);
|
||||
const apps = ref([]);
|
||||
const appIds = ref([]);
|
||||
const allApps = ref([]);
|
||||
|
||||
async function onSubmit() {
|
||||
@@ -29,7 +29,7 @@ async function onSubmit() {
|
||||
formError.value = {};
|
||||
|
||||
if (group.value) {
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.update(group.value.id, name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -37,7 +37,7 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
} else {
|
||||
const [error] = await groupsModel.add(name.value, users.value, apps.value);
|
||||
const [error] = await groupsModel.add(name.value, userIds.value, appIds.value);
|
||||
if (error) {
|
||||
if (error.body && error.body.message.indexOf('name') === 0) formError.value.name = error.body.message;
|
||||
else formError.value.generic = error.body ? error.body.message : 'Internal error';
|
||||
@@ -63,13 +63,13 @@ defineExpose({
|
||||
if (error) return console.error(error);
|
||||
result.forEach(u => u.label = (u.username || u.email));
|
||||
allUsers.value = result;
|
||||
users.value = g ? g.userIds : [];
|
||||
userIds.value = g ? g.userIds : [];
|
||||
|
||||
[error, result] = await appsModel.list();
|
||||
if (error) return console.error(error);
|
||||
result.forEach(a => a.label = (a.label || a.fqdn));
|
||||
allApps.value = result;
|
||||
apps.value = g ? g.appIds : [];
|
||||
appIds.value = g ? g.appIds : [];
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
@@ -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 !== ''"
|
||||
@@ -103,13 +103,14 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label for="usersInput">{{ $t('users.group.users') }}</label>
|
||||
<div v-if="group?.source"><span v-for="user of groupEdit.selectedUsers" :key="user.id"> {{ (user.username || user.email) }}</span></div>
|
||||
<MultiSelect v-else v-model="users" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
<!-- membership of external groups cannot be edited -->
|
||||
<div v-if="group?.source"><span v-for="userId of userIds" :key="userId" style="padding-right: 5px">{{ allUsers.find(u => u.id === userId)?.username || allUsers.find(u => u.id === userId)[userId]?.email }}</span></div>
|
||||
<MultiSelect v-else v-model="userIds" :options="allUsers" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="appsInput">Access to Apps</label>
|
||||
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
<label for="appsInput">{{ $t('users.group.allowedApps') }}</label>
|
||||
<MultiSelect v-model="appIds" :options="allApps" option-key="id" :search-threshold="20"/>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -7,12 +7,13 @@ const t = i18n.t;
|
||||
import { onMounted, onUnmounted, ref, useTemplateRef, inject } from 'vue';
|
||||
import { marked } from 'marked';
|
||||
import { eachLimit } from 'async';
|
||||
import { Button, Popover, Icon, Spinner } from '@cloudron/pankow';
|
||||
import { Menu, Button, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
|
||||
import { prettyDate, prettyLongDate } from '@cloudron/pankow/utils';
|
||||
import NotificationsModel from '../models/NotificationsModel.js';
|
||||
import ServicesModel from '../models/ServicesModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const props = defineProps(['config', 'subscription']);
|
||||
defineProps(['config', 'subscription']);
|
||||
|
||||
const profile = inject('profile');
|
||||
|
||||
@@ -24,6 +25,7 @@ function onOpenHelp(popover, event, elem) {
|
||||
}
|
||||
|
||||
const servicesModel = ServicesModel.create();
|
||||
const profileModel = ProfileModel.create();
|
||||
|
||||
const notificationModel = NotificationsModel.create();
|
||||
const notificationButton = useTemplateRef('notificationButton');
|
||||
@@ -33,6 +35,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,12 +46,15 @@ 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() {
|
||||
notificationsAllBusy.value = true;
|
||||
|
||||
await eachLimit(notifications.value, 2, async (notification) => {
|
||||
await eachLimit(notifications.value, 5, async (notification) => {
|
||||
notification.busy = true;
|
||||
const [error] = await notificationModel.update(notification.id, true);
|
||||
if (error) return console.error(error);
|
||||
@@ -55,6 +63,8 @@ async function onMarkAllNotificationRead() {
|
||||
await refresh();
|
||||
|
||||
notificationsAllBusy.value = false;
|
||||
|
||||
if (notifications.value.length === 0) setTimeout(notificationPopover.value.close, 2000);
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
@@ -77,7 +87,7 @@ function onSubscriptionRequired() {
|
||||
|
||||
const platformStatus = ref({
|
||||
message: '',
|
||||
isReady: true,
|
||||
state: '',
|
||||
});
|
||||
|
||||
let platformTimeoutId = 0;
|
||||
@@ -87,7 +97,16 @@ async function trackPlatformStatus() {
|
||||
|
||||
platformStatus.value = result;
|
||||
|
||||
if (!result.isReady) platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
|
||||
if (result.state === 'starting') platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
function onShowPlatformError() {
|
||||
inputDialog.value.info({
|
||||
confirmLabel: t('main.dialog.close'),
|
||||
title: t('main.platform.startupFailed'),
|
||||
message: platformStatus.value.message,
|
||||
});
|
||||
}
|
||||
|
||||
const description = marked.parse(t('support.help.description', {
|
||||
@@ -97,6 +116,23 @@ const description = marked.parse(t('support.help.description', {
|
||||
apiLink: 'https://docs.cloudron.io/api.html'
|
||||
}));
|
||||
|
||||
const avatarActions = [{//
|
||||
icon: 'fa-solid fa-circle-user',
|
||||
label: t('profile.title'),
|
||||
action: () => { window.location.href = '#/profile'; }
|
||||
}, {
|
||||
separator: true,
|
||||
}, {
|
||||
icon: 'fa-solid fa-right-from-bracket',
|
||||
label: t('main.logout'),
|
||||
action: () => { profileModel.logout(); }
|
||||
}];
|
||||
|
||||
const avatarMenu = useTemplateRef('avatarMenu');
|
||||
function onAvatarClick(event) {
|
||||
avatarMenu.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
if (profile.value.isAtLeastAdmin) await refresh();
|
||||
|
||||
@@ -111,6 +147,9 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<div class="headerbar">
|
||||
<InputDialog ref="inputDialog"/>
|
||||
<Menu ref="avatarMenu" :model="avatarActions" />
|
||||
|
||||
<Popover ref="notificationPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
|
||||
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
|
||||
<div v-if="notifications.length" style="overflow: auto; margin-bottom: 10px">
|
||||
@@ -150,17 +189,20 @@ onUnmounted(() => {
|
||||
|
||||
<div style="flex-grow: 1;"></div>
|
||||
|
||||
<div v-if="!platformStatus.isReady" class="headerbar-info">
|
||||
<Spinner style="margin-right: 10px"/> {{ platformStatus.message }}
|
||||
<div v-if="platformStatus.state === 'starting'" class="headerbar-info">
|
||||
<Spinner style="margin-right: 10px"/>{{ platformStatus.message }}
|
||||
</div>
|
||||
<div v-else-if="platformStatus.state === 'failed'" class="headerbar-info text-danger" style="cursor: pointer" @click="onShowPlatformError">
|
||||
<Icon :icon="'fa fa-exclamation-triangle'"/> {{ $t('main.platform.startupFailed') }}
|
||||
</div>
|
||||
|
||||
<!-- Warnings if subscription is expired or unpaid -->
|
||||
<div v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" style="gap: 6px" @click="onSubscriptionRequired()">Subscription Expired</div>
|
||||
|
||||
<div class="headerbar-action" style="gap: 6px" v-if="profile.isAtLeastAdmin" ref="notificationButton" @click="onOpenNotifications(notificationPopover, $event, notificationButton)"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</div>
|
||||
<div class="headerbar-action pankow-no-mobile" style="gap: 6px" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
||||
<div class="headerbar-action" v-if="profile.isAtLeastAdmin" ref="notificationButton" @click="onOpenNotifications(notificationPopover, $event, notificationButton)"><Icon :icon="notifications.length ? 'fas fa-bell' : 'far fa-bell'"/> {{ notifications.length > 99 ? '99+' : notifications.length }}</div>
|
||||
<div class="headerbar-action pankow-no-mobile" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
|
||||
<!-- <a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="#/support"><Icon icon="fa fa-question"/></a> -->
|
||||
<a class="headerbar-action" href="#/profile"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
||||
<a class="headerbar-action" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -175,13 +217,14 @@ onUnmounted(() => {
|
||||
|
||||
.headerbar-info {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
padding: 4px 15px;
|
||||
}
|
||||
|
||||
.headerbar-action {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
cursor: pointer;
|
||||
color: var(--pankow-text-color);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,16 @@ const editProvider = ref('');
|
||||
const editAddress = ref('');
|
||||
const editInterfaceName = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) return false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await networkModel.getIpv4Config();
|
||||
@@ -65,10 +69,11 @@ function onConfigure() {
|
||||
editInterfaceName.value = interfaceName.value || '';
|
||||
|
||||
dialog.value.open();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
editBusy.value = true;
|
||||
editError.value = {};
|
||||
@@ -100,39 +105,39 @@ onMounted(async () => {
|
||||
:title="$t('network.configureIp.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="editBusy"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="!editBusy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
|
||||
<input style="display: none" type="submit"/>
|
||||
|
||||
<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>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
|
||||
<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>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, useTemplateRef, computed } from 'vue';
|
||||
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
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';
|
||||
}
|
||||
}
|
||||
@@ -36,12 +36,16 @@ const editProvider = ref('');
|
||||
const editAddress = ref('');
|
||||
const editInterfaceName = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) return false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
|
||||
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
let [error, result] = await networkModel.getIpv6Config();
|
||||
@@ -65,10 +69,11 @@ function onConfigure() {
|
||||
editInterfaceName.value = interfaceName.value || '';
|
||||
|
||||
dialog.value.open();
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
editBusy.value = true;
|
||||
editError.value = {};
|
||||
@@ -100,23 +105,23 @@ onMounted(async () => {
|
||||
:title="$t('network.configureIpv6.title')"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:confirm-busy="editBusy"
|
||||
:confirm-active="isValid"
|
||||
:confirm-active="!editBusy && isFormValid"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="editBusy">
|
||||
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<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"/>
|
||||
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name" required/>
|
||||
<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 +135,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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, onMounted, computed } from 'vue';
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, FormGroup, ClipboardButton, Checkbox, PasswordInput, TextInput, InputGroup } from '@cloudron/pankow';
|
||||
import Section from './Section.vue';
|
||||
import DomainsModel from '../models/DomainsModel.js';
|
||||
@@ -19,17 +19,14 @@ const ldapUrl = ref('');
|
||||
const secret = ref('');
|
||||
const allowlist = ref('');
|
||||
|
||||
const isValid = computed(() => {
|
||||
if (enabled.value) {
|
||||
if (!secret.value) return false;
|
||||
if (!allowlist.value) return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
editError.value = {};
|
||||
@@ -65,6 +62,8 @@ onMounted(async () => {
|
||||
enabled.value = result.enabled;
|
||||
secret.value = result.secret;
|
||||
allowlist.value = result.allowlist;
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -72,11 +71,10 @@ onMounted(async () => {
|
||||
<template>
|
||||
<Section :title="$t('users.exposedLdap.title')">
|
||||
<div>{{ $t('users.exposedLdap.description') }}</div>
|
||||
<br/>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none" type="submit" :disabled="busy || !isValid" />
|
||||
<input style="display: none" type="submit" />
|
||||
|
||||
<Checkbox v-model="enabled" :label="$t('users.exposedLdap.enabled')" help-url="https://docs.cloudron.io/user-directory/#ldap-directory-server"/>
|
||||
|
||||
@@ -92,14 +90,15 @@ onMounted(async () => {
|
||||
<FormGroup>
|
||||
<label for="secretInput">{{ $t('users.exposedLdap.secret.label') }}</label>
|
||||
<div description v-html="$t('users.exposedLdap.secret.description', { userDN: 'cn=admin,ou=system,dc=cloudron' })"></div>
|
||||
<PasswordInput id="secretInput" v-model="secret" required />
|
||||
<PasswordInput id="secretInput" v-model="secret" required :disabled="!enabled" />
|
||||
<div class="has-error" v-show="editError.secret">{{ editError.secret }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<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 :disabled="!enabled"></textarea>
|
||||
<small style="helper-text">{{ $t('users.exposedLdap.ipRestriction.placeholder') }}</small>
|
||||
<div class="has-error" v-show="editError.allowlist">{{ editError.allowlist }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -108,6 +107,6 @@ onMounted(async () => {
|
||||
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
|
||||
|
||||
<br/>
|
||||
<Button :loading="busy" :disabled="!isValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
<Button :loading="busy" :disabled="!isFormValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
|
||||
</Section>
|
||||
</template>
|
||||
|
||||
@@ -1,150 +1,144 @@
|
||||
<script>
|
||||
<script setup>
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef, onUnmounted, onMounted } from 'vue';
|
||||
import { Button, InputDialog, TopBar, MainLayout, ButtonGroup } from '@cloudron/pankow';
|
||||
import LogsModel from '../models/LogsModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
|
||||
export default {
|
||||
name: 'LogsViewer',
|
||||
components: {
|
||||
Button,
|
||||
ButtonGroup,
|
||||
InputDialog,
|
||||
MainLayout,
|
||||
TopBar
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
accessToken: localStorage.token,
|
||||
logsModel: null,
|
||||
appsModel: null,
|
||||
busyRestart: false,
|
||||
showRestart: false,
|
||||
showFilemanager: false,
|
||||
showTerminal: false,
|
||||
id: '',
|
||||
name: '',
|
||||
type: '',
|
||||
downloadUrl: '',
|
||||
logLines: []
|
||||
};
|
||||
},
|
||||
methods: {
|
||||
onClear() {
|
||||
while (this.$refs.linesContainer.firstChild) this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
},
|
||||
onDownload() {
|
||||
this.logsModel.download();
|
||||
},
|
||||
async onRestartApp() {
|
||||
if (this.type !== 'app') return;
|
||||
const linesContainer = useTemplateRef('linesContainer');
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
|
||||
const confirmed = await this.$refs.inputDialog.confirm({
|
||||
message: this.$t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmStyle: 'primary',
|
||||
confirmLabel: this.$t('main.dialog.yes'),
|
||||
rejectLabel: this.$t('main.dialog.no'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
let logsModel = null;
|
||||
const appsModel = AppsModel.create();
|
||||
let refreshInterval = 0;
|
||||
|
||||
if (!confirmed) return;
|
||||
const busyRestart = ref(false);
|
||||
const showRestart = ref(false);
|
||||
const showFilemanager = ref(false);
|
||||
const showTerminal = ref(false);
|
||||
const id = ref('');
|
||||
const name = ref('');
|
||||
const type = ref('');
|
||||
const downloadUrl = ref('');
|
||||
|
||||
this.busyRestart = true;
|
||||
function onClear() {
|
||||
while (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
}
|
||||
|
||||
const [error] = await this.appsModel.restart(this.id);
|
||||
if (error) return console.error(error);
|
||||
async function onRestartApp() {
|
||||
if (type.value !== 'app') return;
|
||||
|
||||
this.busyRestart = false;
|
||||
}
|
||||
},
|
||||
async mounted() {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
const confirmed = await inputDialog.value.confirm({
|
||||
message: t('filemanager.toolbar.restartApp') + '?',
|
||||
confirmLabel: t('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const id = urlParams.get('id');
|
||||
if (!confirmed) return;
|
||||
|
||||
if (appId) {
|
||||
this.type = 'app';
|
||||
this.id = appId;
|
||||
this.name = 'App ' + appId;
|
||||
} else if (taskId) {
|
||||
this.type = 'task';
|
||||
this.id = taskId;
|
||||
this.name = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
this.type = 'crash';
|
||||
this.id = crashId;
|
||||
this.name = 'Crash ' + crashId;
|
||||
} else if (id) {
|
||||
if (id === 'box') {
|
||||
this.type = 'platform';
|
||||
this.id = id;
|
||||
this.name = 'Box';
|
||||
} else {
|
||||
this.type = 'service';
|
||||
this.id = id;
|
||||
this.name = 'Service ' + id;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
busyRestart.value = true;
|
||||
|
||||
this.logsModel = LogsModel.create(this.type, this.id);
|
||||
const [error] = await appsModel.restart(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
if (this.type === 'app') {
|
||||
this.appsModel = AppsModel.create();
|
||||
busyRestart.value = false;
|
||||
}
|
||||
|
||||
const [error, app] = await this.appsModel.get(this.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
this.name = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
this.showFilemanager = !!app.manifest.addons.localstorage;
|
||||
this.showTerminal = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
this.showRestart = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${this.name}`;
|
||||
|
||||
this.downloadUrl = this.logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else this.$refs.linesContainer.removeChild(this.$refs.linesContainer.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
this.$refs.linesContainer.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
this.logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
onMounted(async () => {
|
||||
if (!localStorage.token) {
|
||||
console.error('Set localStorage.token');
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const appId = urlParams.get('appId');
|
||||
const taskId = urlParams.get('taskId');
|
||||
const crashId = urlParams.get('crashId');
|
||||
const idParam = urlParams.get('id');
|
||||
|
||||
if (appId) {
|
||||
type.value = 'app';
|
||||
id.value = appId;
|
||||
name.value = 'App ' + appId;
|
||||
} else if (taskId) {
|
||||
type.value = 'task';
|
||||
id.value = taskId;
|
||||
name.value = 'Task ' + taskId;
|
||||
} else if (crashId) {
|
||||
type.value = 'crash';
|
||||
id.value = crashId;
|
||||
name.value = 'Crash ' + crashId;
|
||||
} else if (idParam) {
|
||||
if (idParam === 'box') {
|
||||
type.value = 'platform';
|
||||
id.value = idParam;
|
||||
name.value = 'Box';
|
||||
} else {
|
||||
type.value = 'service';
|
||||
id.value = idParam;
|
||||
name.value = 'Service ' + idParam;
|
||||
}
|
||||
} else {
|
||||
console.error('no supported log type specified');
|
||||
return;
|
||||
}
|
||||
|
||||
logsModel = LogsModel.create(type.value, id.value);
|
||||
|
||||
if (type.value === 'app') {
|
||||
const [error, app] = await appsModel.get(id.value);
|
||||
if (error) return console.error(error);
|
||||
|
||||
name.value = `${app.label || app.fqdn} (${app.manifest.title})`;
|
||||
showFilemanager.value = !!app.manifest.addons.localstorage;
|
||||
showTerminal.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
showRestart.value = app.manifest.id !== 'io.cloudron.builtin.appproxy';
|
||||
}
|
||||
|
||||
window.document.title = `Logs Viewer - ${name.value}`;
|
||||
|
||||
downloadUrl.value = logsModel.getDownloadUrl();
|
||||
|
||||
const maxLines = 1000;
|
||||
let lines = 0;
|
||||
let newLogLines = [];
|
||||
|
||||
const tmp = document.getElementsByClassName('pankow-main-layout-body')[0];
|
||||
refreshInterval = setInterval(() => {
|
||||
newLogLines = newLogLines.slice(-maxLines);
|
||||
|
||||
for (const line of newLogLines) {
|
||||
if (lines < maxLines) ++lines;
|
||||
else if (linesContainer.value.firstChild) linesContainer.value.removeChild(linesContainer.value.firstChild);
|
||||
|
||||
const logLine = document.createElement('div');
|
||||
logLine.className = 'log-line';
|
||||
logLine.innerHTML = `<span class="time">${line.time || '[no timestamp] ' }</span> <span>${line.html}</span>`;
|
||||
linesContainer.value.appendChild(logLine);
|
||||
|
||||
const autoScroll = tmp.scrollTop > (tmp.scrollHeight - tmp.clientHeight - 34);
|
||||
if (autoScroll) setTimeout(() => tmp.scrollTop = tmp.scrollHeight, 1);
|
||||
}
|
||||
|
||||
newLogLines = [];
|
||||
}, 500);
|
||||
|
||||
logsModel.stream((time, html) => {
|
||||
newLogLines.push({ time, html });
|
||||
}, function (error) {
|
||||
newLogLines.push({ time: error.time, html: error.html });
|
||||
});
|
||||
});
|
||||
|
||||
onUnmounted(() => {
|
||||
clearInterval(refreshInterval);
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
|
||||
@@ -84,9 +84,10 @@ onMounted(async () => {
|
||||
<div v-html="$t('email.dnsStatus.description', { emailDnsDocsLink:'https://docs.cloudron.io/email/#dns-records'})"></div>
|
||||
<br/>
|
||||
|
||||
<!-- DNS records including PTR4/PTR6 -->
|
||||
<div v-if="domainStatus.mx">
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<div>
|
||||
<div v-for="(item, key) in dnsRecordLabels" :key="key" class="record-item">
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa-solid" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
@@ -95,6 +96,7 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ item.label }} record</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
@@ -131,8 +133,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="domainStatus.relay" class="record-item" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<div>
|
||||
<!-- outbound SMTP / Relay status -->
|
||||
<div v-if="domainStatus.relay" class="record-item">
|
||||
<div class="record-header" @click="domainStatus.relay.isOpen = !domainStatus.relay.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus.relay.status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus.relay.status === 'failed',
|
||||
@@ -141,33 +144,41 @@ onMounted(async () => {
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ $t('email.smtpStatus.outboundSmtp') }}</b>
|
||||
<i class="fa-solid" :class="domainStatus.relay.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen">
|
||||
<div class="record-details" v-if="domainStatus.relay.isOpen" @click.stop>
|
||||
{{ domainStatus.relay.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item" @click="item.isOpen = !item.isOpen">
|
||||
<!-- Blacklist -->
|
||||
<div v-for="(item, key) in rblTypes" :key="key" class="record-item">
|
||||
<div v-if="domainStatus[key]">
|
||||
<div>
|
||||
<div class="record-header" @click="item.isOpen = !item.isOpen">
|
||||
<i v-if="!busy" class="fa" :class="{
|
||||
'fa-check-circle text-success': domainStatus[key].status === 'passed',
|
||||
'fa-exclamation-triangle text-danger': domainStatus[key].status === 'failed',
|
||||
'fa-circle-minus text-success': domainStatus[key].status === 'skipped',
|
||||
'fa-circle-minus text-warning': domainStatus[key].status === 'skipped',
|
||||
}"></i>
|
||||
<i v-else class="fa-solid fa-circle-notch fa-spin"></i>
|
||||
|
||||
<b>{{ key === 'rbl4' ? 'IPv4' : 'IPv6' }} {{ $t('email.smtpStatus.rblCheck') }}</b>
|
||||
<i class="fa-solid" :class="item.isOpen ? 'fa-chevron-down' : 'fa-chevron-right'" style="float: right"></i>
|
||||
</div>
|
||||
<div class="record-details" v-if="item.isOpen">
|
||||
<div v-if="domainStatus[key].status !== 'failed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div class="record-details" v-if="item.isOpen" @click.stop>
|
||||
<div v-if="domainStatus[key].status === 'passed'">IP: {{ domainStatus[key].ip }}</div>
|
||||
<div v-else-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
|
||||
<div v-else>
|
||||
{{ domainStatus[key] }}
|
||||
<div v-if="domainStatus[key].servers.length" v-html="$t('email.smtpStatus.blacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
<div v-else v-html="$t('email.smtpStatus.notBlacklisted', { ip: domainStatus[key].ip })"></div>
|
||||
|
||||
<!-- servers is only the blocked servers -->
|
||||
<br/>
|
||||
<div v-for="server in domainStatus[key].servers" :key="server.name">
|
||||
<a :href="server.removal" target="_blank">{{ server.name }}</a>
|
||||
<a :href="server.removal" target="_blank">{{ server.name }} removal link</a>
|
||||
|
||||
<span v-if="server.txtRecords.length">TXT record: {{ server.txtRecords.join('. ') }}</span>
|
||||
<span v-else>No TXT Records</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,20 +189,19 @@ onMounted(async () => {
|
||||
|
||||
<style scoped>
|
||||
|
||||
.record-item {
|
||||
.record-header {
|
||||
border-radius: var(--pankow-border-radius);
|
||||
padding: 10px;
|
||||
gap: 10px;
|
||||
color: var(--pankow-text-color);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.record-item:hover {
|
||||
.record-header:hover {
|
||||
background-color: var(--pankow-color-background-hover);
|
||||
}
|
||||
|
||||
.record-details {
|
||||
padding: 10px 30px;
|
||||
padding: 10px 40px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
@@ -51,7 +54,7 @@ function usesPasswordAuth(provider) {
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value.checkValidity();
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
function onProviderChange() {
|
||||
@@ -94,6 +97,8 @@ async function onShowDialog() {
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = '';
|
||||
|
||||
@@ -130,6 +135,8 @@ async function onSubmit() {
|
||||
return console.error(error);
|
||||
}
|
||||
|
||||
currentProvider.value = provider.value;
|
||||
|
||||
dialog.value.close();
|
||||
|
||||
busy.value = false;
|
||||
@@ -140,6 +147,7 @@ onMounted(async () => {
|
||||
if (error) return console.error(error);
|
||||
|
||||
provider.value = result.relay.provider;
|
||||
currentProvider.value = result.relay.provider;
|
||||
});
|
||||
|
||||
</script>
|
||||
@@ -167,7 +175,7 @@ onMounted(async () => {
|
||||
|
||||
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
|
||||
<fieldset :disabled="busy" v-if="usesExternalServer(provider)">
|
||||
<input type="submit" style="display: none" :disabled="busy || !isFormValid"/>
|
||||
<input type="submit" style="display: none" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="hostInput">{{ $t('email.outbound.mailRelay.host') }}</label>
|
||||
@@ -207,7 +215,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>
|
||||
|
||||
@@ -35,8 +35,8 @@ const domainHasIncomingEnabled = computed(() => {
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
name: '',
|
||||
domain: dashboardDomain.value,
|
||||
label: '@' + dashboardDomain.value,
|
||||
domain: domain.value,
|
||||
label: '@' + domain.value,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -44,7 +44,15 @@ async function onRemoveAlias(index) {
|
||||
aliases.value.splice(index, 1);
|
||||
}
|
||||
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = '';
|
||||
|
||||
@@ -80,7 +88,7 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
emit('success');
|
||||
emit('success', { domain: domain.value, name: name.value, fullName: name.value + '@' + domain.value });
|
||||
dialog.value.close();
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -99,30 +107,23 @@ defineExpose({
|
||||
active.value = m ? m.active : true;
|
||||
enablePop3.value = m ? m.enablePop3 : false;
|
||||
storageQuotaEnabled.value = m && m.storageQuota ? true : false;
|
||||
storageQuota.value = m ? m.storageQuota : 5*1000*1000*1000;
|
||||
storageQuota.value = m && m.storageQuota ? m.storageQuota : 5*1000*1000*1000;
|
||||
usersAndGroupsAndApps.value = [];
|
||||
|
||||
if (props.users.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Users' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.users);
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.users.map(u => {
|
||||
return { ...u, icon: 'fa-solid fa-user', name: u.username || u.displayName || u.email };
|
||||
}));
|
||||
|
||||
if (props.groups.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Groups' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.groups);
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.groups.map(g => {
|
||||
return { ...g, icon: 'fa-solid fa-users' };
|
||||
}));
|
||||
|
||||
if (props.apps.length) usersAndGroupsAndApps.value.push({ separator: true, label: 'Apps' });
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.apps);
|
||||
|
||||
// unify on .name for multiselect
|
||||
usersAndGroupsAndApps.value.forEach(item => {
|
||||
if (item.appIds) {
|
||||
item.icon = 'fa-solid fa-users';
|
||||
} else if (item.username) {
|
||||
item.icon = 'fa-solid fa-user';
|
||||
item.name = item.username;
|
||||
} else {
|
||||
item.icon = 'fa-solid fa-cube';
|
||||
item.name = item.label || item.fqdn;
|
||||
}
|
||||
});
|
||||
usersAndGroupsAndApps.value = usersAndGroupsAndApps.value.concat(props.apps.map(a => {
|
||||
return { ...a, icon: 'fa-solid fa-cube', name: a.label || a.fqdn };
|
||||
}));
|
||||
|
||||
domainList.value = props.domains.map(d => {
|
||||
return {
|
||||
@@ -133,6 +134,8 @@ defineExpose({
|
||||
});
|
||||
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -140,26 +143,25 @@ 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 !== ''"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" novalidate autocomplete="off">
|
||||
<form @submit.prevent="onSubmit()" novalidate autocomplete="off" ref="form" @input="validateForm()">
|
||||
<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" :required="!mailbox"/>
|
||||
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox" :required="!mailbox"/>
|
||||
</InputGroup>
|
||||
<div class="warning-label" v-if="!domainHasIncomingEnabled">{{ $t('email.addMailboxDialog.incomingDisabledWarning') }}</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
@@ -167,7 +169,7 @@ defineExpose({
|
||||
|
||||
<FormGroup>
|
||||
<label>{{ $t('email.editMailboxDialog.owner') }}</label>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name"/>
|
||||
<SingleSelect v-model="ownerId" :options="usersAndGroupsAndApps" :searchThreshold="10" option-key="id" option-label="name" required/>
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-if="mailbox" v-model="active" :label="$t('email.updateMailboxDialog.activeCheckbox')"/>
|
||||
@@ -192,10 +194,9 @@ defineExpose({
|
||||
<Button tool danger icon="fa-solid fa-trash-alt" @click="onRemoveAlias(index)"/>
|
||||
</InputGroup>
|
||||
</div>
|
||||
<div class="error-label" v-if="formError">{{ formError }}</div>
|
||||
<div style="margin-top: 5px"></div>
|
||||
<div v-if="aliases.length === 0">
|
||||
{{ $t('email.editMailboxDialog.noAliases') }} <span class="actionable" @click="onAddAlias($event)">{{ $t('email.editMailboxDialog.addAliasAction') }}</span>
|
||||
{{ $t('email.editMailboxDialog.noAliases') }} <span class="actionable" @click="onAddAlias">{{ $t('email.editMailboxDialog.addAliasAction') }}</span>
|
||||
</div>
|
||||
<div v-else>
|
||||
<div class="actionable" @click="onAddAlias($event)">{{ $t('email.editMailboxDialog.addAnotherAliasAction') }}</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, inject } from 'vue';
|
||||
import { computed, ref, useTemplateRef, inject } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
|
||||
import MailinglistsModel from '../models/MailinglistsModel.js';
|
||||
|
||||
@@ -21,6 +21,10 @@ const active = ref(true);
|
||||
const domainList = ref([]);
|
||||
const dashboardDomain = inject('dashboardDomain');
|
||||
|
||||
const memberCount = computed(() => {
|
||||
return membersText.value.split('\n').map(m => m.trim()).filter(m => m).length;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -84,7 +88,8 @@ 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')"
|
||||
:style="{ 'min-width': '700px' }"
|
||||
:confirm-label="$t(mailinglist ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-active="!busy && name !== '' && domain !== '' && membersText !== ''"
|
||||
@@ -100,17 +105,17 @@ 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"/>
|
||||
<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>
|
||||
|
||||
<FormGroup>
|
||||
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }}</label>
|
||||
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }} ({{ memberCount }})</label>
|
||||
<textarea id="membersInput" v-model="membersText" rows="5"></textarea>
|
||||
<div class="error-label" v-if="formError.members">{{ formError.members }}</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -16,7 +16,7 @@ onMounted(async () => {
|
||||
<div>
|
||||
<FormGroup>
|
||||
<label>{{ $t('app.accessControl.operators.title') }} <sup><a href="https://docs.cloudron.io/apps/#operators" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<div>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
|
||||
<div description>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
|
||||
</FormGroup>
|
||||
|
||||
<div style="margin-top: 10px; margin-left: 20px; display: flex; gap: 10px;">
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -15,16 +15,18 @@ const newPassword = ref('');
|
||||
const newPasswordRepeat = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!newPassword.value) return false;
|
||||
if (newPasswordRepeat.value !== newPassword.value) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
return true;
|
||||
});
|
||||
if (isFormValid.value) {
|
||||
if (newPasswordRepeat.value !== newPassword.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -58,6 +60,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -75,27 +79,27 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;">
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.newPassword">
|
||||
<label>{{ $t('profile.changePassword.newPassword') }}</label>
|
||||
<PasswordInput v-model="newPassword" />
|
||||
<PasswordInput v-model="newPassword" required/>
|
||||
<div class="error-label" v-if="formError.newPassword">{{ formError.newPassword }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="newPasswordRepeat.length !== 0 && newPassword !== newPasswordRepeat">
|
||||
<label>{{ $t('profile.changePassword.newPasswordRepeat') }}</label>
|
||||
<PasswordInput v-model="newPasswordRepeat" />
|
||||
<PasswordInput v-model="newPasswordRepeat" required />
|
||||
<div class="error-label" v-if="newPasswordRepeat.length && newPassword !== newPasswordRepeat">{{ $t('profile.changePassword.errorPasswordsDontMatch') }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changePassword.currentPassword') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -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,9 +1,9 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, computed } from 'vue';
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
|
||||
import { isValidEmail } from '@cloudron/pankow/utils';
|
||||
import ProfileModel from '../../models/ProfileModel.js';
|
||||
import ProfileModel from '../models/ProfileModel.js';
|
||||
|
||||
const emit = defineEmits([ 'success' ]);
|
||||
|
||||
@@ -15,15 +15,19 @@ const busy = ref (false);
|
||||
const email = ref('');
|
||||
const password = ref('');
|
||||
|
||||
const isFormValid = computed(() => {
|
||||
if (!isValidEmail(email.value)) return false;
|
||||
if (!password.value) return false;
|
||||
const form = useTemplateRef('form');
|
||||
const isFormValid = ref(false);
|
||||
function checkValidity() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (!isValidEmail(email.value)) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
async function onSubmit() {
|
||||
if (!isFormValid.value) return;
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
busy.value = true;
|
||||
formError.value = {};
|
||||
@@ -56,6 +60,8 @@ defineExpose({
|
||||
busy.value = false;
|
||||
formError.value = {};
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(checkValidity, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -73,21 +79,21 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit"
|
||||
>
|
||||
<form @submit.prevent="onSubmit" autocomplete="off">
|
||||
<form @submit.prevent="onSubmit" autocomplete="off" ref="form" @input="checkValidity()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" :disabled="!isFormValid"/>
|
||||
<input type="submit" style="display: none;"/>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<FormGroup :has-error="formError.email">
|
||||
<label>{{ $t('profile.changeEmail.email') }}</label>
|
||||
<EmailInput v-model="email" />
|
||||
<EmailInput v-model="email" required/>
|
||||
<div class="error-label" v-if="formError.email">{{ formError.email }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup :has-error="formError.password">
|
||||
<label>{{ $t('profile.changeEmail.password') }}</label>
|
||||
<PasswordInput v-model="password" />
|
||||
<PasswordInput v-model="password" required />
|
||||
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
@@ -110,6 +110,7 @@ defineProps({
|
||||
font-weight: 400;
|
||||
font-size: 1.75em;
|
||||
margin-bottom: 1rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.public-page-layout-right {
|
||||
|
||||
55
dashboard/src/components/RequestErrorDialog.vue
Normal file
55
dashboard/src/components/RequestErrorDialog.vue
Normal file
@@ -0,0 +1,55 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef } from 'vue';
|
||||
import { Dialog } from '@cloudron/pankow';
|
||||
|
||||
const dialog = useTemplateRef('dialog');
|
||||
|
||||
const status = ref(0);
|
||||
const message = ref('');
|
||||
const stackTrace = ref('');
|
||||
|
||||
async function onError(error) {
|
||||
// this is handled by the fetcher global error hook
|
||||
if (error.status === 401 || error.status >= 502 || error instanceof TypeError) return;
|
||||
|
||||
console.error(error);
|
||||
|
||||
status.value = error.status || 0;
|
||||
message.value = error.body?.message || error.message || 'unkown';
|
||||
|
||||
let stack = '';
|
||||
if (error.stack) stack = error.stack;
|
||||
else stack = (new Error()).stack;
|
||||
|
||||
if (stack.indexOf('Error') === 0) { // chrome v8
|
||||
stackTrace.value = stack.split('\n').slice(2, 7).map(l => l.slice(' at '.length).split(' ')[0] + '()').join('\n');
|
||||
} else { // firefox and safari
|
||||
stackTrace.value = stack.split('\n').slice(1, 7).map(l => l.split('@')[0] + '()').join('\n');
|
||||
}
|
||||
|
||||
dialog.value.open();
|
||||
}
|
||||
|
||||
if (!window.cloudron) window.cloudron = {};
|
||||
window.cloudron.onError = onError;
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog" title="Unhandled error"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div>
|
||||
<label v-if="status">Status:</label>
|
||||
<pre v-if="status">{{ status }}</pre>
|
||||
<label>Details:</label>
|
||||
<pre>{{ message }}</pre>
|
||||
<label>Trace:</label>
|
||||
<pre>
|
||||
{{ stackTrace }}
|
||||
...
|
||||
</pre>
|
||||
</div>
|
||||
</Dialog>
|
||||
</template>
|
||||
@@ -36,6 +36,15 @@ watch(password, () => {
|
||||
formError.value.password = null;
|
||||
});
|
||||
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
|
||||
if (isFormValid.value) {
|
||||
if (password.value !== passwordRepeat.value) isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
@@ -107,12 +116,12 @@ onMounted(async () => {
|
||||
<PublicPageLayout :footer-html="footer" :cloudron-name="cloudronName">
|
||||
<div>
|
||||
<div v-if="mode === MODE.SETUP">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<p style="margin-bottom: 8px">{{ $t('setupAccount.description') }}</p>
|
||||
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset>
|
||||
<!-- prevents autofill -->
|
||||
<input type="password" style="display: none;"/>
|
||||
@@ -145,26 +154,23 @@ onMounted(async () => {
|
||||
</form>
|
||||
|
||||
<br/>
|
||||
<Button :disabled="busy || password !== passwordRepeat" :loading="busy" @click="onSubmit()">{{ $t('setupAccount.setupAction') }}</Button>
|
||||
<Button :disabled="busy || !isFormValid" :loading="busy" @click="onSubmit()">{{ $t('setupAccount.setupAction') }}</Button>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.NO_USERNAME">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3>{{ $t('setupAccount.noUsername.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.noUsername.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.INVALID_TOKEN">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3 class="error-label">{{ $t('setupAccount.invalidToken.title') }}</h3>
|
||||
<div>{{ $t('setupAccount.invalidToken.description') }}</div>
|
||||
</div>
|
||||
|
||||
<div v-if="mode === MODE.DONE">
|
||||
<h2>{{ $t('setupAccount.welcomeTo') }}</h2>
|
||||
<br/>
|
||||
<h2>{{ $t('setupAccount.welcome') }}</h2>
|
||||
<h3>{{ $t('setupAccount.success.title') }}</h3>
|
||||
<Button :href="dashboardUrl">{{ $t('setupAccount.success.openDashboardAction') }}</Button>
|
||||
</div>
|
||||
|
||||
@@ -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">
|
||||
|
||||
195
dashboard/src/components/SideBar.vue
Normal file
195
dashboard/src/components/SideBar.vue
Normal file
@@ -0,0 +1,195 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, useTemplateRef, onMounted, inject } from 'vue';
|
||||
import { onSwipe } from '@cloudron/pankow/gestures.js';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
defineProps({
|
||||
cloudronAvatarUrl: {
|
||||
type: String,
|
||||
default: '',
|
||||
},
|
||||
cloudronName: {
|
||||
type: String,
|
||||
default: 'Cloudron',
|
||||
},
|
||||
items: {
|
||||
type: Array
|
||||
}
|
||||
});
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
const sideBar = useTemplateRef('sideBar');
|
||||
const isVisible = ref(false);
|
||||
const isCollapsed = ref(!!window.localStorage['sideBarCollapsed']);
|
||||
|
||||
function open() {
|
||||
isVisible.value = true;
|
||||
}
|
||||
|
||||
function close() {
|
||||
isVisible.value = false;
|
||||
}
|
||||
|
||||
function onToggleCollapse() {
|
||||
isCollapsed.value = !isCollapsed.value;
|
||||
if (isCollapsed.value) window.localStorage['sideBarCollapsed'] = 'true';
|
||||
else window.localStorage.removeItem('sideBarCollapsed');
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
onSwipe(sideBar.value, (direction) => {
|
||||
if (direction === 'left') close();
|
||||
});
|
||||
});
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="sidebar" ref="sideBar" :class="{ 'sidebar-closed': !isVisible, 'sidebar-collapsed': isCollapsed }">
|
||||
<Transition name="pankow-scale">
|
||||
<div class="sidebar-close-action" v-if="isVisible" @click="close()"><i class="fa-solid fa-xmark"></i></div>
|
||||
<div class="sidebar-open-action" v-else @click="open()"><i class="fa-solid fa-bars"></i></div>
|
||||
</Transition>
|
||||
<div class="sidebar-inner">
|
||||
<a href="#/" class="sidebar-logo" @click="close()">
|
||||
<img :src="cloudronAvatarUrl" :alt="cloudronName + ' icon'" v-tooltip.right="isCollapsed && !isMobile ? cloudronName : null"/> {{ cloudronName }}
|
||||
</a>
|
||||
<div class="sidebar-list">
|
||||
<SideBarItem v-for="item in items" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
:collapsed="isCollapsed"
|
||||
@close="close"
|
||||
/>
|
||||
</div>
|
||||
<div style="flex-grow: 1"></div>
|
||||
<div class="sidebar-collapse-action pankow-no-mobile" @click="onToggleCollapse()" v-tooltip.right="isCollapsed && !isMobile ? $t('main.sidebar.collapseAction') : null"><i class="fa-solid" :class="{ 'fa-arrow-left': !isCollapsed, 'fa-arrow-right': isCollapsed }"></i> <span v-if="!isCollapsed">{{ $t('main.sidebar.collapseAction') }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar {
|
||||
display: block;
|
||||
height: 100%;
|
||||
overflow: auto;
|
||||
background-color: var(--navbar-background);
|
||||
padding: 22px 10px 10px 10px;
|
||||
margin-right: 20px;
|
||||
}
|
||||
|
||||
.sidebar-collapsed {
|
||||
min-width: unset !important;
|
||||
width: 70px;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action {
|
||||
display: block;
|
||||
color: gray;
|
||||
border-radius: 3px;
|
||||
padding: 5px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
transition: all 180ms ease-out;
|
||||
}
|
||||
|
||||
.sidebar-collapse-action i {
|
||||
opacity: 0.5;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
.sidebar-inner {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
font-size: 24px;
|
||||
padding: 8px 14px;
|
||||
cursor: pointer;
|
||||
color: var(--pankow-color-dark);
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
font-size: 32px;
|
||||
padding: 8px 20px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-logo img {
|
||||
margin-right: 10px;
|
||||
height: 40px;
|
||||
width: 40px;
|
||||
border-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-logo,
|
||||
.sidebar-logo:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--pankow-text-color);
|
||||
text-decoration: none;
|
||||
padding-left: 5px;
|
||||
max-width: 300px;
|
||||
overflow: hidden;
|
||||
min-height: 55px;
|
||||
}
|
||||
|
||||
.sidebar-list {
|
||||
overflow: auto;
|
||||
padding-top: 25px;
|
||||
scrollbar-color: transparent transparent;
|
||||
scrollbar-width: thin;
|
||||
}
|
||||
|
||||
.sidebar-list:hover {
|
||||
scrollbar-color: var(--color-neutral-border) transparent;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 2000;
|
||||
transition: left 250ms ease-in-out;
|
||||
}
|
||||
|
||||
.sidebar-closed {
|
||||
position: fixed;
|
||||
left: -600px; /* depends on media query */
|
||||
}
|
||||
|
||||
.sidebar-open-action {
|
||||
display: block;
|
||||
position: fixed;
|
||||
left: 0;
|
||||
top: 0;
|
||||
z-index: 2000;
|
||||
}
|
||||
|
||||
.sidebar-close-action {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
293
dashboard/src/components/SideBarItem.vue
Normal file
293
dashboard/src/components/SideBarItem.vue
Normal file
@@ -0,0 +1,293 @@
|
||||
<script setup>
|
||||
|
||||
import { ref, computed, useTemplateRef, watch, inject } from 'vue';
|
||||
import SideBarItem from './SideBarItem.vue';
|
||||
|
||||
const isMobile = inject('isMobile');
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
const props = defineProps({
|
||||
label: {
|
||||
type: String,
|
||||
},
|
||||
icon: {
|
||||
type: String,
|
||||
},
|
||||
route: {
|
||||
type: String,
|
||||
},
|
||||
active: {
|
||||
type: [ Boolean, Function ],
|
||||
default: false,
|
||||
},
|
||||
separator: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
collapsed: {
|
||||
type: Boolean,
|
||||
default: false,
|
||||
},
|
||||
visible: {
|
||||
type: [ Boolean, Function ],
|
||||
default: true,
|
||||
},
|
||||
childItems: {
|
||||
type: Array,
|
||||
default: () => [],
|
||||
}
|
||||
});
|
||||
|
||||
const isMenuExpanded = ref(false);
|
||||
const isMenuOpen = ref(false);
|
||||
|
||||
watch(() => props.collapsed, () => {
|
||||
isMenuExpanded.value = false;
|
||||
isMenuOpen.value = false;
|
||||
});
|
||||
|
||||
const isActive = computed(() => {
|
||||
const active = props.active;
|
||||
return typeof active === 'function' ? active() : active ?? true;
|
||||
});
|
||||
|
||||
const isVisible = computed(() => {
|
||||
const visible = props.visible;
|
||||
return typeof visible === 'function' ? visible() : visible ?? true;
|
||||
});
|
||||
|
||||
function close() {
|
||||
isMenuOpen.value = false;
|
||||
emit('close');
|
||||
}
|
||||
|
||||
const subMenuElement = useTemplateRef('subMenuElement');
|
||||
const elem = useTemplateRef('elem');
|
||||
|
||||
function getViewport() {
|
||||
const win = window,
|
||||
d = document,
|
||||
e = d.documentElement,
|
||||
g = d.getElementsByTagName('body')[0],
|
||||
w = win.innerWidth || e.clientWidth || g.clientWidth,
|
||||
h = win.innerHeight || e.clientHeight || g.clientHeight;
|
||||
|
||||
return {
|
||||
width: w,
|
||||
height: h
|
||||
};
|
||||
}
|
||||
|
||||
function getHiddenElementSize(element) {
|
||||
if (element) {
|
||||
const originalVisibility = element.style.visibility;
|
||||
const originalDisplay = element.style.display;
|
||||
|
||||
element.style.visibility = 'hidden';
|
||||
element.style.display = 'block';
|
||||
|
||||
element.clientHeight; // force reflow
|
||||
|
||||
const height = element.offsetHeight;
|
||||
const width = element.offsetWidth;
|
||||
|
||||
element.style.display = originalDisplay;
|
||||
element.style.visibility = originalVisibility;
|
||||
|
||||
return { height, width };
|
||||
}
|
||||
return { height: 0, width: 0 };
|
||||
}
|
||||
|
||||
const subMenuFlipped = ref(false);
|
||||
function toggleMenu() {
|
||||
if (props.collapsed && !isMobile.value) {
|
||||
const size = getHiddenElementSize(subMenuElement.value);
|
||||
const viewport = getViewport();
|
||||
|
||||
// rect of triggering element
|
||||
const top = elem.value.getBoundingClientRect().top;
|
||||
const bottom = elem.value.getBoundingClientRect().bottom;
|
||||
const right = elem.value.getBoundingClientRect().right;
|
||||
|
||||
// vertically flip or not
|
||||
subMenuFlipped.value = false;
|
||||
let menuTop = top;
|
||||
if (top + size.height - document.body.scrollTop > viewport.height) {
|
||||
if (top - document.body.scrollTop > viewport.height/2) {
|
||||
menuTop = bottom - size.height;
|
||||
subMenuFlipped.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
subMenuElement.value.style.left = right + 10 + 'px';
|
||||
subMenuElement.value.style.top = menuTop + 'px';
|
||||
|
||||
isMenuOpen.value = true;
|
||||
} else {
|
||||
isMenuExpanded.value = !isMenuExpanded.value;
|
||||
}
|
||||
}
|
||||
|
||||
function onBackdrop(event) {
|
||||
isMenuOpen.value = false;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="isVisible">
|
||||
<hr v-if="separator"/>
|
||||
<a v-else-if="!childItems?.length" class="sidebar-item" :class="{ active: isActive }" :href="route" @click="close()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }}</span></a>
|
||||
<div v-else-if="childItems.length" ref="elem" class="sidebar-item" :class="{ 'sidebar-item-menu-open': isMenuOpen ? '#e9ecef' : null }" @click="toggleMenu()" v-tooltip.right="collapsed && !isMobile ? label : null"><i :class="icon"></i> <span :class="{ 'sidebar-item-label-collapsed': collapsed }">{{ label }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: isMenuExpanded }" style="margin-left: 6px;"></i></span></div>
|
||||
|
||||
<teleport to="#app">
|
||||
<div class="pankow-menu-backdrop" @click="onBackdrop($event)" @contextmenu="onBackdrop($event)" v-show="isMenuOpen"></div>
|
||||
<div v-show="isMenuOpen" ref="subMenuElement" class="sidebar-item-menu">
|
||||
<div :class="{ 'sidebar-item-menu-anchor': !subMenuFlipped }">
|
||||
<span class="sidebar-item-header">{{ label }}</span>
|
||||
</div>
|
||||
<div v-for="(item, index) in childItems" :key="item" :class="{ 'sidebar-item-menu-anchor': subMenuFlipped && index === childItems.length-1 }">
|
||||
<hr v-if="item.separator"/>
|
||||
<a v-else class="sidebar-item" :href="item.route" @click="close()"><i :class="item.icon"></i> {{ item.label }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</teleport>
|
||||
|
||||
<Transition name="sidebar-item-group-animation">
|
||||
<div class="sidebar-item-group" v-if="isMenuExpanded">
|
||||
<SideBarItem v-for="item in childItems" :key="item"
|
||||
:label="item.label"
|
||||
:icon="item.icon"
|
||||
:route="item.route"
|
||||
:visible="item.visible"
|
||||
:active="item.active"
|
||||
:separator="item.separator"
|
||||
:child-items="item.childItems"
|
||||
@close="close()"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.sidebar-item-menu {
|
||||
position: absolute;
|
||||
z-index: 3002; /* backdrop is at 3001 -> see pankow */
|
||||
background-color: var(--navbar-background);
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
border-bottom-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item-menu-open {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-menu-anchor::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -20px;
|
||||
width: 20px;
|
||||
height: 37px;
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
.sidebar-item-header {
|
||||
background-color: #e9ecef;
|
||||
display: block;
|
||||
font-weight: bold;
|
||||
color: var(--pankow-text-color);
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
border-radius: 0;
|
||||
border-top-right-radius: var(--pankow-border-radius);
|
||||
}
|
||||
|
||||
.sidebar-item {
|
||||
display: block;
|
||||
color: var(--pankow-text-color);
|
||||
border-radius: 3px;
|
||||
padding: 10px 15px;
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.sidebar-item i {
|
||||
opacity: 0.7;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.sidebar-item.active {
|
||||
color: var(--pankow-color-primary);
|
||||
text-decoration: none;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.sidebar-item:hover {
|
||||
background-color: #e9ecef;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.sidebar-item-header,
|
||||
.sidebar-item:hover,
|
||||
.sidebar-item-menu-open,
|
||||
.sidebar-item-menu-anchor::before {
|
||||
background-color: #282d38;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item.active i ,
|
||||
.sidebar-item:hover i {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sidebar-item-label-collapsed {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.sidebar-item-label-collapsed {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar-item-group {
|
||||
padding-left: 20px;
|
||||
height: auto;
|
||||
overflow: hidden;
|
||||
/* we need height to auto so we animate max-height. needs to be bigger than we need */
|
||||
max-height: 300px;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-enter-active,
|
||||
.sidebar-item-group-animation-leave-active {
|
||||
transition: all 0.2s linear;
|
||||
}
|
||||
|
||||
.sidebar-item-group-animation-leave-to,
|
||||
.sidebar-item-group-animation-enter-from {
|
||||
transform: translateX(-100px);
|
||||
opacity: 0;
|
||||
max-height: 0;
|
||||
}
|
||||
|
||||
.slide-fade-enter-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-leave-active {
|
||||
transition: all 0.1s ease-out;
|
||||
}
|
||||
|
||||
.slide-fade-enter-from,
|
||||
.slide-fade-leave-to {
|
||||
transform: translateX(20px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
</style>
|
||||
@@ -5,34 +5,29 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Button, ClipboardAction, Menu, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { Button, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { TASK_TYPES } from '../constants.js';
|
||||
import ActionBar from '../components/ActionBar.vue';
|
||||
import Section from '../components/Section.vue';
|
||||
import BackupsModel from '../models/BackupsModel.js';
|
||||
import BackupSitesModel from '../models/BackupSitesModel.js';
|
||||
import AppsModel from '../models/AppsModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import { download } from '../utils.js';
|
||||
import BackupInfoDialog from './BackupInfoDialog.vue';
|
||||
|
||||
const backupsModel = BackupsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
const appsModel = AppsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
|
||||
const columns = {
|
||||
preserveSecs: {
|
||||
label: '',
|
||||
icon: 'fa-solid fa-archive',
|
||||
width: '40px',
|
||||
sort: true
|
||||
},
|
||||
packageVersion: {
|
||||
label: t('backups.listing.version'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
creationTime: {
|
||||
label: t('main.table.created'),
|
||||
sort(a, b) {
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
site: {
|
||||
label: t('backup.target.label'),
|
||||
@@ -47,19 +42,19 @@ const columns = {
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort: true,
|
||||
sort: false,
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('main.table.date'),
|
||||
sort: true
|
||||
packageVersion: {
|
||||
label: t('backups.listing.version'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
actions: {}
|
||||
};
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(backup, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-circle-info',
|
||||
label: t('backups.archives.info'),
|
||||
action: onInfo.bind(null, backup),
|
||||
@@ -72,8 +67,6 @@ function onActionMenu(backup, event) {
|
||||
label: t('backups.listing.tooltipDownloadBackupConfig'),
|
||||
action: onDownloadConfig.bind(null, backup),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const busy = ref(true);
|
||||
@@ -170,6 +163,13 @@ async function refreshBackups() {
|
||||
backups.value = result;
|
||||
}
|
||||
|
||||
async function refreshBackupSites() {
|
||||
const [error, result] = await backupSitesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
sites.value = result;
|
||||
}
|
||||
|
||||
async function onDownloadConfig(backup) {
|
||||
const [error, dashboardConfig] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
@@ -181,46 +181,9 @@ async function onDownloadConfig(backup) {
|
||||
download(filename, JSON.stringify(backupConfig, null, 4));
|
||||
}
|
||||
|
||||
// backups info dialog
|
||||
const infoDialog = useTemplateRef('infoDialog');
|
||||
const infoBackup = ref({ contents: [] });
|
||||
async function onInfo(backup) {
|
||||
infoBackup.value = backup;
|
||||
infoBackup.value.contents = [];
|
||||
infoDialog.value.open();
|
||||
|
||||
// amend detailed app info
|
||||
const appsById = {};
|
||||
|
||||
const [appsError, apps] = await appsModel.list();
|
||||
if (appsError) console.error('Failed to get apps list:', appsError);
|
||||
|
||||
(apps || []).forEach(function (app) {
|
||||
appsById[app.id] = app;
|
||||
});
|
||||
|
||||
for (const contentId of infoBackup.value.dependsOn) {
|
||||
const match = contentId.match(/(mail|app)_(.*?)_.*/); // *? means non-greedy
|
||||
if (!match) continue;
|
||||
const [error, backup] = await backupsModel.get(contentId);
|
||||
if (error) console.error(error);
|
||||
const content = { id: null, label: null, fqdn: null, stats: null };
|
||||
content.stats = backup.stats;
|
||||
if (match[1] === 'mail') {
|
||||
content.id = 'mail';
|
||||
content.label = 'Mail Server';
|
||||
} else {
|
||||
const app = appsById[match[2]];
|
||||
if (app) {
|
||||
content.id = app.id;
|
||||
content.label = app.label;
|
||||
content.fqdn = app.fqdn;
|
||||
} else { // uninstalled app
|
||||
content.id = match[2];
|
||||
}
|
||||
}
|
||||
infoBackup.value.contents.push(content);
|
||||
}
|
||||
infoDialog.value.open(backup);
|
||||
}
|
||||
|
||||
// edit backups dialog
|
||||
@@ -244,25 +207,23 @@ async function onEditSubmit() {
|
||||
|
||||
const [error] = await backupsModel.update(editBackupId.value, editBackupLabel.value, editBackupPersist.value ? -1 : 0);
|
||||
if (error) {
|
||||
return console.error(error);
|
||||
editBackupBusy.value = false;
|
||||
editBackupError.value = error.body?.message || JSON.stringify(error);
|
||||
return;
|
||||
}
|
||||
|
||||
await refreshBackups();
|
||||
editBackupBusy.value = false;
|
||||
editDialog.value.close();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await refreshBackupSites();
|
||||
await refreshBackups();
|
||||
await refreshTasks();
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const [error, result] = await backupSitesModel.list();
|
||||
if (error) return console.error(error);
|
||||
|
||||
sites.value = result;
|
||||
|
||||
await refreshBackupSites();
|
||||
await refreshBackups();
|
||||
|
||||
busy.value = false;
|
||||
@@ -276,57 +237,7 @@ defineExpose({ refresh });
|
||||
|
||||
<template>
|
||||
<Section :title="$t('backups.listing.title')">
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
|
||||
<Dialog ref="infoDialog"
|
||||
:title="$t('backups.backupDetails.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
|
||||
<div class="info-value">{{ infoBackup.id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.label') }}</div>
|
||||
<div class="info-value">{{ infoBackup.label }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
|
||||
<div class="info-value">
|
||||
<div>
|
||||
{{ infoBackup.remotePath }}
|
||||
<ClipboardAction plain :value="infoBackup.remotePath"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
|
||||
<div class="info-value">{{ prettyLongDate(infoBackup.creationTime) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ infoBackup.packageVersion }}</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="infoBackup.stats?.aggregatedUpload">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.size') }}</div>
|
||||
<div class="info-value">{{ prettyFileSize(infoBackup.stats.aggregatedUpload.size) }} | {{ infoBackup.stats.aggregatedUpload.fileCount }} file(s)</div>
|
||||
</div>
|
||||
<div class="info-row" v-if="infoBackup.stats?.aggregatedCopy">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.duration') }}</div>
|
||||
<div class="info-value">{{ prettyDuration(infoBackup.stats.aggregatedUpload.duration + infoBackup.stats.aggregatedCopy.duration) }}</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div 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"> {{ prettyFileSize(content.stats.upload.size) }} | {{ content.stats.upload.fileCount }} file(s)</span>
|
||||
</div>
|
||||
</Dialog>
|
||||
<BackupInfoDialog ref="infoDialog" />
|
||||
|
||||
<Dialog ref="editDialog"
|
||||
:title="$t('backups.backupEdit.title')"
|
||||
@@ -337,7 +248,7 @@ defineExpose({ refresh });
|
||||
:confirm-busy="editBackupBusy"
|
||||
@confirm="onEditSubmit()"
|
||||
>
|
||||
<p class="has-error text-center" v-show="editBackupError">{{ editBackupError }}</p>
|
||||
<div class="has-error text-center" v-show="editBackupError">{{ editBackupError }}</div>
|
||||
|
||||
<form @submit.prevent="onEditSubmit()" autocomplete="off">
|
||||
<fieldset>
|
||||
@@ -346,23 +257,29 @@ defineExpose({ refresh });
|
||||
<TextInput id="backupLabelInput" v-model="editBackupLabel" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="editBackupPersist" :label="$t('backups.backupEdit.preserved.description')" />
|
||||
<Checkbox v-model="editBackupPersist" :label="$t('backups.backupEdit.preserved.description')" help-url="https://docs.cloudron.io/backups#backup-labels"/>
|
||||
<!-- <sup><a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{ 'backups.backupEdit.preserved.tooltip' | tr: { appsLength: editBackup.backup.contents.length} }}"><i class="fa fa-question-circle"></i></a></sup> -->
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
<div v-html="t('backups.listing.description', { restoreLink: 'https://docs.cloudron.io/backups/#restore-cloudron', migrateLink: 'https://docs.cloudron.io/backups/#move-cloudron-to-another-server' })"></div>
|
||||
|
||||
<br/>
|
||||
|
||||
<template #header-buttons>
|
||||
<Button tool secondary :menu="taskLogsMenu" :disabled="!taskLogsMenu.length">{{ $t('main.action.logs') }}</Button>
|
||||
</template>
|
||||
|
||||
<TableView :columns="columns" :model="backups" :busy="busy" :placeholder="$t('backups.listing.noBackups')">
|
||||
<template #preserveSecs="backup">
|
||||
<i class="fas fa-archive" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i>
|
||||
<template #creationTime="backup">
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template #creationTime="backup">{{ prettyLongDate(backup.creationTime) }} <b v-show="backup.label">({{ backup.label }})</b></template>
|
||||
|
||||
<template #content="backup">
|
||||
<span v-if="backup.appCount">{{ $t('backups.listing.appCount', { appCount: backup.appCount }) }}</span>
|
||||
<span v-else>{{ $t('backups.listing.noApps') }}</span>
|
||||
@@ -375,9 +292,7 @@ defineExpose({ refresh });
|
||||
<template #site="backup">{{ backup.site.name }}</template>
|
||||
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click.capture="onActionMenu(backup, $event)" icon="fa-solid fa-ellipsis" />
|
||||
</div>
|
||||
<ActionBar :actions="createActionMenu(backup)"/>
|
||||
</template>
|
||||
</TableView>
|
||||
</Section>
|
||||
|
||||
@@ -32,6 +32,7 @@ async function onReboot() {
|
||||
confirmLabel: t('main.rebootDialog.rebootAction'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary'
|
||||
});
|
||||
|
||||
if (!confirmed) return;
|
||||
|
||||
@@ -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';
|
||||
@@ -15,32 +15,13 @@ import AppsModel from '../models/AppsModel.js';
|
||||
import UpdaterModel from '../models/UpdaterModel.js';
|
||||
import TasksModel from '../models/TasksModel.js';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
import { cronDays, cronHours } from '../utils.js';
|
||||
import { cronDays, cronHours, prettySchedule, parseSchedule } from '../utils.js';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const tasksModel = TasksModel.create();
|
||||
const updaterModel = UpdaterModel.create();
|
||||
const dashboardModel = DashboardModel.create();
|
||||
|
||||
function prettyAutoUpdateSchedule(pattern) {
|
||||
if (!pattern) return '';
|
||||
const tmp = pattern.split(' ');
|
||||
|
||||
if (tmp.length === 1) return tmp[0];
|
||||
|
||||
const hours = tmp[2].split(',');
|
||||
const days = tmp[5].split(',');
|
||||
const prettyDay = (days.length === 7 || days[0] === '*') ? 'Every day' : days.map((day) => { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(', ');
|
||||
|
||||
try {
|
||||
const prettyHour = hours.map((hour) => { return cronHours[parseInt(hour, 10)]; }).sort((a,b) => a.value - b.value).map(h => h.name).join(', ');
|
||||
return prettyDay + ' at ' + prettyHour;
|
||||
} catch (error) {
|
||||
console.error('Unable to build pattern.', error);
|
||||
return 'Custom pattern';
|
||||
}
|
||||
}
|
||||
|
||||
const inputDialog = useTemplateRef('inputDialog');
|
||||
const updateDialog = useTemplateRef('updateDialog');
|
||||
|
||||
@@ -106,19 +87,13 @@ async function refreshPendingUpdateInfo() {
|
||||
}
|
||||
|
||||
function onShowConfigure() {
|
||||
configureType.value = configurePattern.value === 'never' ? 'never' : 'pattern';
|
||||
|
||||
const tmp = currentPattern.value.split(' ');
|
||||
const hours = tmp[2] ? tmp[2].split(',') : [];
|
||||
const days = tmp[5] ? tmp[5].split(',') : [];
|
||||
|
||||
if (days[0] === '*') configureDays.value = cronDays.map(day => { return day.id; });
|
||||
else configureDays.value = days.map(day => { return parseInt(day, 10); });
|
||||
|
||||
try {
|
||||
configureHours.value = hours.map(hour => { return parseInt(hour, 10); });
|
||||
} catch (error) {
|
||||
console.error('Error parsing hour', error);
|
||||
if (currentPattern.value === 'never') {
|
||||
configureType.value = 'never';
|
||||
} else {
|
||||
configureType.value = 'pattern';
|
||||
const result = parseSchedule(currentPattern.value);
|
||||
configureDays.value = result.days; // Array of cronDays.id
|
||||
configureHours.value = result.hours; // Array of cronHours.id
|
||||
}
|
||||
|
||||
configureDialog.value.open();
|
||||
@@ -185,6 +160,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 +252,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 +295,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')">
|
||||
@@ -345,7 +322,7 @@ onMounted(async () => {
|
||||
<SettingsItem v-if="ready">
|
||||
<div>
|
||||
<label>{{ $t('settings.updates.schedule') }}</label>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettyAutoUpdateSchedule(currentPattern) || '-' }}</span>
|
||||
<span v-if="currentPattern !== 'never'">{{ prettySchedule(currentPattern) }}</span>
|
||||
<span v-else>{{ $t('settings.updates.disabled') }}</span>
|
||||
</div>
|
||||
<div style="display: flex; align-items: center">
|
||||
@@ -365,11 +342,12 @@ 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>
|
||||
<Button danger v-if="updateBusy" @click="onStop()">{{ $t('settings.updates.stopUpdateAction') }}</Button>
|
||||
<Button :danger="(pendingUpdate && pendingUpdate.unstable) ? true : undefined" :success="(pendingUpdate && !pendingUpdate.unstable) ? true : undefined" v-show="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
|
||||
<Button :danger="pendingUpdate?.unstable" :success="!pendingUpdate?.unstable" v-if="pendingUpdate && pendingUpdate.version !== version && !updateBusy" @click="onShowUpdate()">{{ $t('settings.updates.updateAvailableAction') }}</Button>
|
||||
</div>
|
||||
</Section>
|
||||
</div>
|
||||
|
||||
@@ -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;
|
||||
@@ -137,9 +139,9 @@ function onSchedulerMenu(event) {
|
||||
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('main.action.restart'),
|
||||
confirmStyle: 'danger',
|
||||
rejectLabel: t('main.dialog.cancel'),
|
||||
rejectStyle: 'secondary',
|
||||
});
|
||||
|
||||
|
||||
@@ -5,7 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, useTemplateRef, inject } from 'vue';
|
||||
import { Dialog, TextInput, FormGroup, Checkbox, MultiSelect, SingleSelect } from '@cloudron/pankow';
|
||||
import { Dialog, TextInput, EmailInput, FormGroup, Checkbox, MultiSelect, SingleSelect } from '@cloudron/pankow';
|
||||
import { ROLES } from '../constants.js';
|
||||
import ImagePicker from '../components/ImagePicker.vue';
|
||||
import DashboardModel from '../models/DashboardModel.js';
|
||||
@@ -32,7 +32,6 @@ const roles = ref([]);
|
||||
const profile = ref({});
|
||||
const busy = ref(false);
|
||||
const profileLocked = ref(false);
|
||||
const external2FA = ref(false);
|
||||
const formError = ref({});
|
||||
const displayName = ref('');
|
||||
const email = ref('');
|
||||
@@ -40,32 +39,23 @@ const fallbackEmail = ref('');
|
||||
const avatarUrl = ref('');
|
||||
const username = ref('');
|
||||
const role = ref('');
|
||||
const groups = ref([]);
|
||||
const localGroups = ref([]);
|
||||
const localGroupIds = ref([]);
|
||||
const allGroups = ref([]);
|
||||
const allLocalGroups = ref([]);
|
||||
const active = ref(true);
|
||||
const sendInvite = ref(false);
|
||||
const isSelf = ref(false);
|
||||
const reset2FABusy = ref(false);
|
||||
|
||||
async function onReset2FA() {
|
||||
if (!user.value) return;
|
||||
|
||||
reset2FABusy.value = true;
|
||||
|
||||
const [error] = await usersModel.disableTwoFactorAuthentication(user.value.id);
|
||||
if (error) return console.error(error);
|
||||
|
||||
user.value.twoFactorAuthenticationEnabled = false;
|
||||
reset2FABusy.value = false;
|
||||
}
|
||||
|
||||
let avatarFile = 'src';
|
||||
function onAvatarChanged(file) {
|
||||
avatarFile = file;
|
||||
}
|
||||
|
||||
const isFormValid = ref(false);
|
||||
function validateForm() {
|
||||
isFormValid.value = form.value ? form.value.checkValidity() : false;
|
||||
}
|
||||
|
||||
async function onSubmit() {
|
||||
if (!form.value.reportValidity()) return;
|
||||
|
||||
@@ -141,7 +131,7 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
const [groupError] = await usersModel.setLocalGroups(userId, localGroups.value);
|
||||
const [groupError] = await usersModel.setLocalGroups(userId, localGroupIds.value);
|
||||
if (groupError) {
|
||||
formError.value.generic = groupError.body ? groupError.body.message : 'Internal error';
|
||||
busy.value = false;
|
||||
@@ -198,8 +188,7 @@ defineExpose({
|
||||
result.forEach(g => g.label = g.name);
|
||||
allGroups.value = result;
|
||||
allLocalGroups.value = result.filter(g => !g.source);
|
||||
groups.value = u ? u.groupIds : [];
|
||||
localGroups.value = (u ? u.groupIds.filter(g => !g.source) : []);
|
||||
localGroupIds.value = u ? u.groupIds.filter(gid => allLocalGroups.value.find(g => g.id === gid)) : [];
|
||||
|
||||
[error, result] = await profileModel.get();
|
||||
if (error) return console.error(error);
|
||||
@@ -217,10 +206,11 @@ defineExpose({
|
||||
[error, result] = await dashboardModel.config();
|
||||
if (error) return console.error(error);
|
||||
profileLocked.value = result.profileLocked;
|
||||
external2FA.value = result.external2FA;
|
||||
|
||||
imagePicker.value.reset();
|
||||
dialog.value.open();
|
||||
|
||||
setTimeout(validateForm, 100); // update state of the confirm button
|
||||
}
|
||||
});
|
||||
|
||||
@@ -228,24 +218,16 @@ 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"
|
||||
:confirm-active="!busy && isFormValid"
|
||||
reject-style="secondary"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
:reject-active="!busy"
|
||||
alternate-style="secondary"
|
||||
:alternate-label="(user && user.twoFactorAuthenticationEnabled && !(user.source && external2FA)) ? $t('users.passwordResetDialog.reset2FAAction') : null"
|
||||
:alternate-busy="reset2FABusy"
|
||||
@alternate="onReset2FA()"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p class="text-warning" v-if="user && user.source">{{ $t('users.editUserDialog.externalLdapWarning') }}</p>
|
||||
|
||||
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
|
||||
<fieldset :disabled="busy">
|
||||
<input type="submit" style="display: none;" />
|
||||
|
||||
@@ -255,47 +237,51 @@ 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>
|
||||
<div class="text-warning" v-if="user && user.source">{{ $t('users.editUserDialog.externalLdapWarning') }}</div>
|
||||
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
|
||||
|
||||
<!-- if profile edit is locked a username has to be set here . username is editable if none is set -->
|
||||
<FormGroup :has-error="formError.username">
|
||||
<label for="usernameInput">{{ $t('users.user.username') }}</label>
|
||||
<TextInput id="usernameInput" v-model="username" :required="!user?.username && profileLocked" :readonly="user?.username ? true : false" />
|
||||
<small v-if="!user?.username && !profileLocked" class="helper-text">{{ $t('users.user.usernamePlaceholder') }}</small>
|
||||
<div class="error-label" 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>
|
||||
<EmailInput id="emailInput" v-model="email" :readonly="user?.source ? true : false" :required="user?.source ? false : true" />
|
||||
<div class="error-label" 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?.source ? true : false"/>
|
||||
<small v-if="!user || !user.username" class="helper-text">{{ $t('users.user.displayNamePlaceholder') }}</small> <!-- don't show if user has already signed up -->
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="fallbackEmailInput">{{ $t('users.user.recoveryEmail') }} <sup><a href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<TextInput id="fallbackEmailInput" v-model="fallbackEmail" />
|
||||
<EmailInput id="fallbackEmailInput" v-model="fallbackEmail" />
|
||||
<small class="helper-text">{{ $t('users.user.fallbackEmailPlaceholder') }}</small>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup v-if="profile.isAtLeastAdmin" :has-error="formError.role">
|
||||
<label for="roleInput">{{ $t('users.user.role') }} <sup><a href="https://docs.cloudron.io/user-management/#roles" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<SingleSelect id="roleInput" v-model="role" :options="roles" option-key="id" option-label="name" :disabled="isSelf"/>
|
||||
<div class="text-danger" v-if="formError.role">{{ formError.role }}</div>
|
||||
<div class="error-label" v-if="formError.role">{{ formError.role }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<!-- local groups. they can have local and external users -->
|
||||
<FormGroup>
|
||||
<label for="groupsInput">{{ $t('users.user.groups') }}</label>
|
||||
<div v-if="allGroups.length === 0">{{ $t('users.user.noGroups') }}</div>
|
||||
<MultiSelect v-if="allLocalGroups.length" v-model="localGroups" option-key="id" :options="allLocalGroups" :search-threshold="20" />
|
||||
<MultiSelect v-if="allLocalGroups.length" v-model="localGroupIds" 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"/>
|
||||
<Checkbox v-if="!user" v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
|
||||
<!-- 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-else v-model="sendInvite" :label="$t('users.addUserDialog.sendInviteCheckbox')" />
|
||||
</fieldset>
|
||||
</form>
|
||||
</Dialog>
|
||||
|
||||
@@ -92,7 +92,7 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<Dialog ref="dialog"
|
||||
:title="$t('domains.domainWellKnown.title', { domain })"
|
||||
:title="$t('domains.wellknown.title')"
|
||||
:confirm-busy="busy"
|
||||
:confirm-label="$t('main.dialog.save')"
|
||||
:reject-label="$t('main.dialog.cancel')"
|
||||
@@ -100,7 +100,9 @@ defineExpose({
|
||||
reject-style="secondary"
|
||||
@confirm="onSubmit()"
|
||||
>
|
||||
<p v-html="$t('domains.domainDialog.wellKnownDescription', { domain, docsLink: 'https://docs.cloudron.io/domains/#well-known-locations' })"></p>
|
||||
<div v-html="$t('domains.wellknown.context', { domain })"></div>
|
||||
<br/>
|
||||
<div v-html="$t('domains.wellknown.description', { domain, docsLink: 'https://docs.cloudron.io/domains/#well-known-locations' })"></div>
|
||||
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy">
|
||||
|
||||
@@ -92,8 +92,8 @@ onMounted(async () => {
|
||||
<template>
|
||||
<div v-if="!loading">
|
||||
<div class="text-danger" v-if="errorMessage">{{ errorMessage }}</div>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :users="users" :groups="groups" :manifest="app.manifest" :hide-optional-sso-option="!app.sso"/>
|
||||
<br/>
|
||||
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :users="users" :groups="groups" :manifest="app.manifest" :sso="app.sso" :installation="false"/>
|
||||
<div style="padding-top: 10px"></div>
|
||||
<OperatorAccessControl v-model:acl="operatorAcl" :users="users" :groups="groups" :has-ftp="app.manifest.addons?.localstorage?.ftp"/>
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
@@ -5,7 +5,7 @@ const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted, useTemplateRef } from 'vue';
|
||||
import { Icon, Button, Switch, Checkbox, FormGroup, TextInput, TableView, Menu, Dialog, ProgressBar } from '@cloudron/pankow';
|
||||
import { Button, Switch, Checkbox, FormGroup, TextInput, TableView, Dialog, ProgressBar } from '@cloudron/pankow';
|
||||
import { prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
|
||||
import { API_ORIGIN, RSTATES } from '../../constants.js';
|
||||
import { download } from '../../utils.js';
|
||||
@@ -16,6 +16,8 @@ import AppsModel from '../../models/AppsModel.js';
|
||||
import BackupSitesModel from '../../models/BackupSitesModel.js';
|
||||
import TasksModel from '../../models/TasksModel.js';
|
||||
import { TASK_TYPES } from '../../constants.js';
|
||||
import BackupInfoDialog from '../BackupInfoDialog.vue';
|
||||
import ActionBar from '../../components/ActionBar.vue';
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
const backupSitesModel = BackupSitesModel.create();
|
||||
@@ -24,26 +26,25 @@ const tasksModel = TasksModel.create();
|
||||
const props = defineProps([ 'app' ]);
|
||||
|
||||
const columns = ref({
|
||||
preserveSecs: {
|
||||
label: '',
|
||||
icon: 'fa-solid fa-archive',
|
||||
width: '40px',
|
||||
sort: true
|
||||
},
|
||||
packageVersion: {
|
||||
label: t('main.table.version'),
|
||||
sort: true,
|
||||
creationTime: {
|
||||
label: t('main.table.created'),
|
||||
sort(a, b) {
|
||||
return new Date(a) - new Date(b);
|
||||
}
|
||||
},
|
||||
site: {
|
||||
label: t('backup.target.label'),
|
||||
sort: true,
|
||||
sort(a, b) {
|
||||
return b.name <= a.name ? 1 : -1;
|
||||
},
|
||||
},
|
||||
size: {
|
||||
label: t('backup.target.size'),
|
||||
sort: true,
|
||||
hideMobile: true,
|
||||
},
|
||||
creationTime: {
|
||||
label: t('app.backups.backups.time'),
|
||||
packageVersion: {
|
||||
label: t('main.table.version'),
|
||||
sort: true,
|
||||
},
|
||||
actions: {
|
||||
@@ -52,10 +53,8 @@ const columns = ref({
|
||||
}
|
||||
});
|
||||
|
||||
const actionMenuModel = ref([]);
|
||||
const actionMenuElement = useTemplateRef('actionMenuElement');
|
||||
function onActionMenu(backup, event) {
|
||||
actionMenuModel.value = [{
|
||||
function createActionMenu(backup) {
|
||||
return [{
|
||||
icon: 'fa-solid fa-info',
|
||||
label: t('backups.archives.info'),
|
||||
action: onInfo.bind(null, backup),
|
||||
@@ -70,7 +69,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'),
|
||||
@@ -89,6 +88,7 @@ function onActionMenu(backup, event) {
|
||||
label: t('app.backups.backups.restoreTooltip'),
|
||||
disabled: !!props.app.taskId || props.app.runState === 'stopped',
|
||||
action: onRestore.bind(null, backup),
|
||||
quickAction: true
|
||||
// }, {
|
||||
// separator: true,
|
||||
// }, {
|
||||
@@ -97,13 +97,10 @@ function onActionMenu(backup, event) {
|
||||
// visible: props.app.accessLevel === 'admin',
|
||||
// action: onCheckIntegrity.bind(null, backup),
|
||||
}];
|
||||
|
||||
actionMenuElement.value.open(event, event.currentTarget);
|
||||
}
|
||||
|
||||
const busy = ref(true);
|
||||
const errorMessage = ref('');
|
||||
const infoBackup = ref({});
|
||||
const editBusy = ref(false);
|
||||
const editError = ref('');
|
||||
const editBackup = ref({});
|
||||
@@ -189,8 +186,7 @@ async function onStopBackup() {
|
||||
}
|
||||
|
||||
function onInfo(backup) {
|
||||
infoBackup.value = backup;
|
||||
infoDialog.value.open();
|
||||
infoDialog.value.open(backup);
|
||||
}
|
||||
|
||||
function onEdit(backup) {
|
||||
@@ -298,33 +294,10 @@ onMounted(async () => {
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<Menu ref="actionMenuElement" :model="actionMenuModel" />
|
||||
<AppRestoreDialog ref="cloneDialog"/>
|
||||
<AppImportDialog ref="importDialog"/>
|
||||
|
||||
<Dialog ref="infoDialog"
|
||||
:title="$t('backups.backupDetails.title')"
|
||||
:reject-label="$t('main.dialog.close')"
|
||||
>
|
||||
<div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
|
||||
<div class="info-value">{{ infoBackup.id }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
|
||||
<div class="info-value">{{ infoBackup.remotePath }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
|
||||
<div class="info-value">{{ prettyLongDate(infoBackup.creationTime) }}</div>
|
||||
</div>
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
|
||||
<div class="info-value">{{ infoBackup.packageVersion }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
<BackupInfoDialog ref="infoDialog" />
|
||||
|
||||
<Dialog ref="editDialog"
|
||||
:title="$t('backups.backupEdit.title')"
|
||||
@@ -338,22 +311,22 @@ onMounted(async () => {
|
||||
<div>
|
||||
<form @submit.prevent="onEditSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="editBusy">
|
||||
<p class="has-error" v-show="editError">{{ editError }}</p>
|
||||
<div class="has-error" v-show="editError">{{ editError }}</div>
|
||||
|
||||
<FormGroup>
|
||||
<label for="labelInput">{{ $t('backups.backupEdit.label') }}</label>
|
||||
<TextInput v-model="editLabel" id="labelInput" />
|
||||
</FormGroup>
|
||||
|
||||
<Checkbox v-model="editPersist" :label="$t('backups.backupEdit.preserved.description')"/>
|
||||
<Checkbox v-model="editPersist" :label="$t('backups.backupEdit.preserved.description')" help-url="https://docs.cloudron.io/backups#backup-labels"/>
|
||||
</fieldset>
|
||||
</form>
|
||||
</div>
|
||||
</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 +334,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>
|
||||
@@ -415,11 +387,12 @@ onMounted(async () => {
|
||||
<br/>
|
||||
|
||||
<TableView :model="backups" :columns="columns" :busy="busy" :placeholder="$t('backups.listing.noBackups')" style="max-height: 400px;" >
|
||||
<template #preserveSecs="backup">
|
||||
<Icon icon="fa-solid fa-archive" v-show="backup.preserveSecs === -1" />
|
||||
</template>
|
||||
<template #creationTime="backup">
|
||||
{{ prettyLongDate(backup.creationTime) }} <b v-show="backup.label">({{ backup.label }})</b>
|
||||
<div>
|
||||
<span>{{ prettyLongDate(backup.creationTime) }}</span>
|
||||
<span v-if="backup.label"> <b>{{ backup.label }}</b></span>
|
||||
<span> <i class="fa-solid fa-thumbtack text-muted" v-show="backup.preserveSecs === -1" v-tooltip="$t('backups.listing.tooltipPreservedBackup')"></i></span>
|
||||
</div>
|
||||
</template>
|
||||
<template #site="backup">
|
||||
{{ backup.site.name }}
|
||||
@@ -429,7 +402,7 @@ onMounted(async () => {
|
||||
</template>
|
||||
<template #actions="backup">
|
||||
<div style="text-align: right;">
|
||||
<Button tool plain secondary @click="onActionMenu(backup, $event)" icon="fa-solid fa-ellipsis" />
|
||||
<ActionBar style="width: 100px" :actions="createActionMenu(backup)"/>
|
||||
</div>
|
||||
</template>
|
||||
</TableView>
|
||||
|
||||
@@ -41,15 +41,17 @@ const crontabDefault = `# +------------------------ minute (0 - 59)
|
||||
|
||||
const busy = ref(false);
|
||||
const crontab = ref('');
|
||||
const submitError = ref({});
|
||||
|
||||
async function onSubmit() {
|
||||
if (crontab.value === crontabDefault && !props.app.crontab) return;
|
||||
if (crontab.value === props.app.crontab) return;
|
||||
|
||||
submitError.value = {};
|
||||
busy.value = true;
|
||||
|
||||
const [error] = await appsModel.configure(props.app.id, 'crontab', { crontab: crontab.value });
|
||||
if (error) return console.error(error);
|
||||
if (error) submitError.value.generic = error.body?.message || JSON.stringify(error);
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -73,6 +75,7 @@ onMounted(() => {
|
||||
</label>
|
||||
<div description>{{ $t('app.cron.description') }}</div>
|
||||
<textarea id="crontabInput" style="width: 100%; white-space: pre-wrap; font-family: monospace;" v-model="crontab" rows="10"></textarea>
|
||||
<div class="error-label" v-show="submitError.generic">{{ submitError.generic }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -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">
|
||||
@@ -99,13 +99,14 @@ onMounted(() => {
|
||||
<FormGroup>
|
||||
<label for="labelInput">{{ $t('app.display.label') }}</label>
|
||||
<TextInput id="labelInput" v-model="label"/>
|
||||
<div class="text-error" v-if="labelError">{{ labelError }}</div>
|
||||
<div class="error-label" v-if="labelError">{{ labelError }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<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')"/>
|
||||
<div class="text-error" v-if="tagsError">{{ tagsError }}</div>
|
||||
<TagInput id="tagsInput" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')"/>
|
||||
<small class="helper-text">{{ $t('app.display.tagsPlaceholder') }}</small>
|
||||
<div class="error-label" v-if="tagsError">{{ tagsError }}</div>
|
||||
</FormGroup>
|
||||
</fieldset>
|
||||
</form>
|
||||
|
||||
@@ -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 style="margin-bottom: 12px;" :style="{ 'padding-left': sendmailOptional ? '25px' : '0' }">
|
||||
<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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -95,8 +95,11 @@ onMounted(() => {
|
||||
|
||||
<div class="info-row">
|
||||
<div class="info-label">{{ $t('app.updates.info.packageVersion') }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId"><a :href="`/#/appstore/${app.manifest.id}?version=${app.manifest.version}`">{{ app.manifest.id }}@{{ app.manifest.version }}</a></div>
|
||||
<div class="info-value" v-else>{{ app.manifest.version }}</div>
|
||||
<div class="info-value" v-if="app.appStoreId">
|
||||
<a :href="`/#/appstore/${app.manifest.id}?version=${app.manifest.version}`">{{ app.manifest.id }}@{{ app.manifest.version }}</a>
|
||||
<ClipboardAction plain :value="app.manifest.id + '@' + app.manifest.version"/>
|
||||
</div>
|
||||
<div class="info-value" v-else>{{ app.manifest.version }} <ClipboardAction plain :value="app.manifest.version"/></div>
|
||||
</div>
|
||||
|
||||
<div class="info-row">
|
||||
|
||||
@@ -39,7 +39,7 @@ function isNoopOrManual(domain) {
|
||||
|
||||
function onAddAlias() {
|
||||
aliases.value.push({
|
||||
domain: dashboardDomain.value,
|
||||
domain: domain.value,
|
||||
subdomain: ''
|
||||
});
|
||||
}
|
||||
@@ -50,7 +50,7 @@ function onRemoveAlias(index) {
|
||||
|
||||
function onAddRedirect() {
|
||||
redirects.value.push({
|
||||
domain: dashboardDomain.value,
|
||||
domain: domain.value,
|
||||
subdomain: ''
|
||||
});
|
||||
}
|
||||
@@ -64,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;
|
||||
|
||||
@@ -218,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">
|
||||
@@ -233,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') }}. </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">
|
||||
@@ -251,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') }}. </span>
|
||||
<span class="actionable" v-if="!busy" @click="onAddRedirect()">{{ $t('app.location.addRedirectionAction') }}</span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
</fieldset>
|
||||
</form>
|
||||
@@ -262,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>
|
||||
|
||||
@@ -65,7 +65,7 @@ onMounted(() => {
|
||||
<label>{{ $t('app.repair.restart.title') }}</label>
|
||||
<div>{{ $t('app.repair.restart.description') }}</div>
|
||||
<br/>
|
||||
<Button @click="onRestart()" :disabled="busyRestart || app.taskId || !!app.error" :loading="busyRestart || app.installationState === 'pending_restart'">{{ $t('app.repair.recovery.restartAction') }}</Button>
|
||||
<Button @click="onRestart()" :disabled="!!(busyRestart || app.taskId || !!app.error)" :loading="busyRestart || app.installationState === 'pending_restart'">{{ $t('app.repair.recovery.restartAction') }}</Button>
|
||||
</div>
|
||||
<hr style="margin-top: 20px"/>
|
||||
|
||||
@@ -73,7 +73,7 @@ onMounted(() => {
|
||||
<label>{{ $t('app.repair.recovery.title') }}</label>
|
||||
<div v-html="$t('app.repair.recovery.description', { docsLink: 'https://docs.cloudron.io/apps/#recovery-mode' })"></div>
|
||||
<br/>
|
||||
<Button @click="onToggleDebugMode" :disabled="debugModeBusy || (app.error && app.error.installationState !== ISTATES.PENDING_DEBUG) || app.taskId">
|
||||
<Button @click="onToggleDebugMode" :disabled="!!(debugModeBusy || (app.error && app.error.installationState !== ISTATES.PENDING_DEBUG) || app.taskId)">
|
||||
<span v-if="app.debugMode">{{ $t('app.repair.recovery.disableAction') }}</span>
|
||||
<span v-else>{{ $t('app.repair.recovery.enableAction') }}</span>
|
||||
</Button>
|
||||
@@ -86,7 +86,7 @@ onMounted(() => {
|
||||
<div>{{ $t('app.repair.taskError.description') }}</div>
|
||||
<div v-if="app.error" style="margin-top: 10px;">An error occurred during the <b>{{ taskNameFromInstallationState(app.error.installationState) }}</b> operation: <span class="text-danger"><b>{{ app.error.reason + ': ' + app.error.message }}</b></span></div>
|
||||
<br/>
|
||||
<Button @click="onRepair()" :disabled="busyRepair || app.taskId || !app.error" :loading="busyRepair">{{ $t('app.repair.taskError.retryAction', { task: app.error ? taskNameFromInstallationState(app.error.installationState) : '' }) }}</Button>
|
||||
<Button @click="onRepair()" :disabled="!!(busyRepair || app.taskId || !app.error)" :loading="busyRepair">{{ $t('app.repair.taskError.retryAction', { task: app.error ? taskNameFromInstallationState(app.error.installationState) : '' }) }}</Button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
<script setup>
|
||||
|
||||
import { useI18n } from 'vue-i18n';
|
||||
const i18n = useI18n();
|
||||
const t = i18n.t;
|
||||
|
||||
import { ref, onMounted } from 'vue';
|
||||
import { Button, FormGroup, Checkbox } from '@cloudron/pankow';
|
||||
import AppsModel from '../../models/AppsModel.js';
|
||||
@@ -8,17 +12,41 @@ const props = defineProps([ 'app' ]);
|
||||
|
||||
const appsModel = AppsModel.create();
|
||||
|
||||
function onAddDisableIndexing() {
|
||||
robotsTxt.value = '# Disable search engine indexing\n\nUser-agent: *\nDisallow: /';
|
||||
}
|
||||
|
||||
const busy = ref(false);
|
||||
const robotsTxt = ref('');
|
||||
const csp = ref('');
|
||||
const hstsPreload = ref(false);
|
||||
const submitError = ref({ robotsTxt: '', csp: '' });
|
||||
|
||||
function addRobotsTxtPreset(pattern) {
|
||||
if (robotsTxt.value) robotsTxt.value += '\n';
|
||||
robotsTxt.value += pattern;
|
||||
}
|
||||
|
||||
const commonRobotsTxtMenu = [
|
||||
{ label: t('app.security.robots.commonPattern.allowAll'), action: () => addRobotsTxtPreset('# Allow all\nUser-agent: *\nDisallow:') },
|
||||
{ label: t('app.security.robots.commonPattern.disallowAll'), action: () => addRobotsTxtPreset('# Disable search engine indexing\n\nUser-agent: *\nDisallow: /') },
|
||||
{ label: t('app.security.robots.commonPattern.disallowCommonBots'), action: () => addRobotsTxtPreset('# Disallow common bots\nUser-agent: Googlebot\nDisallow: /\n\nUser-agent: Bingbot\nDisallow: /\n\nUser-agent: Slurp\nDisallow: /\n\nUser-agent: DuckDuckBot\nDisallow: /\n\nUser-agent: Baiduspider\nDisallow: /\n\nUser-agent: YandexBot\nDisallow: /\n\nUser-agent: facebot\nDisallow: /\n\nUser-agent: ia_archiver\nDisallow: /') },
|
||||
{ label: t('app.security.robots.commonPattern.disallowAdminPaths'), action: () => addRobotsTxtPreset('# Disallow admin paths\nUser-agent: *\nDisallow: /admin/\nDisallow: /internal/\nDisallow: /private/') },
|
||||
{ label: t('app.security.robots.commonPattern.disallowApiPaths'), action: () => addRobotsTxtPreset('# Disallow API paths\nUser-agent: *\nDisallow: /api/\nDisallow: /v1/\nDisallow: /v2/') },
|
||||
];
|
||||
|
||||
function addCspPreset(pattern) {
|
||||
if (csp.value) csp.value += '\n';
|
||||
csp.value += pattern;
|
||||
}
|
||||
|
||||
const commonCspMenu = [
|
||||
{ label: t('app.security.csp.commonPattern.allowEmbedding'), action: () => addCspPreset("# Allow embedding from all sites\ndefault-src 'self';\nframe-ancestors 'none';") },
|
||||
{ label: t('app.security.csp.commonPattern.sameOriginEmbedding'), action: () => addCspPreset("# Allow embedding from subdomains\ndefault-src 'self';\nframe-ancestors 'self';") },
|
||||
{ label: t('app.security.csp.commonPattern.allowCdnAssets'), action: () => addCspPreset("# Allow CDN assets\ndefault-src 'self';\nscript-src 'self' https://cdn.example.com;\nstyle-src 'self' https://cdn.example.com;\nimg-src 'self' data: https://cdn.example.com;\nfont-src 'self' https://cdn.example.com;\nobject-src 'none';\nframe-ancestors 'none';") },
|
||||
{ label: t('app.security.csp.commonPattern.reportOnly'), action: () => addCspPreset("# Report violations. A POST request will be sent to URL below\ndefault-src 'self';\nreport-uri /csp-report;") },
|
||||
{ label: t('app.security.csp.commonPattern.strictBaseline'), action: () => addCspPreset("# Secure CSP that restricts all resources to the same origin\ndefault-src 'self';\nbase-uri 'self';\nobject-src 'none';\nframe-ancestors 'none';\nform-action 'self';\nscript-src 'self';\nstyle-src 'self';\nimg-src 'self' data:;\nfont-src 'self';\nconnect-src 'self';\nmedia-src 'self';\nframe-src 'self';\nworker-src 'self';\nmanifest-src 'self';") },
|
||||
];
|
||||
|
||||
async function onSubmit() {
|
||||
busy.value = true;
|
||||
submitError.value = {};
|
||||
|
||||
const data = {
|
||||
robotsTxt: robotsTxt.value || null, // empty string resets
|
||||
@@ -27,7 +55,11 @@ async function onSubmit() {
|
||||
};
|
||||
|
||||
const [error] = await appsModel.configure(props.app.id, 'reverse_proxy', data);
|
||||
if (error) return console.error(error);
|
||||
if (error) {
|
||||
if (error.body?.message.includes('CSP')) submitError.value.csp = error.body.message;
|
||||
else if (error.body?.includes('robots')) submitError.value.robotsTxt = error.body.message;
|
||||
else submitError.value.csp = JSON.stringify(error);
|
||||
}
|
||||
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -43,26 +75,32 @@ onMounted(() => {
|
||||
<template>
|
||||
<div>
|
||||
<form @submit.prevent="onSubmit()" autocomplete="off">
|
||||
<fieldset :disabled="busy || app.error">
|
||||
<fieldset :disabled="busy">
|
||||
<input style="display: none;" type="submit" />
|
||||
|
||||
<FormGroup>
|
||||
<label for="robotsTxtInput" style="display: flex; justify-content: space-between;">
|
||||
<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>
|
||||
<Button small outline :menu="commonRobotsTxtMenu">{{ $t('app.security.robots.insertCommonRobotsTxt') }}</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" spellcheck="false" style="white-space: pre-wrap; font-family: monospace;" v-model="robotsTxt" rows="10"></textarea>
|
||||
<div class="error-label" v-show="submitError.robotsTxt">{{ submitError.robotsTxt }}</div>
|
||||
</FormGroup>
|
||||
|
||||
<FormGroup>
|
||||
<label for="cspInput">{{ $t('app.security.csp.title') }} <sup><a href="https://docs.cloudron.io/apps/#custom-csp" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> </label>
|
||||
<label for="cspInput" style="display: flex; justify-content: space-between;">
|
||||
<span>{{ $t('app.security.csp.title') }} <sup><a href="https://docs.cloudron.io/apps/#custom-csp" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></span>
|
||||
<Button small outline :menu="commonCspMenu">{{ $t('app.security.csp.insertCommonCsp') }}</Button>
|
||||
</label>
|
||||
<div description>{{ $t('app.security.csp.description') }}</div>
|
||||
<textarea id="cspInput" style="white-space: pre-wrap; font-family: monospace;" v-model="csp" placeholder="default-src 'self'; frame-ancestors 'none';" rows="2"></textarea>
|
||||
<textarea id="cspInput" spellcheck="false" style="white-space: pre-wrap; font-family: monospace;" v-model="csp" rows="5"></textarea>
|
||||
<div class="error-label" v-show="submitError.csp">{{ submitError.csp }}</div>
|
||||
</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>
|
||||
|
||||
@@ -191,9 +191,8 @@ 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>
|
||||
<div class="error-label" v-if="mountsError">{{ mountsError }}</div>
|
||||
|
||||
<table class="table table-hover" style="margin-top: 10px;" v-if="mounts.length">
|
||||
<thead>
|
||||
@@ -212,15 +211,15 @@ onMounted(async () => {
|
||||
<SingleSelect v-model="mount.readOnly" :options="mountPermissions" option-key="value" option-label="name" />
|
||||
</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 tool 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 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') }} </span>
|
||||
<span v-if="mounts.length === 0">{{ $t('app.storage.mounts.noMounts') }}. </span>
|
||||
<span class="actionable" @click="onMountAdd()">{{ $t('app.storage.mounts.addMountAction') }}</span>
|
||||
</div>
|
||||
</FormGroup>
|
||||
|
||||
@@ -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')
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user