Compare commits

..

346 Commits

Author SHA1 Message Date
Girish Ramakrishnan 9522b8aa8d appstore: include provider as part of state
(cherry picked from commit 6533ba4581)
2026-01-29 14:39:30 +01:00
Girish Ramakrishnan fd881b4c61 9.0.18 changes 2026-01-29 14:30:24 +01:00
Girish Ramakrishnan 424ca715c9 ami: do not set domain provider by default
(cherry picked from commit b5f5b096d4)
2026-01-29 14:29:43 +01:00
Girish Ramakrishnan fe3c5f7a1b ami: add instanceId input box
(cherry picked from commit dce05140bf)
2026-01-29 14:29:36 +01:00
Girish Ramakrishnan e601fc93d6 setup: setupToken is not used anymore
(cherry picked from commit 2b80c6c1ad)
2026-01-29 14:29:30 +01:00
Girish Ramakrishnan 8f7076e4ef setup: set initial value for tls config
(cherry picked from commit 94a62b040b)
2026-01-29 14:29:01 +01:00
Girish Ramakrishnan bcc2c38ab7 Update translations 2026-01-18 18:27:16 +01:00
Girish Ramakrishnan 529d227e74 services: set some width to avoid column shifting 2026-01-18 18:26:26 +01:00
Girish Ramakrishnan 6b56efcf14 postgresql: reindex fix 2026-01-18 16:24:54 +01:00
Girish Ramakrishnan f23c8a9243 services: keep quick actions consistent 2026-01-18 16:24:54 +01:00
Girish Ramakrishnan 98660567e5 9.0.17 changes 2026-01-18 11:45:31 +01:00
Girish Ramakrishnan 5bf2c27030 postgres: fix hook that upgrades vectorchord
(cherry picked from commit 23e0fe5791)
2026-01-18 11:43:19 +01:00
Girish Ramakrishnan 2f4b300274 Merge tag 'v9.0.16' into 9.0
Version 9.0.16
2026-01-18 11:41:09 +01:00
Girish Ramakrishnan da5852d330 Fix version in changelog file 2026-01-16 10:32:27 +01:00
Johannes Zellner 81fa8544dd Fix email event log crash 2026-01-16 10:29:51 +01:00
Girish Ramakrishnan e407286c39 add descriptions to various views 2026-01-15 15:25:23 +01:00
Johannes Zellner 908f7b8985 Remove wrong expiration note for invite links in email 2026-01-15 12:10:58 +01:00
Johannes Zellner 98edbcaeb2 Move backup sites view to ActionBar pattern 2026-01-15 11:59:53 +01:00
Johannes Zellner 482b7e8017 We still need the click handler on the ellipsis if no quickaction is shown 2026-01-15 11:45:04 +01:00
Johannes Zellner acf295a259 Do not show double ellipse on touch devices for ActionBar 2026-01-15 11:35:51 +01:00
Johannes Zellner a0667da4de Always give a visual hint for actions on ActionBar even is all actions are shown 2026-01-15 09:59:29 +01:00
Girish Ramakrishnan f95ad86d5b services: make percent unsortable 2026-01-14 18:59:57 +01:00
Girish Ramakrishnan 72f03c75c8 app link is now called external link 2026-01-14 18:34:55 +01:00
Girish Ramakrishnan 14cb8f0014 backups: use ActionBar 2026-01-14 18:34:55 +01:00
Johannes Zellner 0d57870311 Update translations 2026-01-14 17:24:32 +01:00
Johannes Zellner fb6fca152f Move applinks back from appearance to app store 2026-01-14 17:09:32 +01:00
Girish Ramakrishnan 11a33455ce slight wording change 2026-01-14 15:57:06 +01:00
Girish Ramakrishnan 124076ed72 backup: make labels bold 2026-01-14 15:41:22 +01:00
Johannes Zellner 294f591152 Use ActionBar in app password view 2026-01-14 15:37:58 +01:00
Johannes Zellner f9414dc815 Update pankow 2026-01-14 15:35:46 +01:00
Girish Ramakrishnan 99c1e0e262 backups: list backup contents explicitly 2026-01-14 15:33:03 +01:00
Johannes Zellner f6c344873d Show all actions as quick actions, if ActionBar only has 2 or less actions 2026-01-14 15:19:27 +01:00
Johannes Zellner f8f768337e Give some basic hover feedback on ActionBar buttons 2026-01-14 14:48:44 +01:00
Girish Ramakrishnan c64694e40f services: merge memory limit column 2026-01-14 13:51:29 +01:00
Girish Ramakrishnan 116791f29f reduce bottom margin 2026-01-14 13:35:32 +01:00
Johannes Zellner 69fd7e0b7d Make ActionBar buttons less gray 2026-01-14 12:29:31 +01:00
Johannes Zellner ac539d1f90 Show ActionBar ellipsis button as active if menu is open 2026-01-14 12:10:10 +01:00
Johannes Zellner 9774a17f7e Make background of some ProgressBars transparent 2026-01-14 12:02:00 +01:00
Girish Ramakrishnan 1f7b0c076c mailbox: show correct usage for fresh mailboxes 2026-01-14 11:40:32 +01:00
Girish Ramakrishnan 51c6c37ea6 backups: fix formatting of preserved and label 2026-01-14 10:38:48 +01:00
Girish Ramakrishnan 790de8cfa6 backups: fix display of preserved and label 2026-01-14 10:01:56 +01:00
Girish Ramakrishnan f49f2ecb6c backups: show error if label is malformed 2026-01-14 09:36:04 +01:00
Girish Ramakrishnan 9647fb358b show menu when avatar is clicked 2026-01-13 22:49:00 +01:00
Girish Ramakrishnan e9e28ae26a user dialog: update state of button 2026-01-13 22:30:20 +01:00
Girish Ramakrishnan 60032c186d users: add default symbolic avatar 2026-01-13 22:14:53 +01:00
Girish Ramakrishnan e7011ca0a5 diskusage: show used instead of free
we have to match watch is shown in the progressbar below
2026-01-13 21:53:28 +01:00
Girish Ramakrishnan 0382113567 diskusage: show last updated date when loading from cache 2026-01-13 21:51:44 +01:00
Girish Ramakrishnan 18fe633979 diskusage: add localStorage cache with 1 hour expiry 2026-01-13 19:41:27 +01:00
Girish Ramakrishnan 2d8b4d9c2a more changes 2026-01-13 18:47:16 +01:00
Girish Ramakrishnan d4d6050862 display cron errors 2026-01-13 18:46:36 +01:00
Girish Ramakrishnan 6bed5265e2 display csp and robotsTxt errors 2026-01-13 18:40:48 +01:00
Girish Ramakrishnan a1b4fdf624 csp: allow multiple lines and add presets 2026-01-13 17:39:00 +01:00
Girish Ramakrishnan b9ea1573ea Add common robots.txt patterns 2026-01-13 17:05:54 +01:00
Girish Ramakrishnan 7a56545e9e date -> created 2026-01-13 16:34:37 +01:00
Girish Ramakrishnan 2bf9b66af7 api tokens: move last used as last column 2026-01-13 16:29:32 +01:00
Girish Ramakrishnan 215a6faae9 app: make package version copyable 2026-01-13 16:27:55 +01:00
Girish Ramakrishnan 61f37e0260 mongodb: fix fcv update issue 2026-01-13 15:21:30 +01:00
Girish Ramakrishnan b2c434a1fd installer: make docker pull timeout if pull hangs 2026-01-13 10:37:14 +01:00
Girish Ramakrishnan 0d2bcbf25b cloudron-support: add --disable-ipv6 2026-01-13 10:24:37 +01:00
Girish Ramakrishnan a3d1838a8c cloudron-support: remove --patch 2026-01-13 10:20:57 +01:00
Girish Ramakrishnan 692fb1a68c domains: add debug to print the error 2026-01-12 18:35:18 +01:00
Girish Ramakrishnan c71d915a4b typo 2026-01-12 18:33:22 +01:00
Johannes Zellner a0b5dec8b9 Update translations 2026-01-12 16:27:37 +01:00
Girish Ramakrishnan e2f71b10ec mail: update haraka to 3.1.2 2026-01-12 11:25:48 +01:00
Elias Hackradt 743e4fce0b Fixed wrong URL for PTR doc issue url 2026-01-10 19:59:27 +01:00
Johannes Zellner d97c608323 Do not use app.fqdn in href links and blindly prepend the protocol 2026-01-07 14:46:12 +01:00
Johannes Zellner 89baa3cabf Set the default locale to C.UTF-8 in 2026 2026-01-07 13:46:10 +01:00
Johannes Zellner d83712b093 Make filemanager the quickaction for volumes 2026-01-06 21:15:19 +01:00
Johannes Zellner 806309fc33 Apply mountoint vs mountpointS lsblk output fix also to mounts 2026-01-06 21:11:16 +01:00
Johannes Zellner 70f6343a2c Use ActionBar in API tokens list 2026-01-06 17:37:55 +01:00
Johannes Zellner 03dca869c8 Fix tooltip bug in API token table 2026-01-06 17:33:14 +01:00
Girish Ramakrishnan 84a10d4eb1 backups: add synology c2 2026-01-06 16:42:54 +01:00
Johannes Zellner 554a77fbca Use ActionBar for domain listing 2026-01-06 16:01:31 +01:00
Johannes Zellner e12f5e41ff Better error for invalid update versions 2026-01-06 15:54:55 +01:00
Johannes Zellner 79ad003bc6 Fix width of app archive action column to avoid jumping 2026-01-06 01:08:43 +01:00
Johannes Zellner fc417022c9 Do not autofocus appstore search input when dialog closes 2026-01-05 17:31:15 +01:00
Johannes Zellner f427d9f1c4 Use ActionBar in apps list 2026-01-05 17:22:53 +01:00
Girish Ramakrishnan 409f185f7e cloudron-support: do not use nc 2026-01-05 09:30:43 +01:00
Girish Ramakrishnan 6b080455ff add to changes 2026-01-05 09:30:38 +01:00
Girish Ramakrishnan da726ecd15 dockerregistry: do not use auth with explicit registry for appstore images 2026-01-02 10:24:51 +01:00
Girish Ramakrishnan a8f61878ca docker: add comments 2026-01-01 11:18:40 +01:00
Girish Ramakrishnan 73e929f0cf test: Happy new year! 2026-01-01 10:10:39 +01:00
Girish Ramakrishnan 60420c3e32 cloudron-support: make troubleshoot script work when not set up yet 2025-12-30 17:17:35 +01:00
Girish Ramakrishnan a02e933375 Upgrade mongodb to mongobleed 2025-12-29 12:53:02 +01:00
Johannes Zellner 73df6519f0 Update translations 2025-12-28 15:37:39 +01:00
Johannes Zellner ac3a34ff58 Clear formError in app install dialog 2025-12-28 13:16:32 +01:00
Johannes Zellner 8d85b521c8 Fix oidc profile avatar route 2025-12-24 10:51:38 +01:00
Johannes Zellner 6d89010a1f Use ActionBar in remaining lists 2025-12-20 09:06:32 +01:00
Johannes Zellner 8c85fdd7b5 Use ActionBar for email related lists 2025-12-20 08:49:35 +01:00
Johannes Zellner cc535b0d0a Use ActionBar in oidc clients list 2025-12-20 08:39:37 +01:00
Johannes Zellner d275b56dc1 Always show actionBar if device has no hover 2025-12-20 08:39:19 +01:00
Johannes Zellner ad1fc9b9c7 Do not show ErrorDialog on network errors 2025-12-20 07:55:27 +01:00
Johannes Zellner 1ea6fb9300 Ensure ActionBar is in the middle of the row 2025-12-19 20:51:48 +01:00
Johannes Zellner 9d96ab8f6a Show tooltips in ActionBar 2025-12-19 11:00:44 +01:00
Johannes Zellner 4f518d2315 Use ActionBar also for GroupsView 2025-12-19 10:45:29 +01:00
Johannes Zellner 7377476f97 Fix crash in GroupDialog when listing users for ldap groups 2025-12-19 10:43:48 +01:00
Johannes Zellner a55bd4458c Improve on the quick action bar 2025-12-19 10:25:40 +01:00
Girish Ramakrishnan 22cb7f7d8f addons is optional 2025-12-18 18:30:30 +01:00
Johannes Zellner 7b46595503 Add missing ActionBar.vue 2025-12-18 17:04:01 +01:00
Johannes Zellner aa30f6ef98 Add some quick actions in users listing 2025-12-18 16:56:27 +01:00
Girish Ramakrishnan 5107cd28c4 mail status: make the status text selectable 2025-12-18 16:51:19 +01:00
Girish Ramakrishnan b537d73a55 app install: show any install error in the UI 2025-12-18 15:24:00 +01:00
Johannes Zellner 9a5c49bd08 Add tooltip for and translate sidebar collapse action 2025-12-18 13:12:23 +01:00
Johannes Zellner 19cf204dc4 Improve colors for submenus 2025-12-18 11:41:38 +01:00
Johannes Zellner a75baba1f6 Show cloudron name in tooltip when sidebar is collapsed 2025-12-18 11:21:51 +01:00
Johannes Zellner a2dd45fd69 Include label property again in app search 2025-12-18 10:27:24 +01:00
Johannes Zellner b90cdb8686 Provide a globally injected isMobile state for reactivity 2025-12-17 16:44:01 +01:00
Johannes Zellner 16e79c6546 Add tooltips when sidebar is collapsed 2025-12-17 16:29:40 +01:00
Johannes Zellner f3fbff291f Show submenu headers 2025-12-17 15:40:49 +01:00
Girish Ramakrishnan f994088d38 increase opacity of sidebar icons 2025-12-17 13:17:00 +01:00
Girish Ramakrishnan 091a49ff78 Adjust sidebar width to text 2025-12-17 13:17:00 +01:00
Johannes Zellner 357313b555 Flip the submenu vertically if we have no space for it to drop down 2025-12-17 12:16:43 +01:00
Johannes Zellner 3b64d8b0a5 Fix css selector for gap element in submenu 2025-12-17 01:06:39 +01:00
Johannes Zellner 6fa95d9f4f Do not overlap the submenu with the main sidebar 2025-12-17 00:57:41 +01:00
Johannes Zellner 15ff5ede7e Do not rely on pankow Menu for SideBar 2025-12-16 20:55:04 +01:00
Johannes Zellner d89c826e18 Reduce padding for collapse action in SideBar 2025-12-16 19:17:43 +01:00
Johannes Zellner 5e485fb87e Collapse all submenus if the main menu gets collapsed 2025-12-16 12:50:02 +01:00
Johannes Zellner 6b7e8bef1d Fix main menu on mobile 2025-12-16 12:50:02 +01:00
Johannes Zellner 5cb2312806 Fix padding of logo in sidebar 2025-12-16 12:50:02 +01:00
Johannes Zellner aa7543ad0c Store sidebar collapse state in localstorage 2025-12-16 12:50:02 +01:00
Johannes Zellner b6df80dcef Use normal context menus for sidebar submenus 2025-12-16 12:50:02 +01:00
Girish Ramakrishnan c0ad75cc4d mailserver: typo where port was not used 2025-12-16 11:48:34 +01:00
Johannes Zellner 612002ec33 Fix mailbox owner select if username is not set 2025-12-16 10:52:23 +01:00
Johannes Zellner bb96b96e24 Fallback to email for mailbox owner if no username nor display name is set 2025-12-16 08:38:05 +01:00
Johannes Zellner 49fc63d422 Fix crash if email eventlog got unmounted during initial fetch 2025-12-15 18:53:59 +01:00
Johannes Zellner 350315fa56 Define main menu as a js object structure 2025-12-15 18:52:03 +01:00
Johannes Zellner fa859a3b5d Use custom SideBar instead of Pankow component 2025-12-15 16:58:05 +01:00
Girish Ramakrishnan b2f5110871 align the cloudron name to center 2025-12-14 11:08:40 +01:00
Girish Ramakrishnan 18d0cae6b0 9.0.15 changes
(cherry picked from commit 631333f48e)
2025-12-13 10:02:42 +01:00
Johannes Zellner a6f380444a Fix crash in the LogsViewer accessing non-existing nodes 2025-12-12 18:17:03 +01:00
Girish Ramakrishnan 631333f48e 9.0.15 changes 2025-12-12 18:13:17 +01:00
Johannes Zellner f279317105 Use unique temporary ssh key file for each ssh remote operation
File operations may run in parallel so we cannot rely on a well defined
keyfilename

(cherry picked from commit 854fbe53be)
2025-12-12 18:09:24 +01:00
Johannes Zellner f09b03338e Port LogViewer from vue object to composition style 2025-12-12 17:24:44 +01:00
Johannes Zellner 6e011ae70e Fix crash in the LogsViewer accessing non-existing nodes 2025-12-12 16:02:38 +01:00
Johannes Zellner 854fbe53be Use unique temporary ssh key file for each ssh remote operation
File operations may run in parallel so we cannot rely on a well defined
keyfilename
2025-12-12 15:50:32 +01:00
Johannes Zellner 1ef252fbc2 Revert "Rely on single private key file for optimized ssh remote fs operations"
This reverts commit aaebe01892.
2025-12-12 15:26:56 +01:00
Johannes Zellner aaebe01892 Rely on single private key file for optimized ssh remote fs operations 2025-12-12 14:44:19 +01:00
Johannes Zellner 83efffb7f9 Use new postgres addon for vectorchord 0.5.3 2025-12-12 12:00:46 +01:00
Girish Ramakrishnan b89aa4488c shell: add string fields for debugging 2025-12-12 11:59:41 +01:00
Girish Ramakrishnan 2029148e7c update postgres vectorchord ext 2025-12-11 18:59:43 +01:00
Girish Ramakrishnan 8b33414c55 backup info: add default label 2025-12-11 09:53:24 +01:00
Girish Ramakrishnan 0e177a7a4c volumes: set default port to 23 for sshfs 2025-12-11 09:34:47 +01:00
Girish Ramakrishnan 11fc6a61d5 relay: better wording for noop 2025-12-11 09:26:57 +01:00
Johannes Zellner ca5ab6edf5 Show user avatar in user listing
Moving the role icon to the username and hiding
external directory flag. This is not too useful anyways
2025-12-10 20:21:37 +01:00
Girish Ramakrishnan bbefca71e5 profile: add hasAvatar 2025-12-10 18:57:02 +01:00
Johannes Zellner 001adcee62 Fix sorting by username in users list 2025-12-10 18:50:11 +01:00
Girish Ramakrishnan 4870cdd76f Update translations 2025-12-10 18:18:32 +01:00
Girish Ramakrishnan 3dc8e87a27 Update well-known translations 2025-12-10 18:04:36 +01:00
Johannes Zellner 1cd069df5e Revert "Replace generic console.error handlers with window.cloudron.onError"
This reverts commit 7db5a48e35.
2025-12-10 18:04:07 +01:00
Johannes Zellner 4dd1a960c1 Revert "Only do an early return instead of onError() when domain adding errors"
This reverts commit 49f8b3b7f6.
2025-12-10 18:04:00 +01:00
Johannes Zellner 2c8dc3e6a7 Only wait in appstore view if this is the first time we open it 2025-12-10 17:29:02 +01:00
Johannes Zellner 49f8b3b7f6 Only do an early return instead of onError() when domain adding errors 2025-12-10 17:26:24 +01:00
Johannes Zellner dd9dc34308 Remove dead function in appstore view 2025-12-10 16:16:01 +01:00
Johannes Zellner a8b41945d0 Give app search focus on desktop 2025-12-10 16:14:52 +01:00
Johannes Zellner fa776c34de Update pankow 2025-12-10 15:53:48 +01:00
Johannes Zellner a3a4bbbb83 Update pankow 2025-12-10 15:39:40 +01:00
Johannes Zellner 52e1276c8d Improve reactivity if app install dialog should be opened 2025-12-10 14:21:01 +01:00
Johannes Zellner 241be5eaee Improve volume form error display 2025-12-10 12:15:13 +01:00
Johannes Zellner a32903218e Fix button size for volume filemanager link in app config 2025-12-10 12:05:47 +01:00
Johannes Zellner 6620fc8570 Fetch more notifications to avoid required pagination 2025-12-10 11:57:51 +01:00
Johannes Zellner 388a4d93e4 Improve readability of graph tooltips 2025-12-10 11:21:07 +01:00
Girish Ramakrishnan 85898d3531 volumes: fix display of target 2025-12-10 11:02:45 +01:00
Girish Ramakrishnan 1f2e1691f9 add note that remoteDir is not required 2025-12-10 10:52:10 +01:00
Girish Ramakrishnan 2693f5f496 volumes: remove redundant form validation check 2025-12-10 10:49:35 +01:00
Girish Ramakrishnan 854f7d7f2e cloudron-support: handle systemd-detect-virt error 2025-12-09 16:06:20 +01:00
Johannes Zellner 1cac67d4c5 Do not loose graph item colors after sorting 2025-12-09 16:03:52 +01:00
Johannes Zellner 72970720d2 Only show the first 5 graph lines in tooltip 2025-12-09 16:00:04 +01:00
Johannes Zellner b5c75caea0 Sort the graph tooltip items according to the value at the given point in time 2025-12-09 15:38:28 +01:00
Johannes Zellner f421fd771f Prefix domain (un)register calls with the domain which failed
The info would also be in the extra error info, however we catch the
error in apptask and here we don't know if this is a domain error or
something else.
2025-12-09 14:36:38 +01:00
Johannes Zellner 748f3a3a4f Do not console.log() activation state 2025-12-09 14:08:27 +01:00
Johannes Zellner 59ccf6181e Separate subscription plan and status display 2025-12-09 14:07:43 +01:00
Girish Ramakrishnan c7f5e6b5b0 typo 2025-12-09 13:01:13 +01:00
Girish Ramakrishnan 10f99673c5 oidc: filter oidc-provider module response instead 2025-12-09 12:52:37 +01:00
Girish Ramakrishnan aff5e8f44d oidc: add separate jwks key route for cloudflare access 2025-12-09 12:51:27 +01:00
Johannes Zellner 7db5a48e35 Replace generic console.error handlers with window.cloudron.onError 2025-12-08 20:11:13 +01:00
Johannes Zellner fe73e76fe9 No need to clear error dialog content on close, just makes UI flicker 2025-12-08 19:47:45 +01:00
Johannes Zellner faa22feebf Disable create new backup or run cleanup task for site which has an active task 2025-12-08 19:21:40 +01:00
Girish Ramakrishnan 9773c02e7d backupcleaner: remove integrity information 2025-12-08 19:19:23 +01:00
Johannes Zellner 628902bb70 request errors for 401 or >= 502 are handled in fetcher global error hook 2025-12-08 19:18:36 +01:00
Johannes Zellner c2e981b35a fetching the profile should error normally 2025-12-08 19:18:36 +01:00
Girish Ramakrishnan 2f40eeb49f df: check if path exists 2025-12-08 18:57:41 +01:00
Johannes Zellner cfb2501576 Reset page on email eventlog refresh 2025-12-08 16:51:26 +01:00
Girish Ramakrishnan 4057906b2c do not disable hidden submit
this allows user to press enter and the user will report validity
2025-12-08 11:08:41 +01:00
Girish Ramakrishnan 93fe97b94d setup: do not disable submit button with invalid form 2025-12-07 16:59:26 +01:00
Girish Ramakrishnan aa2df465a0 Update changelog for 9.0.14 2025-12-07 16:25:58 +01:00
Girish Ramakrishnan 350438b2c4 Update lockfile 2025-12-07 16:20:14 +01:00
Girish Ramakrishnan 075499b695 Update pankow 2025-12-07 16:19:43 +01:00
Girish Ramakrishnan b361adbe30 backupsite: fix form state 2025-12-06 11:33:18 +01:00
Girish Ramakrishnan c448322367 backups: fix download 2025-12-06 11:19:05 +01:00
Girish Ramakrishnan b6d4b58f86 Update pankow 2025-12-05 21:08:19 +01:00
Girish Ramakrishnan bbb00ff36f better defaults for rsync 2025-12-05 21:03:21 +01:00
Girish Ramakrishnan 07dc823528 better defaults for cifs and sshfs 2025-12-05 20:55:29 +01:00
Girish Ramakrishnan b9ae97e5ec volume: fix up form validation pattern 2025-12-05 20:47:49 +01:00
Girish Ramakrishnan dfafbdd882 Use same pattern for form validation 2025-12-05 19:46:34 +01:00
Girish Ramakrishnan 35d0227862 setup: fix title and heading 2025-12-05 17:48:33 +01:00
Girish Ramakrishnan c8842cc71f fix access to form in checkValidity 2025-12-05 17:48:33 +01:00
Girish Ramakrishnan 620974217a restore: teardown pseudo backup site 2025-12-05 16:12:59 +01:00
Girish Ramakrishnan 392d47852d system: skip dataDir analysis if it is missing 2025-12-05 15:59:49 +01:00
Girish Ramakrishnan f714cd66f7 rework mail domain stats
We can now show list count, alias count as well in the mail domains UI
2025-12-05 13:32:07 +01:00
Johannes Zellner 425e196dfc add ESC key event handler in apps view to clear filter 2025-12-04 18:17:16 +01:00
Johannes Zellner 1ffe617287 Give better feedback when no include/exclude content is selected for a backup site's contents 2025-12-04 10:51:42 +01:00
Johannes Zellner ea93d197ab Ensure we reset the days and hours of the backup schedule when showing the dialog 2025-12-04 10:40:40 +01:00
Johannes Zellner 37c569a976 Reset include/exclude backup site content on dialog open 2025-12-04 10:15:53 +01:00
Girish Ramakrishnan 7a189bd5e5 readonly and required should only be assigned boolean values 2025-12-04 09:59:51 +01:00
Girish Ramakrishnan d3876eb7b0 gcs: there is no endpoint 2025-12-04 09:22:06 +01:00
Girish Ramakrishnan 64cb848a37 sftp: give it a static ip 2025-12-04 09:09:19 +01:00
Girish Ramakrishnan 162e51a0af restore: fix crash when trying to mount fs volumes 2025-12-04 00:14:37 +01:00
Girish Ramakrishnan 59b9991a2c Fix form validation when credentials change 2025-12-04 00:03:06 +01:00
Girish Ramakrishnan 97128673ff fix form validation in file upload buttons 2025-12-03 23:39:09 +01:00
Girish Ramakrishnan fdac444aed make readonly and required mutually exclusive
per https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Attributes/readonly

"Note: The required attribute is not permitted on inputs with the readonly attribute specified."
2025-12-03 23:33:24 +01:00
Girish Ramakrishnan c656903772 Use the simpler file.save to verify 2025-12-03 21:02:53 +01:00
Girish Ramakrishnan 61b5ab8a49 gcs: ensure handlers are attached before write 2025-12-03 20:36:55 +01:00
Girish Ramakrishnan 550df1be89 import: explictly handle all the config keys 2025-12-03 20:33:23 +01:00
Girish Ramakrishnan 99c14533a5 gcs: fix copy operation
copy() is part of the interface and does not include the prefix.
2025-12-03 18:31:26 +01:00
Girish Ramakrishnan b759fdb6e3 s3: remove leading slash in CopySource 2025-12-03 17:11:51 +01:00
Girish Ramakrishnan 374e1f65c6 typo. mountpoint is a command 2025-12-03 11:54:44 +01:00
Girish Ramakrishnan 3d6526de3e backup site: fix placeholder strings 2025-12-03 11:52:27 +01:00
Girish Ramakrishnan 8f43c7d3d8 location: use the domain where app is installed as default 2025-12-03 11:02:42 +01:00
Girish Ramakrishnan e5b7ad5be2 restore: remove unused fields 2025-12-03 10:42:00 +01:00
Girish Ramakrishnan 8227ce1158 restore: fix typo. error -> formError 2025-12-03 10:27:20 +01:00
Girish Ramakrishnan 35b80178ed account: unlinking is not a settings item 2025-12-03 09:59:50 +01:00
Girish Ramakrishnan 80b0dba9fe remove old changelog 2025-12-02 15:22:37 +01:00
Girish Ramakrishnan a5497dc215 restore: validate ipv6 config 2025-12-02 15:19:59 +01:00
Girish Ramakrishnan 964fb5d251 typo 2025-12-02 15:16:26 +01:00
Johannes Zellner e24ee05337 Ensure we also refetch the backup sites when reloading the system backups 2025-12-02 14:51:40 +01:00
Johannes Zellner c6858d505f Until we know better, just hide app backup size on mobile 2025-12-02 14:39:47 +01:00
Johannes Zellner 0ea1e47176 Hide backup size on mobile 2025-12-02 13:59:10 +01:00
Johannes Zellner 5355b91f37 Fix table layout for groups and bring back member usernames 2025-12-02 13:17:36 +01:00
Johannes Zellner 86e7eb1087 Bring back group labels in users view with constrained table columns 2025-12-02 13:13:00 +01:00
Johannes Zellner 043d89c03b Ensure we purge the ssh backup key file in case it was left over by a
previous failed backup run

fs.writeFileSync() would fail to overwrite due to restricted file mode
for ssh
2025-12-02 12:14:33 +01:00
Girish Ramakrishnan 1cbad1057d cloudron-support: with equal timestamps, order by name 2025-12-02 09:33:55 +01:00
Girish Ramakrishnan d906771b18 Update translations 2025-12-02 09:12:07 +01:00
Johannes Zellner 76ef9c0388 Go back to mailbox alias column eliding 2025-12-01 22:21:02 +01:00
Girish Ramakrishnan 262d96f8d7 Fix welcome translation 2025-12-01 22:09:37 +01:00
Girish Ramakrishnan 41b7466325 profile: show 2fa button for local users (when ldap connector enabled) 2025-12-01 21:16:33 +01:00
Girish Ramakrishnan 76f2c5f9fc mandatory 2fa: show undismissable dialog and warning 2025-12-01 20:56:21 +01:00
Johannes Zellner e5a1fc9e2d Ensure the restore progress message does not overflow the screen 2025-12-01 20:50:03 +01:00
Girish Ramakrishnan 11f9e260ed 2fa: fix hash parsing in router 2025-12-01 19:54:19 +01:00
Girish Ramakrishnan e209bdec65 SetupAccount: fix set up button disable status 2025-12-01 19:28:41 +01:00
Girish Ramakrishnan 6432851a78 users: make remove 2fa separate dialog 2025-12-01 19:19:12 +01:00
Johannes Zellner 31fb22a7c3 Add window.cloudron.onRequestError() 2025-12-01 19:05:22 +01:00
Johannes Zellner bc47e30ad3 Use storageQuota instead of quotaLimit in the mailbox list 2025-12-01 17:22:58 +01:00
Johannes Zellner 58cf7c720f Same as users view, only show user count in groups view 2025-12-01 17:10:22 +01:00
Johannes Zellner 48bf73de80 replace line-height with max-height for logo to avoid squashing 2025-12-01 16:47:40 +01:00
Johannes Zellner 76a3f4e86c Only show group count in users view and reduce horizontal view size 2025-12-01 16:38:49 +01:00
Johannes Zellner 3a760282f1 Only refresh changed email domains when mailboxes change 2025-12-01 16:04:14 +01:00
Girish Ramakrishnan 71affc0239 cloudron-support: add env type 2025-12-01 15:10:23 +01:00
Johannes Zellner 3b95d23d23 Increase logo line-height 2025-12-01 15:07:10 +01:00
Girish Ramakrishnan 8cd5345f8c mailboxes: set size to 0 if missing in usage 2025-12-01 14:45:44 +01:00
Girish Ramakrishnan fda393b5e1 alias: use mailbox domain as default and not dashboard 2025-12-01 14:26:36 +01:00
Girish Ramakrishnan 264f9f84ed mailbox owner is required 2025-12-01 14:26:36 +01:00
Johannes Zellner 1d73760901 Limit cloudron name input to 64 chars 2025-12-01 11:50:30 +01:00
Johannes Zellner 03a13df47b Add :maxlength property to EditableField component 2025-12-01 11:50:19 +01:00
Johannes Zellner 5160f22d91 Give cloudron logo in sidebar a sensible max-width 2025-12-01 11:49:15 +01:00
Girish Ramakrishnan 3bbc2bf986 9.0.14 changes 2025-12-01 10:47:19 +01:00
Johannes Zellner 90f68da42f Reduce mailbox view width back to normal 2025-12-01 10:37:55 +01:00
Johannes Zellner f37438b7a7 Update frontend dependencies 2025-12-01 10:20:02 +01:00
Girish Ramakrishnan 826d124a5f Update translations 2025-12-01 09:48:35 +01:00
Girish Ramakrishnan c162fd178b Fix tests 2025-11-28 17:40:13 +01:00
Johannes Zellner 9b92e48a6e Fixup some vue prop type warnings in repair view 2025-11-28 15:06:22 +01:00
Johannes Zellner 5b5c15b7f3 Show raw platform startup errors in dialog 2025-11-28 14:50:18 +01:00
Girish Ramakrishnan 6e9cd4c11b platform: give feedback on service being started 2025-11-28 12:54:22 +01:00
Girish Ramakrishnan 8c03c73b28 platform: show any container upgrade errors in the UI 2025-11-28 12:16:27 +01:00
Girish Ramakrishnan 2c10ceba5b mail status: fix rbl display 2025-11-28 12:01:50 +01:00
Girish Ramakrishnan 2a3110cd3d network: detect default ipv6 interface when no ipv4 interface 2025-11-28 10:02:36 +01:00
Johannes Zellner 924ea435b1 Show error label if subscription is expired 2025-11-27 23:34:25 +01:00
Girish Ramakrishnan 0e4a389910 change restart button text 2025-11-27 18:48:15 +01:00
Girish Ramakrishnan 720dc14ecf query root dns to detect udp 53 blockage 2025-11-27 18:42:11 +01:00
Girish Ramakrishnan 51f5f0b82d typo 2025-11-27 18:18:15 +01:00
Girish Ramakrishnan f380a6f8cf cloudron-support: make nameserver list customizable 2025-11-27 18:15:32 +01:00
Girish Ramakrishnan 437a033739 Fix broken comment 2025-11-27 14:00:47 +01:00
Girish Ramakrishnan 2b77e4d292 Fix restart dialog buttons 2025-11-27 13:57:17 +01:00
Girish Ramakrishnan 0e104ee936 app search: title is optional manifest 2025-11-27 13:39:25 +01:00
Johannes Zellner a820bf7bd0 Only show mailbox alias counts in main table to avoid too much overflow 2025-11-27 11:36:35 +01:00
Johannes Zellner 09fdec8fbd Better indicator if no mailbox quota is set 2025-11-27 11:32:07 +01:00
Johannes Zellner 80f6d733b9 Show only the mailinglist member count in the table 2025-11-27 11:31:15 +01:00
Johannes Zellner 838345ba46 Accomodate for long translation strings in mailinglist dialog 2025-11-27 11:27:33 +01:00
Johannes Zellner c2378d33b4 Also use a temporary SSH identity file for optimized ssh remote rm -rf 2025-11-27 10:04:06 +01:00
Johannes Zellner 95575bc040 Improve mailboxes list view if it would overflow 2025-11-27 09:49:35 +01:00
Girish Ramakrishnan 2926871eab Update translations 2025-11-26 16:46:43 +01:00
Johannes Zellner 5b05ea285c Update frontend dependencies 2025-11-26 16:45:41 +01:00
Girish Ramakrishnan 48a2e6881f import/restore: check validity after prefill 2025-11-26 16:22:22 +01:00
Johannes Zellner edbeaa2f77 check validity on app import form 2025-11-26 16:20:25 +01:00
Girish Ramakrishnan 48a85a620d restore: remount sites in background 2025-11-26 15:36:33 +01:00
Girish Ramakrishnan cc8db71ecf apps: typo caused invalid backupId 2025-11-26 14:39:16 +01:00
Girish Ramakrishnan e4573f74a4 import/restore: fix copying of various s3 options 2025-11-26 14:14:08 +01:00
Girish Ramakrishnan 8cff72cf59 use a real placeholder 2025-11-26 13:15:07 +01:00
Girish Ramakrishnan 73a9de7708 9.0.13 changes 2025-11-26 12:57:35 +01:00
Girish Ramakrishnan 104318ab8c import/restore: automatically detect prefix from the full path 2025-11-26 12:57:32 +01:00
Girish Ramakrishnan 8ec4659949 move the code block down for readability 2025-11-26 11:37:16 +01:00
Girish Ramakrishnan ffa8ff8427 add comment 2025-11-26 11:36:14 +01:00
Girish Ramakrishnan 4ef1339ba2 filesystem: handle non-existent prefix 2025-11-26 11:25:35 +01:00
Girish Ramakrishnan 3702efdcb3 import/restore: add any prefix from the config into the remotePath 2025-11-26 10:43:00 +01:00
Girish Ramakrishnan bbdfbe1ab7 restore: when restoring apps, use the latest backup id
this ignores the user provided site information. the site contents may or may
not contain this app.
2025-11-25 18:12:54 +01:00
Johannes Zellner cc1fc5c269 login.loginTo translation is gone 2025-11-25 17:05:26 +01:00
Johannes Zellner bc32fa64bf Disable service restart if a service is in recovery mode 2025-11-25 16:46:30 +01:00
Johannes Zellner cfc7de9c77 Do not poll services if they are in recoveryMode 2025-11-25 16:37:23 +01:00
Girish Ramakrishnan 945ab30373 add utils.prettySiteLocation 2025-11-25 14:52:33 +01:00
Johannes Zellner 494125227f Keep track of services poll timers and clear them on view unload 2025-11-25 14:15:58 +01:00
Girish Ramakrishnan a4919b06f9 services: handle disabled state explicitly 2025-11-25 13:40:52 +01:00
Girish Ramakrishnan 790ba406bf cloudron-support: remove cloudron from arg
'cloudron' is a bit redundant and matches our UI text 'services'
reorder the help to be alphabetical
change cli args to plural
2025-11-25 09:42:42 +01:00
Elias Hackradt e0367056bd cloudron-support: add --check-cloudron-services and add it to troubleshoot 2025-11-25 09:24:30 +01:00
Girish Ramakrishnan 4bf0dc192c import: copy all config values (s3 was missing) 2025-11-25 09:23:25 +01:00
Johannes Zellner 4575a0ddce Fetch mailbox usage in the background to not delay mailbox listing 2025-11-24 17:32:03 +01:00
Johannes Zellner 837cbff092 Only offer local groups in user config dialog 2025-11-24 16:22:45 +01:00
Johannes Zellner 4108047644 Dump ldap group search results on sync to help finding correct configs 2025-11-24 15:46:40 +01:00
Johannes Zellner 347cf4f67d Remove early return leftover from debugging 2025-11-24 15:02:33 +01:00
Elias Hackradt 7f9344a556 Added --check- and --apply-db-migration and add --check-db-migration to troubleshoot 2025-11-24 14:28:03 +01:00
Girish Ramakrishnan 8907b692c1 nginx: do not log query params 2025-11-24 14:11:06 +01:00
Johannes Zellner 6c0d5cb601 Remove yesno node module 2025-11-24 13:58:03 +01:00
Girish Ramakrishnan 5c69a146f6 Only show no matches placeholder after domains are loaded 2025-11-24 13:50:07 +01:00
Girish Ramakrishnan de75ae5b9e collectd is gone 2025-11-24 13:50:07 +01:00
Johannes Zellner 9c9e2c6a62 Better name groupId variable to be more clear 2025-11-24 13:46:05 +01:00
Girish Ramakrishnan 917c18a423 s3: ensure endpoint has a scheme 2025-11-24 12:23:52 +01:00
Johannes Zellner aac81c2fba Update dashboard dependencies 2025-11-24 12:08:01 +01:00
Girish Ramakrishnan 9e82839fb7 rsync: bump empty dir limit to 80k 2025-11-24 12:06:52 +01:00
Girish Ramakrishnan ae2f74777b rename some variables for clarity 2025-11-23 15:35:18 +01:00
Girish Ramakrishnan 4c5d67606f remove unused variable 2025-11-23 15:03:40 +01:00
Girish Ramakrishnan 0d2a0f91c7 Update translations 2025-11-23 11:34:46 +01:00
Girish Ramakrishnan b65fa3e2c7 make logout button standout a bit 2025-11-23 11:32:33 +01:00
Girish Ramakrishnan e87d2e1218 Fix issue where footer/name can break templates
stringify the template variables at render time

JSON.stringify - will escape out quotes
<%- renders as-is without any more escaping
2025-11-23 11:17:59 +01:00
Girish Ramakrishnan 00ae320b51 remove spurious comma 2025-11-22 08:18:18 +01:00
Girish Ramakrishnan 3d46d24038 9.0.12 changes 2025-11-21 14:09:53 +01:00
Girish Ramakrishnan 8b04484ff7 Update haraka
deferred information and inet_prefer setting
2025-11-20 23:32:01 +01:00
Girish Ramakrishnan 7f9f3f683b Fix outbound port 25 relay warning (prefer ipv4) 2025-11-20 16:08:54 +01:00
Johannes Zellner fb2ce06621 Replace table in eventlog with custom elements 2025-11-20 15:43:36 +01:00
Girish Ramakrishnan 89f5e87601 use placeholder text for zone name 2025-11-20 15:15:44 +01:00
Girish Ramakrishnan e124755363 Fix dialog title 2025-11-20 14:19:02 +01:00
Johannes Zellner d0ccbe2786 Do not use cached service object in service edit dialog 2025-11-20 14:13:13 +01:00
Johannes Zellner 25dec602b8 Add english labels for eventlog filtering 2025-11-20 02:08:08 +01:00
Johannes Zellner bbf7007250 appId is part of eventlog.data not toplevel 2025-11-19 23:21:27 +01:00
Johannes Zellner 2b4f8ff00d store actual appId not oidc clientId for log in events 2025-11-19 23:21:09 +01:00
Girish Ramakrishnan b467b58ee7 disable directoryserver logs by default 2025-11-19 17:17:41 +01:00
Girish Ramakrishnan facefeddae mailbox dialog: error is displayed twice 2025-11-19 17:15:08 +01:00
Girish Ramakrishnan 141bdb1307 mail: check for outbound ipv6 connectivity 2025-11-19 16:31:31 +01:00
Johannes Zellner b53da61e7c Always fetch enough event logs to fill the screen 2025-11-19 16:08:22 +01:00
Girish Ramakrishnan ede93323af remove double fullstop 2025-11-19 13:39:04 +01:00
Girish Ramakrishnan 8ccf79175a another casing fix 2025-11-19 09:30:46 +01:00
Girish Ramakrishnan 9fa330a0a0 activation: fix casing 2025-11-18 15:01:57 +01:00
Girish Ramakrishnan 3693857960 backup schedule: fix button state with 'never' 2025-11-18 10:37:42 +01:00
Girish Ramakrishnan c5f97e8bb0 fix parsing of cron pattern
in some old instances, we had "00 00  * * *" (note double space
and only 5 components).
2025-11-18 09:58:38 +01:00
Girish Ramakrishnan 2cb7b4d1ea 9.0.11 changes 2025-11-17 09:08:51 +01:00
Girish Ramakrishnan 6247cece94 backup site: create info dir of the clone site 2025-11-17 09:08:46 +01:00
Girish Ramakrishnan 417f5c3610 backup site: fix migration with mixed formats 2025-11-16 12:07:44 +01:00
Girish Ramakrishnan 3e6f3bd807 mailinglist: fix search on name 2025-11-16 11:11:17 +01:00
Girish Ramakrishnan 6346c7fe9b mail: fix count indicator when loading 2025-11-15 11:00:54 +01:00
170 changed files with 6125 additions and 2960 deletions
+60
View File
@@ -3062,3 +3062,63 @@
* 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
+7 -6
View File
@@ -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>
+7 -6
View File
@@ -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>
+8 -7
View File
@@ -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>
+337 -224
View File
@@ -6,26 +6,26 @@
"packages": {
"": {
"dependencies": {
"@cloudron/pankow": "^3.5.9",
"@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.39.1",
"eslint-plugin-vue": "^10.5.1",
"marked": "^17.0.0",
"eslint-plugin-vue": "^10.6.2",
"marked": "^17.0.1",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"vite": "^7.2.2",
"vite": "^7.2.7",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-router": "^4.6.3"
}
},
@@ -76,15 +76,16 @@
}
},
"node_modules/@cloudron/pankow": {
"version": "3.5.9",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.9.tgz",
"integrity": "sha512-59aAGwAdOGwSi3csh+jf6+cOEB5IyJrgppooyfj8K031Go145CmN3rD7J1eeVhZRDBa9zjbHNTSqs/rMqkwyEA==",
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.6.4.tgz",
"integrity": "sha512-sQWA1jt308Jwmx86zyBEnf3buBX9HEbyfaIFNi1ICLWoYNH0DLPAAOnf1WZQ6nnsdS+eL8oPjDOpV0N0buXyYQ==",
"license": "ISC",
"dependencies": {
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
"filesize": "^11.0.13",
"monaco-editor": "^0.54.0"
"monaco-editor": "^0.55.1",
"online-3d-viewer": "^0.18.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
@@ -699,13 +700,13 @@
}
},
"node_modules/@intlify/core-base": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
"license": "MIT",
"dependencies": {
"@intlify/message-compiler": "11.1.12",
"@intlify/shared": "11.1.12"
"@intlify/message-compiler": "11.2.2",
"@intlify/shared": "11.2.2"
},
"engines": {
"node": ">= 16"
@@ -715,12 +716,12 @@
}
},
"node_modules/@intlify/message-compiler": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
"license": "MIT",
"dependencies": {
"@intlify/shared": "11.1.12",
"@intlify/shared": "11.2.2",
"source-map-js": "^1.0.2"
},
"engines": {
@@ -731,9 +732,9 @@
}
},
"node_modules/@intlify/shared": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw==",
"license": "MIT",
"engines": {
"node": ">= 16"
@@ -754,9 +755,9 @@
"integrity": "sha512-hW0GwZj06z/ZFUW2Espl7toVDjghJN+EKqyXzPSV8NV89d5BYp5rRMBJoc+aUN0x5OXDMeRQHazejr2Xmqj2tw=="
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
"integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q==",
"version": "1.0.0-beta.50",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
"integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA==",
"license": "MIT"
},
"node_modules/@rollup/rollup-android-arm-eabi": {
@@ -1019,6 +1020,16 @@
"win32"
]
},
"node_modules/@simonwep/pickr": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.9.0.tgz",
"integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==",
"license": "MIT",
"dependencies": {
"core-js": "3.32.2",
"nanopop": "2.3.0"
}
},
"node_modules/@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -1031,13 +1042,20 @@
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
"license": "MIT"
},
"node_modules/@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"license": "MIT",
"optional": true
},
"node_modules/@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
"integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==",
"license": "MIT",
"dependencies": {
"@rolldown/pluginutils": "1.0.0-beta.29"
"@rolldown/pluginutils": "1.0.0-beta.50"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
@@ -1048,39 +1066,39 @@
}
},
"node_modules/@vue/compiler-core": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.24",
"@vue/shared": "3.5.25",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-core": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.24",
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24",
"@vue/compiler-core": "3.5.25",
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
@@ -1088,13 +1106,13 @@
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-dom": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/devtools-api": {
@@ -1103,53 +1121,53 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"node_modules/@vue/reactivity": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.24"
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/reactivity": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
"@vue/shared": "3.5.24",
"@vue/reactivity": "3.5.25",
"@vue/runtime-core": "3.5.25",
"@vue/shared": "3.5.25",
"csstype": "^3.1.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25"
},
"peerDependencies": {
"vue": "3.5.24"
"vue": "3.5.25"
}
},
"node_modules/@vue/shared": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg==",
"license": "MIT"
},
"node_modules/@xterm/addon-attach": {
@@ -1211,9 +1229,9 @@
}
},
"node_modules/anser": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.2.tgz",
"integrity": "sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw==",
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.3.tgz",
"integrity": "sha512-QGY1oxYE7/kkeNmbtY/2ZjQ07BCG3zYdz+k/+sf69kMzEIxb93guHkPnIXITQ+BYi61oQwG74twMOX1tD4aesg==",
"license": "MIT"
},
"node_modules/ansi-styles": {
@@ -1345,6 +1363,17 @@
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==",
"license": "MIT"
},
"node_modules/core-js": {
"version": "3.32.2",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.2.tgz",
"integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ==",
"hasInstallScript": true,
"license": "MIT",
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/core-js"
}
},
"node_modules/cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -1363,6 +1392,7 @@
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz",
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==",
"license": "MIT",
"bin": {
"cssesc": "bin/cssesc"
},
@@ -1371,9 +1401,9 @@
}
},
"node_modules/csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==",
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"license": "MIT"
},
"node_modules/debug": {
@@ -1399,10 +1429,13 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
"node_modules/dompurify": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ==",
"license": "(MPL-2.0 OR Apache-2.0)"
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"license": "(MPL-2.0 OR Apache-2.0)",
"optionalDependencies": {
"@types/trusted-types": "^2.0.7"
}
},
"node_modules/entities": {
"version": "4.5.0",
@@ -1516,15 +1549,15 @@
}
},
"node_modules/eslint-plugin-vue": {
"version": "10.5.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.5.1.tgz",
"integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==",
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.6.2.tgz",
"integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==",
"license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
"nth-check": "^2.1.1",
"postcss-selector-parser": "^6.0.15",
"postcss-selector-parser": "^7.1.0",
"semver": "^7.6.3",
"xml-name-validator": "^4.0.0"
},
@@ -1693,6 +1726,12 @@
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==",
"license": "MIT"
},
"node_modules/fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==",
"license": "MIT"
},
"node_modules/file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -1926,9 +1965,9 @@
}
},
"node_modules/marked": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.0.tgz",
"integrity": "sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg==",
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
@@ -1983,12 +2022,12 @@
}
},
"node_modules/monaco-editor": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz",
"integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==",
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"license": "MIT",
"dependencies": {
"dompurify": "3.1.7",
"dompurify": "3.2.7",
"marked": "14.0.0"
}
},
@@ -2028,6 +2067,12 @@
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/nanopop": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.3.0.tgz",
"integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw==",
"license": "MIT"
},
"node_modules/natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -2044,6 +2089,17 @@
"url": "https://github.com/fb55/nth-check?sponsor=1"
}
},
"node_modules/online-3d-viewer": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/online-3d-viewer/-/online-3d-viewer-0.18.0.tgz",
"integrity": "sha512-y7ZlV/zkakNUyjqcXz6XecA7vXgLEUnaAey9tyx8o6/wcdV64RfjXAQOjGXGY2JOZoDi4Cg1ic9icSWMWAvRQA==",
"license": "MIT",
"dependencies": {
"@simonwep/pickr": "1.9.0",
"fflate": "0.8.2",
"three": "0.176.0"
}
},
"node_modules/optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -2160,9 +2216,10 @@
}
},
"node_modules/postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"license": "MIT",
"dependencies": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -2301,6 +2358,12 @@
"node": ">=8"
}
},
"node_modules/three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz",
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA==",
"license": "MIT"
},
"node_modules/tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -2380,12 +2443,13 @@
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/vite": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"license": "MIT",
"dependencies": {
"esbuild": "^0.25.0",
@@ -2502,16 +2566,16 @@
}
},
"node_modules/vue": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "3.5.24",
"@vue/runtime-dom": "3.5.24",
"@vue/server-renderer": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
"@vue/runtime-dom": "3.5.25",
"@vue/server-renderer": "3.5.25",
"@vue/shared": "3.5.25"
},
"peerDependencies": {
"typescript": "*"
@@ -2548,13 +2612,13 @@
}
},
"node_modules/vue-i18n": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
"license": "MIT",
"dependencies": {
"@intlify/core-base": "11.1.12",
"@intlify/shared": "11.1.12",
"@intlify/core-base": "11.2.2",
"@intlify/shared": "11.2.2",
"@vue/devtools-api": "^6.5.0"
},
"engines": {
@@ -2654,14 +2718,15 @@
}
},
"@cloudron/pankow": {
"version": "3.5.9",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.5.9.tgz",
"integrity": "sha512-59aAGwAdOGwSi3csh+jf6+cOEB5IyJrgppooyfj8K031Go145CmN3rD7J1eeVhZRDBa9zjbHNTSqs/rMqkwyEA==",
"version": "3.6.4",
"resolved": "https://registry.npmjs.org/@cloudron/pankow/-/pankow-3.6.4.tgz",
"integrity": "sha512-sQWA1jt308Jwmx86zyBEnf3buBX9HEbyfaIFNi1ICLWoYNH0DLPAAOnf1WZQ6nnsdS+eL8oPjDOpV0N0buXyYQ==",
"requires": {
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.1.0",
"filesize": "^11.0.13",
"monaco-editor": "^0.54.0"
"monaco-editor": "^0.55.1",
"online-3d-viewer": "^0.18.0"
}
},
"@esbuild/aix-ppc64": {
@@ -2937,27 +3002,27 @@
"integrity": "sha512-xeO57FpIu4p1Ri3Jq/EXq4ClRm86dVF2z/+kvFnyqVYRavTZmaFaUBbWCOuuTh0o/g7DSsk6kc2vrS4Vl5oPOQ=="
},
"@intlify/core-base": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.1.12.tgz",
"integrity": "sha512-whh0trqRsSqVLNEUCwU59pyJZYpU8AmSWl8M3Jz2Mv5ESPP6kFh4juas2NpZ1iCvy7GlNRffUD1xr84gceimjg==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-11.2.2.tgz",
"integrity": "sha512-0mCTBOLKIqFUP3BzwuFW23hYEl9g/wby6uY//AC5hTgQfTsM2srCYF2/hYGp+a5DZ/HIFIgKkLJMzXTt30r0JQ==",
"requires": {
"@intlify/message-compiler": "11.1.12",
"@intlify/shared": "11.1.12"
"@intlify/message-compiler": "11.2.2",
"@intlify/shared": "11.2.2"
}
},
"@intlify/message-compiler": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.1.12.tgz",
"integrity": "sha512-Fv9iQSJoJaXl4ZGkOCN1LDM3trzze0AS2zRz2EHLiwenwL6t0Ki9KySYlyr27yVOj5aVz0e55JePO+kELIvfdQ==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-11.2.2.tgz",
"integrity": "sha512-XS2p8Ff5JxWsKhgfld4/MRQzZRQ85drMMPhb7Co6Be4ZOgqJX1DzcZt0IFgGTycgqL8rkYNwgnD443Q+TapOoA==",
"requires": {
"@intlify/shared": "11.1.12",
"@intlify/shared": "11.2.2",
"source-map-js": "^1.0.2"
}
},
"@intlify/shared": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.1.12.tgz",
"integrity": "sha512-Om86EjuQtA69hdNj3GQec9ZC0L0vPSAnXzB3gP/gyJ7+mA7t06d9aOAiqMZ+xEOsumGP4eEBlfl8zF2LOTzf2A=="
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-11.2.2.tgz",
"integrity": "sha512-OtCmyFpSXxNu/oET/aN6HtPCbZ01btXVd0f3w00YsHOb13Kverk1jzA2k47pAekM55qbUw421fvPF1yxZ+gicw=="
},
"@jridgewell/sourcemap-codec": {
"version": "1.5.5",
@@ -2970,9 +3035,9 @@
"integrity": "sha512-hW0GwZj06z/ZFUW2Espl7toVDjghJN+EKqyXzPSV8NV89d5BYp5rRMBJoc+aUN0x5OXDMeRQHazejr2Xmqj2tw=="
},
"@rolldown/pluginutils": {
"version": "1.0.0-beta.29",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.29.tgz",
"integrity": "sha512-NIJgOsMjbxAXvoGq/X0gD7VPMQ8j9g0BiDaNjVNVjvl+iKXxL3Jre0v31RmBYeLEmkbj2s02v8vFTbUXi5XS2Q=="
"version": "1.0.0-beta.50",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.50.tgz",
"integrity": "sha512-5e76wQiQVeL1ICOZVUg4LSOVYg9jyhGCin+icYozhsUzM+fHE7kddi1bdiE0jwVqTfkjba3jUFbEkoC9WkdvyA=="
},
"@rollup/rollup-android-arm-eabi": {
"version": "4.45.1",
@@ -3094,6 +3159,15 @@
"integrity": "sha512-M/fKi4sasCdM8i0aWJjCSFm2qEnYRR8AMLG2kxp6wD13+tMGA4Z1tVAuHkNRjud5SW2EM3naLuK35w9twvf6aA==",
"optional": true
},
"@simonwep/pickr": {
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/@simonwep/pickr/-/pickr-1.9.0.tgz",
"integrity": "sha512-oEYvv15PyfZzjoAzvXYt3UyNGwzsrpFxLaZKzkOSd0WYBVwLd19iJerePDONxC1iF6+DpcswPdLIM2KzCJuYFg==",
"requires": {
"core-js": "3.32.2",
"nanopop": "2.3.0"
}
},
"@types/estree": {
"version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
@@ -3104,45 +3178,51 @@
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
"integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
},
"@types/trusted-types": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
"integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==",
"optional": true
},
"@vitejs/plugin-vue": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.1.tgz",
"integrity": "sha512-+MaE752hU0wfPFJEUAIxqw18+20euHHdxVtMvbFcOEpjEyfqXH/5DCoTHiVJ0J29EhTJdoTkjEv5YBKU9dnoTw==",
"version": "6.0.2",
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.2.tgz",
"integrity": "sha512-iHmwV3QcVGGvSC1BG5bZ4z6iwa1SOpAPWmnjOErd4Ske+lZua5K9TtAVdx0gMBClJ28DViCbSmZitjWZsWO3LA==",
"requires": {
"@rolldown/pluginutils": "1.0.0-beta.29"
"@rolldown/pluginutils": "1.0.0-beta.50"
}
},
"@vue/compiler-core": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.24.tgz",
"integrity": "sha512-eDl5H57AOpNakGNAkFDH+y7kTqrQpJkZFXhWZQGyx/5Wh7B1uQYvcWkvZi11BDhscPgj8N7XV3oRwiPnx1Vrig==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.25.tgz",
"integrity": "sha512-vay5/oQJdsNHmliWoZfHPoVZZRmnSWhug0BYT34njkYTPqClh3DNWLkZNJBVSjsNMrg0CCrBfoKkjZQPM/QVUw==",
"requires": {
"@babel/parser": "^7.28.5",
"@vue/shared": "3.5.24",
"@vue/shared": "3.5.25",
"entities": "^4.5.0",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"@vue/compiler-dom": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.24.tgz",
"integrity": "sha512-1QHGAvs53gXkWdd3ZMGYuvQFXHW4ksKWPG8HP8/2BscrbZ0brw183q2oNWjMrSWImYLHxHrx1ItBQr50I/q2zw==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.25.tgz",
"integrity": "sha512-4We0OAcMZsKgYoGlMjzYvaoErltdFI2/25wqanuTu+S4gismOTRTBPi4IASOjxWdzIwrYSjnqONfKvuqkXzE2Q==",
"requires": {
"@vue/compiler-core": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-core": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"@vue/compiler-sfc": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.24.tgz",
"integrity": "sha512-8EG5YPRgmTB+YxYBM3VXy8zHD9SWHUJLIGPhDovo3Z8VOgvP+O7UP5vl0J4BBPWYD9vxtBabzW1EuEZ+Cqs14g==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.25.tgz",
"integrity": "sha512-PUgKp2rn8fFsI++lF2sO7gwO2d9Yj57Utr5yEsDf3GNaQcowCLKL7sf+LvVFvtJDXUp/03+dC6f2+LCv5aK1ag==",
"requires": {
"@babel/parser": "^7.28.5",
"@vue/compiler-core": "3.5.24",
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24",
"@vue/compiler-core": "3.5.25",
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.6",
@@ -3150,12 +3230,12 @@
}
},
"@vue/compiler-ssr": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.24.tgz",
"integrity": "sha512-trOvMWNBMQ/odMRHW7Ae1CdfYx+7MuiQu62Jtu36gMLXcaoqKvAyh+P73sYG9ll+6jLB6QPovqoKGGZROzkFFg==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.25.tgz",
"integrity": "sha512-ritPSKLBcParnsKYi+GNtbdbrIE1mtuFEJ4U1sWeuOMlIziK5GtOL85t5RhsNy4uWIXPgk+OUdpnXiTdzn8o3A==",
"requires": {
"@vue/compiler-dom": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-dom": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"@vue/devtools-api": {
@@ -3164,46 +3244,46 @@
"integrity": "sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g=="
},
"@vue/reactivity": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.24.tgz",
"integrity": "sha512-BM8kBhtlkkbnyl4q+HiF5R5BL0ycDPfihowulm02q3WYp2vxgPcJuZO866qa/0u3idbMntKEtVNuAUp5bw4teg==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.25.tgz",
"integrity": "sha512-5xfAypCQepv4Jog1U4zn8cZIcbKKFka3AgWHEFQeK65OW+Ys4XybP6z2kKgws4YB43KGpqp5D/K3go2UPPunLA==",
"requires": {
"@vue/shared": "3.5.24"
"@vue/shared": "3.5.25"
}
},
"@vue/runtime-core": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.24.tgz",
"integrity": "sha512-RYP/byyKDgNIqfX/gNb2PB55dJmM97jc9wyF3jK7QUInYKypK2exmZMNwnjueWwGceEkP6NChd3D2ZVEp9undQ==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.25.tgz",
"integrity": "sha512-Z751v203YWwYzy460bzsYQISDfPjHTl+6Zzwo/a3CsAf+0ccEjQ8c+0CdX1WsumRTHeywvyUFtW6KvNukT/smA==",
"requires": {
"@vue/reactivity": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/reactivity": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"@vue/runtime-dom": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.24.tgz",
"integrity": "sha512-Z8ANhr/i0XIluonHVjbUkjvn+CyrxbXRIxR7wn7+X7xlcb7dJsfITZbkVOeJZdP8VZwfrWRsWdShH6pngMxRjw==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.25.tgz",
"integrity": "sha512-a4WrkYFbb19i9pjkz38zJBg8wa/rboNERq3+hRRb0dHiJh13c+6kAbgqCPfMaJ2gg4weWD3APZswASOfmKwamA==",
"requires": {
"@vue/reactivity": "3.5.24",
"@vue/runtime-core": "3.5.24",
"@vue/shared": "3.5.24",
"@vue/reactivity": "3.5.25",
"@vue/runtime-core": "3.5.25",
"@vue/shared": "3.5.25",
"csstype": "^3.1.3"
}
},
"@vue/server-renderer": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.24.tgz",
"integrity": "sha512-Yh2j2Y4G/0/4z/xJ1Bad4mxaAk++C2v4kaa8oSYTMJBJ00/ndPuxCnWeot0/7/qafQFLh5pr6xeV6SdMcE/G1w==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.25.tgz",
"integrity": "sha512-UJaXR54vMG61i8XNIzTSf2Q7MOqZHpp8+x3XLGtE3+fL+nQd+k7O5+X3D/uWrnQXOdMw5VPih+Uremcw+u1woQ==",
"requires": {
"@vue/compiler-ssr": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-ssr": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"@vue/shared": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.24.tgz",
"integrity": "sha512-9cwHL2EsJBdi8NY22pngYYWzkTDhld6fAD6jlaeloNGciNSJL6bLpbxVgXl96X00Jtc6YWQv96YA/0sxex/k1A=="
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.25.tgz",
"integrity": "sha512-AbOPdQQnAnzs58H2FrrDxYj/TJfmeS2jdfEEhgiKINy+bnOANmVizIEgq1r+C5zsbs6l1CCQxtcj71rwNQ4jWg=="
},
"@xterm/addon-attach": {
"version": "0.11.0",
@@ -3245,9 +3325,9 @@
}
},
"anser": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.2.tgz",
"integrity": "sha512-PMqBCBvrOVDRqLGooQb+z+t1Q0PiPyurUQeZRR5uHBOVZcW8B04KMmnT12USnhpNX2wCPagWzLVppQMUG3u0Dw=="
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/anser/-/anser-2.3.3.tgz",
"integrity": "sha512-QGY1oxYE7/kkeNmbtY/2ZjQ07BCG3zYdz+k/+sf69kMzEIxb93guHkPnIXITQ+BYi61oQwG74twMOX1tD4aesg=="
},
"ansi-styles": {
"version": "4.3.0",
@@ -3340,6 +3420,11 @@
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
"integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg=="
},
"core-js": {
"version": "3.32.2",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-3.32.2.tgz",
"integrity": "sha512-pxXSw1mYZPDGvTQqEc5vgIb83jGQKFGYWY76z4a7weZXUolw3G+OvpZqSRcfYOoOVUQJYEPsWeQK8pKEnUtWxQ=="
},
"cross-spawn": {
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
@@ -3356,9 +3441,9 @@
"integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg=="
},
"csstype": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="
},
"debug": {
"version": "4.4.0",
@@ -3374,9 +3459,12 @@
"integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ=="
},
"dompurify": {
"version": "3.1.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.1.7.tgz",
"integrity": "sha512-VaTstWtsneJY8xzy7DekmYWEOZcmzIe3Qb3zPd4STve1OBTa+e+WmS1ITQec1fZYXI3HCsOZZiSMpG6oxoWMWQ=="
"version": "3.2.7",
"resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz",
"integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==",
"requires": {
"@types/trusted-types": "^2.0.7"
}
},
"entities": {
"version": "4.5.0",
@@ -3486,14 +3574,14 @@
}
},
"eslint-plugin-vue": {
"version": "10.5.1",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.5.1.tgz",
"integrity": "sha512-SbR9ZBUFKgvWAbq3RrdCtWaW0IKm6wwUiApxf3BVTNfqUIo4IQQmreMg2iHFJJ6C/0wss3LXURBJ1OwS/MhFcQ==",
"version": "10.6.2",
"resolved": "https://registry.npmjs.org/eslint-plugin-vue/-/eslint-plugin-vue-10.6.2.tgz",
"integrity": "sha512-nA5yUs/B1KmKzvC42fyD0+l9Yd+LtEpVhWRbXuDj0e+ZURcTtyRbMDWUeJmTAh2wC6jC83raS63anNM2YT3NPw==",
"requires": {
"@eslint-community/eslint-utils": "^4.4.0",
"natural-compare": "^1.4.0",
"nth-check": "^2.1.1",
"postcss-selector-parser": "^6.0.15",
"postcss-selector-parser": "^7.1.0",
"semver": "^7.6.3",
"xml-name-validator": "^4.0.0"
}
@@ -3563,6 +3651,11 @@
"resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz",
"integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw=="
},
"fflate": {
"version": "0.8.2",
"resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.2.tgz",
"integrity": "sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A=="
},
"file-entry-cache": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz",
@@ -3724,9 +3817,9 @@
}
},
"marked": {
"version": "17.0.0",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.0.tgz",
"integrity": "sha512-KkDYEWEEiYJw/KC+DVm1zzlpMQSMIu6YRltkcCvwheCp8HWPXCk9JwOmHJKBlGfzcpzcIt6x3sMnTsRm/51oDg=="
"version": "17.0.1",
"resolved": "https://registry.npmjs.org/marked/-/marked-17.0.1.tgz",
"integrity": "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="
},
"micromatch": {
"version": "4.0.8",
@@ -3759,11 +3852,11 @@
}
},
"monaco-editor": {
"version": "0.54.0",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.54.0.tgz",
"integrity": "sha512-hx45SEUoLatgWxHKCmlLJH81xBo0uXP4sRkESUpmDQevfi+e7K1VuiSprK6UpQ8u4zOcKNiH0pMvHvlMWA/4cw==",
"version": "0.55.1",
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz",
"integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==",
"requires": {
"dompurify": "3.1.7",
"dompurify": "3.2.7",
"marked": "14.0.0"
},
"dependencies": {
@@ -3784,6 +3877,11 @@
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="
},
"nanopop": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/nanopop/-/nanopop-2.3.0.tgz",
"integrity": "sha512-fzN+T2K7/Ah25XU02MJkPZ5q4Tj5FpjmIYq4rvoHX4yb16HzFdCO6JxFFn5Y/oBhQ8no8fUZavnyIv9/+xkBBw=="
},
"natural-compare": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz",
@@ -3797,6 +3895,16 @@
"boolbase": "^1.0.0"
}
},
"online-3d-viewer": {
"version": "0.18.0",
"resolved": "https://registry.npmjs.org/online-3d-viewer/-/online-3d-viewer-0.18.0.tgz",
"integrity": "sha512-y7ZlV/zkakNUyjqcXz6XecA7vXgLEUnaAey9tyx8o6/wcdV64RfjXAQOjGXGY2JOZoDi4Cg1ic9icSWMWAvRQA==",
"requires": {
"@simonwep/pickr": "1.9.0",
"fflate": "0.8.2",
"three": "0.176.0"
}
},
"optionator": {
"version": "0.9.4",
"resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz",
@@ -3867,9 +3975,9 @@
}
},
"postcss-selector-parser": {
"version": "6.1.2",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz",
"integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==",
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.0.tgz",
"integrity": "sha512-8sLjZwK0R+JlxlYcTuVnyT2v+htpdrjDOKuMcOVdYjt52Lh8hWRYpxBPoKx/Zg+bcjc3wx6fmQevMmUztS/ccA==",
"requires": {
"cssesc": "^3.0.0",
"util-deprecate": "^1.0.2"
@@ -3955,6 +4063,11 @@
"has-flag": "^4.0.0"
}
},
"three": {
"version": "0.176.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.176.0.tgz",
"integrity": "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA=="
},
"tinyglobby": {
"version": "0.2.15",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
@@ -4004,12 +4117,12 @@
"util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8="
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
},
"vite": {
"version": "7.2.2",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.2.tgz",
"integrity": "sha512-BxAKBWmIbrDgrokdGZH1IgkIk/5mMHDreLDmCJ0qpyJaAteP8NvMhkwr/ZCQNqNH97bw/dANTE9PDzqwJghfMQ==",
"version": "7.2.7",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.2.7.tgz",
"integrity": "sha512-ITcnkFeR3+fI8P1wMgItjGrR10170d8auB4EpMLPqmx6uxElH3a/hHGQabSHKdqd4FXWO1nFIp9rRn7JQ34ACQ==",
"requires": {
"esbuild": "^0.25.0",
"fdir": "^6.5.0",
@@ -4042,15 +4155,15 @@
}
},
"vue": {
"version": "3.5.24",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.24.tgz",
"integrity": "sha512-uTHDOpVQTMjcGgrqFPSb8iO2m1DUvo+WbGqoXQz8Y1CeBYQ0FXf2z1gLRaBtHjlRz7zZUBHxjVB5VTLzYkvftg==",
"version": "3.5.25",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.25.tgz",
"integrity": "sha512-YLVdgv2K13WJ6n+kD5owehKtEXwdwXuj2TTyJMsO7pSeKw2bfRNZGjhB7YzrpbMYj5b5QsUebHpOqR3R3ziy/g==",
"requires": {
"@vue/compiler-dom": "3.5.24",
"@vue/compiler-sfc": "3.5.24",
"@vue/runtime-dom": "3.5.24",
"@vue/server-renderer": "3.5.24",
"@vue/shared": "3.5.24"
"@vue/compiler-dom": "3.5.25",
"@vue/compiler-sfc": "3.5.25",
"@vue/runtime-dom": "3.5.25",
"@vue/server-renderer": "3.5.25",
"@vue/shared": "3.5.25"
}
},
"vue-eslint-parser": {
@@ -4069,12 +4182,12 @@
}
},
"vue-i18n": {
"version": "11.1.12",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.1.12.tgz",
"integrity": "sha512-BnstPj3KLHLrsqbVU2UOrPmr0+Mv11bsUZG0PyCOzsawCivk8W00GMXHeVUWIDOgNaScCuZah47CZFE+Wnl8mw==",
"version": "11.2.2",
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-11.2.2.tgz",
"integrity": "sha512-ULIKZyRluUPRCZmihVgUvpq8hJTtOqnbGZuv4Lz+byEKZq4mU0g92og414l6f/4ju+L5mORsiUuEPYrAuX2NJg==",
"requires": {
"@intlify/core-base": "11.1.12",
"@intlify/shared": "11.1.12",
"@intlify/core-base": "11.2.2",
"@intlify/shared": "11.2.2",
"@vue/devtools-api": "^6.5.0"
}
},
+8 -8
View File
@@ -7,26 +7,26 @@
},
"type": "module",
"dependencies": {
"@cloudron/pankow": "^3.5.9",
"@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.39.1",
"eslint-plugin-vue": "^10.5.1",
"marked": "^17.0.0",
"eslint-plugin-vue": "^10.6.2",
"marked": "^17.0.1",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"vite": "^7.2.2",
"vite": "^7.2.7",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.24",
"vue-i18n": "^11.1.12",
"vue": "^3.5.25",
"vue-i18n": "^11.2.2",
"vue-router": "^4.6.3"
}
}
+5 -4
View File
@@ -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>
+7 -6
View File
@@ -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>
+2 -17
View File
@@ -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",
@@ -328,7 +324,6 @@
"backupNow": "Backup nu"
},
"backupDetails": {
"list": "Referencer til sikkerhedskopier af {{ appCount }} apps",
"title": "Oplysninger om sikkerhedskopiering",
"id": "Id",
"date": "Dato",
@@ -586,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"
@@ -631,7 +625,6 @@
},
"domainDialog": {
"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",
@@ -697,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",
@@ -1050,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",
@@ -1155,8 +1143,7 @@
"saveAction": "Gem"
},
"robots": {
"title": "Robots.txt",
"disableIndexingAction": "Deaktivere indeksering"
"title": "Robots.txt"
},
"hstsPreload": "Aktiver HSTS-forudindlæsning for dette websted og alle underdomæner"
},
@@ -1239,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",
@@ -1262,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 %>"
},
+204 -167
View File
@@ -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"
},
@@ -384,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",
@@ -401,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": {
@@ -414,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",
@@ -422,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 &amp; 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."
@@ -458,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",
@@ -472,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",
@@ -484,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": {
@@ -508,89 +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",
"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",
@@ -646,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",
@@ -666,14 +687,14 @@
"title": "Backup-Speicher konfigurieren",
"encryptionPasswordRepeat": "Password wiederholen",
"encryptionPasswordPlaceholder": "Zur Verschlüsselung der Sicherungen verwendete Passphrase",
"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",
@@ -682,39 +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",
"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",
@@ -740,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"
@@ -750,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": {
@@ -792,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",
@@ -814,14 +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"
"enableRecoveryMode": "Wiederherstellungsmodus aktivieren",
"description": "Dienst \"{{ name }}\" konfigurieren"
},
"restartActionTooltip": "Neustart"
},
@@ -848,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 %>,"
@@ -864,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": {
@@ -878,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",
@@ -894,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",
@@ -905,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",
@@ -918,7 +953,7 @@
"incomingPasswordUsage": "Passwort des Besitzers der Mailbox",
"incomingPasswordInfo": "Passwort",
"incomingUserInfo": "Benutzername",
"description": "Eingehende E-Mails für diese Domäne empfangen."
"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.",
@@ -927,12 +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.",
"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",
@@ -946,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 }}",
@@ -963,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": {
@@ -1028,7 +1069,7 @@
"reallyDelete": "Wirklich löschen?"
},
"newDirectoryDialog": {
"title": "Neuer Ordner",
"title": "Neuer Ordnername",
"create": "Erstellen"
},
"toolbar": {
@@ -1148,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": {
@@ -1169,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",
@@ -1199,8 +1242,7 @@
"title": "Content-Security-Policy"
},
"robots": {
"title": "robots.txt",
"disableIndexingAction": "Indexierung deaktivieren"
"title": "robots.txt"
},
"hstsPreload": "Aktivieren Sie den HSTS-Preload für diese Website und alle Subdomains"
},
@@ -1282,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",
@@ -1343,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."
@@ -1592,10 +1633,6 @@
"dashboard": {
"title": "Dashboard"
},
"externallinks": {
"label": "Externe Links",
"description": "Verknüpfungen zu externen Diensten im Dashboard hinzufügen"
},
"server": {
"title": "Server"
}
+87 -61
View File
@@ -20,7 +20,7 @@
},
"main": {
"offline": "Cloudron is offline. Reconnecting…",
"logout": "Logout",
"logout": "Log out",
"dialog": {
"cancel": "Cancel",
"save": "Save",
@@ -35,8 +35,8 @@
"displayName": "Display name",
"actions": "Actions",
"table": {
"date": "Date",
"version": "Version"
"version": "Version",
"created": "Created"
},
"action": {
"reboot": "Reboot",
@@ -45,7 +45,9 @@
"edit": "Edit",
"add": "Add",
"next": "Next",
"configure": "Configure"
"configure": "Configure",
"restart": "Restart",
"reset": "Reset"
},
"rebootDialog": {
"title": "Reboot Server",
@@ -62,7 +64,13 @@
"groups": "Groups"
},
"statusEnabled": "Enabled",
"loadingPlaceholder": "Loading"
"loadingPlaceholder": "Loading",
"platform": {
"startupFailed": "Platform startup failed"
},
"sidebar": {
"collapseAction": "Collapse sidebar"
}
},
"appstore": {
"title": "App Store",
@@ -113,13 +121,13 @@
"setGhostTooltip": "Impersonate",
"mailmanagerTooltip": "This user can manage users and mailboxes",
"noMatchesPlaceholder": "No matching user",
"emptyPlaceholder": "No Users"
"emptyPlaceholder": "No users"
},
"groups": {
"name": "Name",
"users": "Users",
"externalLdapTooltip": "From external LDAP directory",
"emptyPlaceholder": "No Groups",
"emptyPlaceholder": "No groups",
"noMatchesPlaceholder": "No matching group"
},
"settings": {
@@ -131,7 +139,7 @@
"externalLdap": {
"title": "Connect an External Directory",
"description": "Synchronize and authenticate users and groups from an external LDAP or Active Directory server. Synchronization runs periodically every 4 hours.",
"noopInfo": "No external directory configured.",
"noopInfo": "No external directory configured",
"provider": "Provider",
"server": "Server URL",
"acceptSelfSignedCert": "Accept self-signed certificate",
@@ -161,13 +169,13 @@
"username": "Username",
"role": "Role",
"groups": "Groups",
"noGroups": "No groups available.",
"usernamePlaceholder": "Optional. If not provided, user can pick during sign up",
"noGroups": "No groups available",
"usernamePlaceholder": "Optional. If not provided, user can pick during sign up.",
"displayName": "Display name",
"primaryEmail": "Primary email",
"recoveryEmail": "Password recovery email",
"activeCheckbox": "User is active",
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up",
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up.",
"fallbackEmailPlaceholder": "If not specified, primary email will be used"
},
"deleteUserDialog": {
@@ -251,7 +259,11 @@
"title": "LDAP Server",
"enabled": "Enable LDAP server"
},
"title": "Users"
"title": "Users",
"2FAResetDialog": {
"title": "Reset User 2FA",
"description": "Remove the existing 2FA setup for user “{{ username }}”?"
}
},
"profile": {
"title": "Profile",
@@ -272,10 +284,10 @@
},
"enable2FA": {
"title": "Enable Two-Factor Authentication",
"description": "Your Cloudron Administrator has required all members to enable two-factor authentication. You will be unable to access the dashboard until you enable 2FA.",
"authenticatorAppDescription": "Use 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>) or a similar TOTP app to scan the secret.",
"token": "Token",
"enable": "Enable"
"enable": "Enable",
"mandatorySetup": "2FA is required to access the dashboard. Please complete the setup to continue."
},
"appPasswords": {
"title": "App Passwords",
@@ -289,7 +301,7 @@
"name": "Name",
"description": "Use these personal access tokens to authenticate with the <a target=\"_blank\" href=\"{{ apiDocsLink }}\">Cloudron API</a>.",
"noTokensPlaceholder": "No API tokens",
"lastUsed": "Last Used",
"lastUsed": "Last used",
"neverUsed": "never",
"scope": "Scope",
"readonly": "Readonly",
@@ -300,7 +312,7 @@
"loginTokens": {
"title": "Login Tokens",
"description": "You have {{ webadminTokenCount}} active web token(s) and {{ cliTokenCount }} CLI token(s).",
"logoutAll": "Logout from all"
"logoutAll": "Log out from all"
},
"changeEmail": {
"title": "Change Primary Email",
@@ -355,21 +367,21 @@
"noBackups": "No backups",
"contents": "Contents",
"version": "Version",
"noApps": "No Apps",
"noApps": "No apps",
"appCount": "{{ appCount }} App(s)",
"tooltipDownloadBackupConfig": "Download config",
"cleanupBackups": "Cleanup backups",
"backupNow": "Backup now",
"tooltipPreservedBackup": "This backup will be preserved"
"tooltipPreservedBackup": "This backup will be preserved",
"description": "System backups contain Cloudron configuration and app installation metadata. They can be used to <a href=\"{{restoreLink}}\" target=\"_blank\">restore</a> or <a href=\"{{migrateLink}}\" target=\"_blank\">migrate</a> the entire Cloudron installation to another server."
},
"backupDetails": {
"title": "Backup Details",
"id": "Id",
"date": "Date",
"version": "Version",
"list": "References backups of {{ appCount }} app(s)",
"id": "Backup ID",
"date": "Created",
"version": "Package version",
"size": "Size",
"duration": "Duration"
"duration": "Backup duration"
},
"configureBackupSchedule": {
"title": "Configure Backup Schedule & Retention",
@@ -443,7 +455,9 @@
"title": "Backups of automatic updates",
"description": "A backup is always created before automatic updates. Select this option to store those backups on this site."
},
"useEncryption": "Encrypt backups"
"useEncryption": "Encrypt backups",
"regionHelperText": "Defaults to \"us-east-1\" if left empty",
"prefixHelperText": "Backups are stored inside this subfolder"
},
"backupEdit": {
"title": "Edit Backup",
@@ -560,8 +574,8 @@
"customRulesPlaceholder": "Custom Spamassassin Rules"
},
"testMailDialog": {
"title": "Send test email for {{ domain }}",
"description": "This will send a test email from <b>no-reply@{{ domain }}</b> to the address below.",
"title": "Send test email",
"description": "Sends a test email from <b>no-reply@{{ domain }}</b> to the specified address.",
"sendAction": "Send"
},
"solrConfig": {
@@ -652,7 +666,6 @@
"setupAction": "Set up account",
"subscription": "Subscription",
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Canceled and ends on",
"subscriptionChangeAction": "Manage subscription",
"subscriptionReactivateAction": "Reactivate Subscription",
"emailNotVerified": "Email not yet verified",
@@ -788,7 +801,7 @@
"wildcardInfo": "Manually set up A (IPv4) and AAAA (IPv6) DNS records for <b>*.{{ domain }}.</b> and <b>{{ domain }}.</b> pointing to this server",
"letsEncryptInfo": "Let's Encrypt requires your server to be reachable on port 80",
"advancedAction": "Advanced settings…",
"zoneName": "Zone name (optional)",
"zoneName": "Zone name",
"fallbackCert": "Fallback Certificate (optional)",
"fallbackCertCustomCert": "Custom Certificate",
"fallbackCertCustomCertInfo": "Provide a <a href=\"{{ customCertLink }}\" target=\"_blank\">wildcard certificate</a> to use for all apps on this domain. If not provided, a self-signed certificate is automatically generated.",
@@ -800,7 +813,6 @@
"netcupApiKey": "Netcup API key",
"netcupApiPassword": "Netcup API password",
"vultrToken": "Vultr token",
"wellKnownDescription": "The values will be used to respond to <code>https://{{ domain }}/.well-known/</code> URLs. Note that an app must be available on the bare domain <code>{{ domain }}</code> for this to work. See the <a href=\"{{docsLink}}\" target=\"_blank\">docs</a> for more information.",
"jitsiHostname": "Jitsi location",
"hetznerToken": "Hetzner token",
"cloudflareDefaultProxyStatus": "Enable proxying for new DNS records",
@@ -818,7 +830,8 @@
"gandiTokenTypePAT": "Personal Access Token (PAT)",
"inwxUsername": "INWX username",
"inwxPassword": "INWX password",
"customNameservers": "Domain uses custom (vanity) nameservers"
"customNameservers": "Domain uses custom (vanity) nameservers",
"zoneNamePlaceholder": "Optional. If not provided, defaults to the root domain."
},
"removeDialog": {
"title": "Remove Domain",
@@ -830,13 +843,15 @@
"description": "Updates app and email DNS records for all domains.",
"syncAction": "Sync DNS"
},
"domainWellKnown": {
"title": "Well-Known Locations of {{ domain }}"
},
"tooltipWellKnown": "Well-Known locations",
"emptyPlaceholder": "No Domains",
"noMatchesPlaceholder": "No matching domain",
"description": "Adding a domain allows you to install apps on its subdomains."
"description": "Adding a domain allows you to install apps on its subdomains.",
"wellknown": {
"editAction": "Well-known URIs",
"title": "Well-known URIs",
"context": "Configure responses to \"https://{{ domain }}/.well-known/\" URLs",
"description": "This feature requires an app installed on the root domain \"{{ domain }}\". See the <a href=\"{{docsLink}}\" target=\"_blank\">documentation</a> for details."
}
},
"notifications": {
"dismissTooltip": "Dismiss",
@@ -997,7 +1012,7 @@
"owner": "Owner",
"aliases": "Aliases",
"usage": "Usage",
"emptyPlaceholder": "No Mailboxes",
"emptyPlaceholder": "No mailboxes",
"noMatchesPlaceholder": "No matching mailboxes",
"stats": "Count: {{ mailboxCount }} / Usage: {{ usage }}"
},
@@ -1084,7 +1099,7 @@
"title": "Edit Mailbox",
"owner": "Mailbox owner",
"aliases": "Aliases",
"noAliases": "No aliases.",
"noAliases": "No aliases",
"addAliasAction": "Add an alias",
"addAnotherAliasAction": "Add another alias",
"enableStorageQuota": "Storage quota"
@@ -1165,7 +1180,7 @@
},
"accessControl": {
"userManagement": {
"description": "Configure who can log in and use the app.",
"description": "Configure who can log in and use the app",
"descriptionSftp": "This setting also controls SFTP access.",
"dashboardVisibility": "Dashboard visibility",
"visibleForAllUsers": "Visible to all users on this Cloudron",
@@ -1179,7 +1194,7 @@
},
"operators": {
"title": "Operators",
"description": "Operators can configure and maintain this app."
"description": "Configure who can maintain the app"
},
"dashboardVisibility": {
"description": "Configure who can see this app on the dashboard."
@@ -1258,14 +1273,29 @@
},
"security": {
"csp": {
"description": "Override any CSP headers defined by the app.",
"description": "Override any CSP headers defined by the app",
"title": "Content Security Policy",
"saveAction": "Save"
"saveAction": "Save",
"insertCommonCsp": "Insert common CSP",
"commonPattern": {
"allowEmbedding": "Allow embedding",
"sameOriginEmbedding": "Allow embedding (only subdomains)",
"allowCdnAssets": "Allow CDN assets",
"reportOnly": "Report CSP violations",
"strictBaseline": "Strict baseline"
}
},
"robots": {
"title": "Robots.txt",
"disableIndexingAction": "Disable indexing",
"description": "By default, bots can index this app."
"description": "By default, bots can index this app",
"commonPattern": {
"allowAll": "Allow all (default)",
"disallowAll": "Disallow all",
"disallowCommonBots": "Disallow common bots",
"disallowAdminPaths": "Disallow admin paths",
"disallowApiPaths": "Disallow API paths"
},
"insertCommonRobotsTxt": "Insert common robots.txt"
},
"hstsPreload": "Enable HSTS Preload (including subdomains)"
},
@@ -1276,7 +1306,7 @@
"packageVersion": "Package version",
"lastUpdated": "Last updated",
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
"installedAt": "Installed at"
"installedAt": "Installed"
},
"auto": {
"description": "App updates are applied periodically based on the <a href=\"/#/system-update\">update schedule</a>",
@@ -1289,8 +1319,7 @@
"backups": {
"backups": {
"title": "Backups",
"description": "Create a complete snapshot of the app.",
"time": "Created at",
"description": "Create a complete snapshot of the app",
"downloadConfigTooltip": "Download config",
"cloneTooltip": "Clone",
"restoreTooltip": "Restore",
@@ -1301,7 +1330,7 @@
},
"import": {
"title": "Import",
"description": "Import the app from an external backup."
"description": "Import app from an external backup"
},
"auto": {
"title": "Automatic backups",
@@ -1387,7 +1416,7 @@
"cron": {
"title": "Crontab",
"saveAction": "Save",
"addCommonPattern": "Add common pattern",
"addCommonPattern": "Insert common pattern",
"commonPattern": {
"everyMinute": "Every Minute",
"everyHour": "Every Hour",
@@ -1473,11 +1502,10 @@
},
"success": {
"title": "Password changed",
"openDashboardAction": "Open Dashboard"
"openDashboardAction": "Open dashboard"
}
},
"setupAccount": {
"welcomeTo": "Welcome to",
"description": "Please set up your account",
"username": "Username",
"fullName": "Full name",
@@ -1492,19 +1520,19 @@
},
"success": {
"title": "Your account is ready",
"openDashboardAction": "Open Dashboard"
"openDashboardAction": "Open dashboard"
},
"noUsername": {
"title": "Cannot set up account",
"description": "Account cannot be set up without a username."
}
"description": "Account cannot be set up without a username. Please contact the administrator."
},
"welcome": "Welcome"
},
"welcomeEmail": {
"welcomeTo": "Welcome to <%= cloudronName %>!",
"salutation": "Hi <%= user %>,",
"inviteLinkAction": "Get started",
"invitor": "You are receiving this email because you were invited by <%= invitor %>.",
"expireNote": "Please note that the invite link will expire in 7 days.",
"inviteLinkActionText": "Follow the link to get started: <%- inviteLink %>",
"subject": "Welcome to <%= cloudron %>"
},
@@ -1587,7 +1615,7 @@
},
"clientCredentials": {
"title": "Client credentials",
"description": "Copy the credentials for client \"{{ clientName }}\"."
"description": "Copy the credentials for client \"{{ clientName }}\""
}
},
"userdirectory": {
@@ -1598,7 +1626,8 @@
"archives": {
"listing": {
"placeholder": "No archived apps"
}
},
"description": "Archived apps preserve the latest backup when an app was archived. These backups are kept permanently and can be restored."
},
"backup": {
"target": {
@@ -1609,7 +1638,8 @@
"sites": {
"title": "Backup Sites",
"emptyPlaceholder": "No backup sites",
"lastRun": "Last run"
"lastRun": "Last run",
"description": "Backup sites specify where system and app backups are stored. App backups can be restored individually."
},
"site": {
"removeDialog": {
@@ -1646,10 +1676,6 @@
"dashboard": {
"title": "Dashboard"
},
"externallinks": {
"label": "External links",
"description": "Add shortcuts to external services on the dashboard"
},
"server": {
"title": "Server"
}
-17
View File
@@ -48,7 +48,6 @@
"next": "Siguiente"
},
"table": {
"date": "Fecha",
"version": "Versión"
},
"actions": "Acciones",
@@ -339,7 +338,6 @@
"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",
@@ -445,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": {
@@ -632,7 +629,6 @@
"settings": {
"appstoreAccount": {
"title": "Cuenta Cloudron.io",
"subscriptionEndsAt": "Cancelado y finaliza el",
"subscriptionReactivateAction": "Reactivar Suscripción",
"setupAction": "Configurar cuenta",
"subscription": "Suscripción",
@@ -735,7 +731,6 @@
"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": "Los valores se usarán para responder a las URL <code>https://{{ domain }}/.well-known/</code>. Ten en cuenta que una aplicación debe estar disponible en el dominio <code>{{ domain }}</code> para que esto funcione. Consulta la <a href=\"{{docsLink}}\" target=\"_blank\">documentación</a> para obtener más información.",
"hetznerToken": "Token de Hetzner",
"bunnyAccessKey": "Clave de acceso Bunny",
"cloudflareDefaultProxyStatus": "Habilitar el Proxy para nuevos Registros DNS",
@@ -770,10 +765,6 @@
"title": "Realmente quieres borrar {{ domain }}?",
"removeAction": "Borrar"
},
"domainWellKnown": {
"title": "Ubicaciones Well-known de {{ domain }}"
},
"tooltipWellKnown": "Ubicaciones conocidas",
"emptyPlaceholder": "Sin Dominios",
"noMatchesPlaceholder": "No coincide ningún dominio"
},
@@ -845,7 +836,6 @@
"restoreTooltip": "Restaurar",
"cloneTooltip": "Clonar",
"downloadConfigTooltip": "Descargar configuración",
"time": "Creado en",
"title": "Backups",
"description": "Las copias de seguridad son instantáneas completas de la aplicación. Puede utilizar copias de seguridad de la aplicación para restaurar o clonar esta aplicación.",
"downloadBackupTooltip": "Descargar",
@@ -862,7 +852,6 @@
},
"security": {
"robots": {
"disableIndexingAction": "Desactivar indexado",
"title": "Robots.txt"
},
"csp": {
@@ -1470,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",
@@ -1490,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%>.",
@@ -1594,10 +1581,6 @@
"dashboard": {
"title": "Panel"
},
"externallinks": {
"label": "Enlaces externos",
"description": "Agrega accesos directos a servicios externos en el panel de control."
},
"server": {
"title": "Servidor"
},
+2 -17
View File
@@ -31,9 +31,6 @@
"username": "Nom d'utilisateur",
"actions": "Actions",
"displayName": "Nom affiché",
"table": {
"date": "Date"
},
"action": {
"logs": "Journaux",
"reboot": "Redémarrer"
@@ -237,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",
@@ -336,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",
@@ -496,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",
@@ -623,7 +617,6 @@
"title": "Politique de sécurité du contenu (CSP)"
},
"robots": {
"disableIndexingAction": "Désactiver l'indexation",
"title": "Robots.txt"
},
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
@@ -743,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"
@@ -1048,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",
@@ -1077,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": {
@@ -1101,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 %>",
@@ -1295,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
+2 -12
View File
@@ -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,8 +131,7 @@
},
"security": {
"robots": {
"title": "Robots.txt",
"disableIndexingAction": "Disabilita indicizzazione"
"title": "Robots.txt"
},
"csp": {
"saveAction": "Salva",
@@ -209,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"
}
@@ -548,7 +541,6 @@
"title": "Configura pianificazione e conservazione backup"
},
"backupDetails": {
"list": "Riferimenti ai bakcup di {{ appCount }} applicazioni",
"version": "Versione",
"date": "Data",
"title": "Dettagli Backup",
@@ -616,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"
},
@@ -816,7 +807,6 @@
"appstoreAccount": {
"subscriptionReactivateAction": "Riattiva Abbonamento",
"subscriptionChangeAction": "Cambia Abbonamento",
"subscriptionEndsAt": "Annullato e termina il",
"cloudronId": "ID Cloudron",
"subscription": "Abbonamento",
"setupAction": "Imposta Account",
-3
View File
@@ -7,9 +7,6 @@
"logs": "ログ",
"reboot": "再起動"
},
"table": {
"date": "日付"
},
"displayName": "表示名",
"username": "ユーザー名",
"dialog": {
+82 -56
View File
@@ -34,8 +34,8 @@
"displayName": "Weergavenaam",
"actions": "Acties",
"table": {
"date": "Datum",
"version": "Versie"
"version": "Versie",
"created": "Aangemaakt"
},
"action": {
"reboot": "Herstart",
@@ -44,7 +44,9 @@
"edit": "Bewerk",
"add": "Toevoegen",
"next": "Volgende",
"configure": "Configureer"
"configure": "Configureer",
"restart": "Herstart",
"reset": "Reset"
},
"rebootDialog": {
"title": "Herstart Server",
@@ -62,7 +64,13 @@
"groups": "Groepen"
},
"statusEnabled": "Ingeschakeld",
"loadingPlaceholder": "Laden"
"loadingPlaceholder": "Laden",
"platform": {
"startupFailed": "Platformstart mislukt"
},
"sidebar": {
"collapseAction": "Zijbalk inklappen"
}
},
"appstore": {
"title": "App Store",
@@ -130,7 +138,7 @@
},
"externalLdap": {
"title": "Verbind met een externe lijst",
"noopInfo": "Geen externe directory geconfigureerd.",
"noopInfo": "Geen externe directory geconfigureerd",
"provider": "Aanbieder",
"acceptSelfSignedCert": "Accepteer zelf-ondertekend certificaat",
"baseDn": "Base DN",
@@ -161,14 +169,14 @@
"username": "Gebruikersnaam",
"role": "Rol",
"groups": "Groepen",
"noGroups": "Geen groepen beschikbaar.",
"noGroups": "Geen groepen beschikbaar",
"displayName": "Weergavenaam",
"primaryEmail": "Primair e-mailadres",
"recoveryEmail": "Wachtwoordherstel e-mailadres",
"activeCheckbox": "Gebruiker is actief",
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen.",
"fallbackEmailPlaceholder": "Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding"
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding."
},
"deleteUserDialog": {
"deleteAction": "Verwijder",
@@ -251,7 +259,11 @@
"title": "LDAP Server",
"enabled": "LDAP server inschakelen"
},
"title": "Gebruikers"
"title": "Gebruikers",
"2FAResetDialog": {
"title": "Reset Gebruiker 2FA",
"description": "Verwijder de bestaande 2FA voor gebruiker “{{ username }}”?"
}
},
"profile": {
"title": "Profiel",
@@ -274,8 +286,8 @@
"token": "Token",
"enable": "Inschakelen",
"title": "Schakel Twee-Factor (2FA) authenticatie in",
"description": "Jouw Cloudron Administrator heeft Twee-Factor (2FA) authenticatie voor alle gebruikers verplicht gesteld. Schakel jouw Twee-Factor (2FA) authenticatie in.",
"authenticatorAppDescription": "Gebruik 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>) of vergelijkbare Twee-Factor (2FA) authenticatie app om de QR-code te scannen."
"authenticatorAppDescription": "Gebruik 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>) of vergelijkbare Twee-Factor (2FA) authenticatie app om de QR-code te scannen.",
"mandatorySetup": "2FA is noodzakelijk voor toegang tot het dashboard. Vervolg het instellen om door te gaan."
},
"appPasswords": {
"app": "App",
@@ -355,21 +367,21 @@
"noBackups": "Geen backups",
"contents": "Inhoud",
"version": "Versie",
"noApps": "Geen Apps",
"noApps": "Geen apps",
"cleanupBackups": "Backups opschonen",
"backupNow": "Backup maken",
"appCount": "{{ appCount }} App(s)",
"tooltipDownloadBackupConfig": "Download configuratie",
"tooltipPreservedBackup": "Deze backup blijft behouden"
"tooltipPreservedBackup": "Deze backup blijft behouden",
"description": "Systeembackups bevatten Cloudron-configuratie en metadata van app-installaties. Ze kunnen worden gebruikt om te <a href=\"{{restoreLink}}\" target=\"_blank\">herstellen</a> of te <a href=\"{{migrateLink}}\" target=\"_blank\">migreren</a> van de volledige Cloudron-installatie naar een andere server."
},
"backupDetails": {
"title": "Backup Details",
"id": "Id",
"date": "Datum",
"version": "Versie",
"list": "Verwijst naar backups van {{ appCount }} app(s)",
"id": "Backup ID",
"date": "Aangemaakt",
"version": "Package versie",
"size": "Grootte",
"duration": "Duur"
"duration": "Backup duur"
},
"configureBackupSchedule": {
"title": "Configureer Backup Planning & Bewaartermijn",
@@ -443,7 +455,9 @@
"title": "Backups van automatische updates",
"description": "Vóór automatische updates wordt altijd een back-up gemaakt. Kies deze optie indien je die back-ups op deze locatie wilt opslaan."
},
"useEncryption": "Encrypt backups"
"useEncryption": "Encrypt backups",
"regionHelperText": "Standaardwaarde is \"us-east-1\" als deze leeg is",
"prefixHelperText": "Backups worden opgeslagen in deze sub-map"
},
"backupEdit": {
"preserved": {
@@ -560,8 +574,8 @@
"blacklisteAddressesInfo": "Overeenkomende adressen belanden in de Spam folder van de gebruikers. '*' en '?' glob patronen worden ondersteund."
},
"testMailDialog": {
"title": "Verstuur test e-mail voor {{ domain }}",
"description": "Hiermee stuur je een test e-mail van <b>no-reply@{{ domain }}</b> aan onderstaand adres.",
"title": "Verstuur test e-mail",
"description": "Stuur een test e-mail van <b>no-reply@{{ domain }}</b> naar het opgegeven adres.",
"sendAction": "Verstuur"
},
"solrConfig": {
@@ -606,7 +620,7 @@
"manualInfo": "Alle DNS-records moeten handmatig worden aangemaakt voordat een app geïnstalleerd kan worden.",
"wildcardInfo": "Stel handmatig A (IPv4) and AAAA (IPv6) DNS records in voor <b>*.{{ domain }}</b> en <b>{{ domain }}</b> met verwijzingen naar deze Cloudron server",
"advancedAction": "Geavanceerde instellingen …",
"zoneName": "Zone naam (optioneel)",
"zoneName": "Zone naam",
"fallbackCert": "Reservecertificaat (optioneel)",
"fallbackCertCustomCert": "Aangepast certificaat",
"fallbackCertCustomCertInfo": "Voorzie een <a href=\"{{ customCertLink }}\" target=\"_blank\">wildcard certificaat</a> voor gebruik door alle apps op dit domein. Als dit niet wordt verstrekt, wordt automatisch een zelfondertekend certificaat aangemaakt.",
@@ -622,7 +636,6 @@
"netcupApiPassword": "Netcup API wachtwoord",
"vultrToken": "Vultr token",
"jitsiHostname": "Jitsi locatie",
"wellKnownDescription": "De waardes worden gebruikt om te reageren op <code>https://{{ domain }}/.well-known/</code> URLs. Let op: de app moet bereikbaar zijn op het hoofddomein <code>{{ domain }}</code> om te kunnen werken. Lees de <a href=\"{{docsLink}}\" target=\"_blank\">documentatie</a> voor meer informatie.",
"hetznerToken": "Hetzner token",
"cloudflareDefaultProxyStatus": "Inschakelen proxy voor nieuwe DNS regels",
"porkbunApikey": "Porkbun API sleutel",
@@ -639,7 +652,8 @@
"gandiTokenTypePAT": "Persoonlijke Toegang Token (PAT)",
"inwxUsername": "INWX gebruikersnaam",
"inwxPassword": "INWX wachtwoord",
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers"
"customNameservers": "Domein maakt gebruik van aangepaste (eigen) naamservers",
"zoneNamePlaceholder": "Optioneel. Indien niet opgegeven, wordt standaard het rootdomein gebruikt."
},
"title": "Domeinen",
"domain": "Domein",
@@ -664,13 +678,15 @@
"description": "Update app en e-mail DNS records van alle domeinen.",
"title": "Sync DNS"
},
"domainWellKnown": {
"title": "Well-Known locaties van {{ domain }}"
},
"tooltipWellKnown": "Well-Known locaties",
"emptyPlaceholder": "Geen Domeinen",
"noMatchesPlaceholder": "Geen bijbehorende domein",
"description": "Het toevoegen van een domein maakt het mogelijk om apps te installeren op de subdomeinen ervan."
"description": "Het toevoegen van een domein maakt het mogelijk om apps te installeren op de subdomeinen ervan.",
"wellknown": {
"editAction": "Well-known URIs",
"title": "Well-known URIs",
"context": "Configureer reacties op de URL's \"https://{{ domain }}/.well-known/\"",
"description": "Deze functie vereist een app die is geïnstalleerd op het hoofddomein \"{{ domain }}\". Zie de <a href=\"{{docsLink}}\" target=\"_blank\">documentatie</a> voor details."
}
},
"app": {
"email": {
@@ -735,7 +751,7 @@
},
"accessControl": {
"userManagement": {
"description": "Instellen wie kan inloggen en de app gebruiken.",
"description": "Instellen wie kan inloggen en de app gebruiken",
"dashboardVisibility": "Dashboardzichtbaarheid",
"visibleForSelected": "Alleen zichtbaar voor de volgende gebruikers en groepen",
"descriptionSftp": "Deze instelling regelt ook SFTP-toegang.",
@@ -749,7 +765,7 @@
},
"operators": {
"title": "Operators",
"description": "Operators kunnen deze app configureren en onderhouden."
"description": "Instellen wie deze app kan onderhouden"
},
"dashboardVisibility": {
"description": "Instellen wie deze app op het dashboard kan zien."
@@ -806,14 +822,29 @@
},
"security": {
"csp": {
"description": "Overschrijf alle CSP-headers die door de app zijn gedefinieerd.",
"description": "Overschrijf alle CSP-headers die door de app zijn gedefinieerd",
"title": "Content Security Policy",
"saveAction": "Opslaan"
"saveAction": "Opslaan",
"insertCommonCsp": "Voeg veelvoorkomende CSP-regels toe",
"commonPattern": {
"allowEmbedding": "Embedden toestaan",
"sameOriginEmbedding": "Embedden toestaan (alleen subdomeinen)",
"allowCdnAssets": "CDN-assets toestaan",
"reportOnly": "Rapporteer CSP-overtredingen",
"strictBaseline": "Strikte baselijn"
}
},
"robots": {
"title": "Robots.txt",
"disableIndexingAction": "Indexering uitschakelen",
"description": "Standaard kunnen bots deze app indexeren."
"description": "Standaard kunnen bots deze app indexeren",
"commonPattern": {
"allowAll": "Allen toestaan (standaard)",
"disallowAll": "Iedereen niet toestaan",
"disallowCommonBots": "Veelvoorkomende bots blokkeren",
"disallowAdminPaths": "Admin-paden niet toestaan",
"disallowApiPaths": "API-paden niet toestaan"
},
"insertCommonRobotsTxt": "Voeg standaard robots.txt toe"
},
"hstsPreload": "Schakel HSTS-preload in (inclusief subdomeinen)"
},
@@ -824,7 +855,7 @@
"packageVersion": "Pakketversie",
"lastUpdated": "Laatst geüpdatet",
"customAppUpdateInfo": "Auto-update is niet beschikbaar voor maatwerk apps.",
"installedAt": "Geïnstalleerd op"
"installedAt": "Geïnstalleerd"
},
"auto": {
"description": "App updates worden uitgevoerd op basis van de <a href=\"/#/system-update\">update planning</a>.",
@@ -837,11 +868,10 @@
"backups": {
"backups": {
"title": "Backups",
"time": "Aangemaakt op",
"downloadConfigTooltip": "Download Configuratie",
"createBackupAction": "Maak backup",
"importAction": "Importeer backup",
"description": "Maak een volledige snapshot van de app.",
"description": "Maak een volledige snapshot van de app",
"cloneTooltip": "Kloon",
"restoreTooltip": "Herstel",
"downloadBackupTooltip": "Download",
@@ -849,7 +879,7 @@
},
"import": {
"title": "Importeer",
"description": "Importeer de app vanuit een externe back-up."
"description": "Importeer app vanuit een externe back-up"
},
"auto": {
"title": "Automatische backups",
@@ -947,7 +977,7 @@
},
"title": "Crontab",
"saveAction": "Opslaan",
"addCommonPattern": "Voeg gemeenschappelijk patroon toe",
"addCommonPattern": "Veelvoorkomend patroon invoegen",
"description": "Cron-taken die nodig zijn voor de werking van de app zijn al opgenomen in het app-pakket. Voeg hier uitsluitend extra taken toe die specifiek zijn voor jouw installatie."
},
"sftpInfoAction": "SFTP Toegang",
@@ -1062,7 +1092,6 @@
"setupAction": "Instellen account",
"subscription": "Abonnement",
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Opgezegd en eindigt op",
"subscriptionChangeAction": "Beheer abonnement",
"subscriptionReactivateAction": "Abonnement heractiveren",
"title": "Cloudron.io Account",
@@ -1319,7 +1348,7 @@
"aliases": "Aliassen",
"usage": "Gebruik",
"title": "E-mailboxen",
"emptyPlaceholder": "Geen Mailboxen",
"emptyPlaceholder": "Geen mailboxen",
"noMatchesPlaceholder": "Geen bijbehorende mailboxen",
"stats": "Aantal: {{ mailboxCount }} / Opslaggebruik: {{ usage }}"
},
@@ -1407,7 +1436,7 @@
"title": "Bewerk mailbox",
"owner": "Mailbox-eigenaar",
"aliases": "Aliassen",
"noAliases": "Geen aliassen.",
"noAliases": "Geen aliassen",
"addAliasAction": "Alias toevoegen",
"addAnotherAliasAction": "Een andere alias toevoegen",
"enableStorageQuota": "Opslagquota"
@@ -1460,7 +1489,7 @@
"usernameOrEmail": "Gebruikersnaam of e-mail",
"resetAction": "Reset",
"success": {
"openDashboardAction": "Open Dashboard",
"openDashboardAction": "Open dashboard",
"title": "Wachtwoord veranderd"
},
"passwordChanged": {
@@ -1516,7 +1545,6 @@
"welcomeEmail": {
"subject": "Welkom bij <%= cloudron %>",
"inviteLinkActionText": "Volg deze link om te starten: <%- inviteLink %>",
"expireNote": "Deze uitnodigingslink is 7 dagen geldig.",
"invitor": "Je ontvangt deze e-mail omdat je bent uitgenodigd door <%= invitor %>.",
"inviteLinkAction": "Start hier",
"salutation": "Hallo <%= user %>,",
@@ -1524,7 +1552,7 @@
},
"setupAccount": {
"success": {
"openDashboardAction": "Open Dashboard",
"openDashboardAction": "Open dashboard",
"title": "Je account is klaar"
},
"invalidToken": {
@@ -1539,11 +1567,11 @@
"fullName": "Volledige naam",
"username": "Gebruikersnaam",
"description": "Stel je account in",
"welcomeTo": "Welkom bij",
"noUsername": {
"title": "Account kan niet ingesteld worden",
"description": "Account kan niet ingesteld worden zonder gebruikersnaam."
}
"description": "Account kan niet ingesteld worden zonder gebruikersnaam. Neem contact op met de administrator."
},
"welcome": "Welkom"
},
"newLoginEmail": {
"subject": "[<%= cloudron %>] Er is vanaf een nieuwe locatie ingelogd op je account",
@@ -1587,7 +1615,7 @@
},
"clientCredentials": {
"title": "Clientreferenties",
"description": "Kopieer de inloggegevens voor client \"{{ clientName }}\"."
"description": "Kopieer de inloggegevens voor client \"{{ clientName }}\""
}
},
"userdirectory": {
@@ -1598,7 +1626,8 @@
"archives": {
"listing": {
"placeholder": "Geen gearchiveerde apps"
}
},
"description": "Gearchiveerde apps bewaren de laatst gemaakte backup op het moment van archiveren. Deze backups worden permanent bewaard en kunnen worden hersteld."
},
"backup": {
"target": {
@@ -1609,7 +1638,8 @@
"sites": {
"title": "Backup Locaties",
"emptyPlaceholder": "Geen backup locaties",
"lastRun": "Laatste uitvoering"
"lastRun": "Laatste uitvoering",
"description": "Backuplocaties geven aan waar systeem- en app-backups worden opgeslagen. App-backups kunnen afzonderlijk worden hersteld."
},
"site": {
"removeDialog": {
@@ -1646,10 +1676,6 @@
"dashboard": {
"title": "Dashboard"
},
"externallinks": {
"label": "Externe links",
"description": "Voegt snelkoppelingen naar externe diensten toe aan het dashboard"
},
"server": {
"title": "Server"
}
-3
View File
@@ -36,9 +36,6 @@
"logs": "Logi",
"reboot": "Restart"
},
"table": {
"date": "Data"
},
"actions": "Akcje",
"displayName": "Wyświetlana nazwa",
"username": "Użytkownik",
+186 -73
View File
@@ -19,7 +19,7 @@
"noMatchesPlaceholder": "Sem aplicações correspondentes"
},
"main": {
"displayName": "Nome a Exibir",
"displayName": "Nome a exibir",
"rebootDialog": {
"description": "Todas as aplicações e serviços irão iniciar automaticamente. <br/><br/>Reiniciar agora o servidor?",
"title": "Reiniciar Servidor",
@@ -36,11 +36,10 @@
"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": {
@@ -50,7 +49,9 @@
"edit": "Editar",
"add": "Adicionar",
"next": "Seguinte",
"configure": "Configurar"
"configure": "Configurar",
"restart": "Reiniciar",
"reset": "Reiniciar"
},
"searchPlaceholder": "Pesquisar",
"multiselect": {
@@ -62,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": {
@@ -73,14 +80,14 @@
"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.",
"userManagement": "Gestão de utilizadores",
"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",
@@ -100,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",
"currentPassword": "Palavra-passe Atual",
"newPassword": "Nova Palavra-passe",
"newPasswordRepeat": "Repetir Nova Palavra-passe",
"currentPassword": "Palavra-passe atual",
"newPassword": "Nova 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",
@@ -127,13 +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": {
"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."
@@ -144,13 +151,13 @@
"description": "Novo 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 }}"
},
"title": "Perfil",
"primaryEmail": "E-mail Principal",
"primaryEmail": "E-mail principal",
"language": "Idioma",
"disable2FA": {
"title": "Desativar Autenticação de Dois Fatores",
@@ -158,11 +165,11 @@
"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": {
@@ -172,28 +179,30 @@
"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"
"passwordRecoveryEmail": "Mensagem de recuperação da palavra-passe"
},
"users": {
"exposedLdap": {
"ipRestriction": {
"label": "Restringir Acesso",
"placeholder": "Endereço de IP ou sub-redes separados por linha",
"description": "Limite o acesso do Servidor de Diretoria para IPs ou intervalos específicos. As linhas que começam com <code>#</code> são tratadas como comentários."
"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 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",
@@ -215,10 +224,10 @@
"externalLdapTooltip": "Da diretoria LDAP externa",
"resetPasswordTooltip": "Redefinir Palavra-passe",
"noMatchesPlaceholder": "Nenhum utilizador correspondente",
"emptyPlaceholder": "Sem Utilizadores"
"emptyPlaceholder": "Sem utilizadores"
},
"groups": {
"emptyPlaceholder": "Sem Grupos",
"emptyPlaceholder": "Sem grupos",
"name": "Nome",
"users": "Utilizadores",
"externalLdapTooltip": "Da diretoria LDAP externa",
@@ -229,12 +238,12 @@
"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"
},
@@ -248,7 +257,7 @@
},
"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).<br/><br/>Eliminar grupo\"{{ name }}\"?",
@@ -256,29 +265,30 @@
"title": "Eliminar Grupo"
},
"invitationDialog": {
"descriptionEmail": "Enviar Hiperligação de Convite",
"title": "Convidar {{ username }}",
"descriptionEmail": "Enviar hiperligação de convite",
"title": "Convidar Utilizador",
"sendAction": "Enviar mensagem",
"descriptionLink": "Hiperligação de Convite",
"description": "A seguinte hiperligação de convite foi enviada para {{ email }}:"
"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",
"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)",
@@ -286,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"
@@ -301,7 +311,7 @@
"mailmanager": "Gestor de E-mails e Utilizadores"
},
"setGhostDialog": {
"password": "Palavra-passe Temporária",
"password": "Palavra-passe temporária",
"setPassword": "Definir palavra-passe",
"generatePassword": "Gerar Palavra-passe",
"title": "Fazer-se passar pelo Utilizador",
@@ -319,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",
"addUserAction": "Adicionar",
"sendInviteCheckbox": "Enviar mensagem de convite"
},
"invitationNotification": {
"body": "Mensagem enviada para {{ email }}"
},
"title": "Utilizadores"
"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",
@@ -512,7 +527,7 @@
"version": "Versão",
"noApps": "Sem Aplicações",
"appCount": "Aplicações: {{ appCount }}",
"backupNow": "Copiar Agora",
"backupNow": "Copiar agora",
"tooltipPreservedBackup": "Esta cópia de segurança será preservada",
"title": "Cópias de Segurança do Sistema",
"noBackups": "Sem Cópias de Segurança",
@@ -524,7 +539,8 @@
"id": "Id.",
"date": "Data",
"version": "Versão",
"list": "Referencia as cópias de segurança de {{ appCount }} aplicação(ões)"
"size": "Tamanho",
"duration": "Duração"
}
},
"passwordReset": {
@@ -764,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": {
@@ -831,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": {
@@ -874,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": {
@@ -883,7 +972,8 @@
},
"noUsername": {
"title": "Não é possível configurar a conta"
}
},
"welcome": "Bem-vindo"
},
"passwordResetEmail": {
"salutation": "Olá <%= user %>,",
@@ -891,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": {
@@ -900,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
-3
View File
@@ -10,9 +10,6 @@
"yes": "ඔව්"
},
"username": "පරිශීලක නාමය",
"table": {
"date": "දිනය"
},
"searchPlaceholder": "සොයන්න",
"multiselect": {
"select": "තෝරන්න"
+70 -57
View File
@@ -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,21 +270,21 @@
"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",
"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.",
"name": "Tên cho mã API",
@@ -275,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",
@@ -299,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ơ",
@@ -312,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": {
@@ -374,7 +398,6 @@
"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",
@@ -382,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 thời gian lưu giữ"
"title": "Lịch sao lưu & thời gian lưu giữ"
},
"backupEdit": {
"preserved": {
@@ -450,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"
@@ -807,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",
@@ -1005,7 +1026,6 @@
"domain": "Tên miền",
"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",
@@ -1043,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": {
@@ -1096,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"
@@ -1114,7 +1129,6 @@
},
"security": {
"robots": {
"disableIndexingAction": "Không cho lên chỉ mục",
"title": "File Robots.txt"
},
"csp": {
@@ -1370,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 %>!"
+2 -12
View File
@@ -59,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": {
@@ -112,8 +111,7 @@
"title": "备份详情",
"id": "Id",
"date": "日期",
"version": "版本",
"list": "备份了下列 {{ appCount }} 个应用"
"version": "版本"
},
"configureBackupSchedule": {
"title": "配置备份计划和保留时间",
@@ -185,9 +183,6 @@
"username": "用户名",
"displayName": "昵称",
"actions": "操作",
"table": {
"date": "日期"
},
"action": {
"reboot": "重启",
"logs": "日志"
@@ -525,7 +520,6 @@
"setupAction": "设置账户",
"subscription": "订阅",
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "已取消并将终止于",
"subscriptionChangeAction": "更改订阅",
"subscriptionReactivateAction": "重新激活订阅"
},
@@ -978,8 +972,7 @@
"description": "使用此设置来覆盖应用自带的 CSP header"
},
"robots": {
"title": "Robots.txt",
"disableIndexingAction": "禁止爬取"
"title": "Robots.txt"
}
},
"updates": {
@@ -1028,7 +1021,6 @@
"importAction": "导入备份",
"title": "备份",
"description": "备份是应用的完整快照。你可以使用应用的备份来恢复或者克隆该应用。",
"time": "创建于",
"downloadConfigTooltip": "下载备份的配置文件",
"cloneTooltip": "由此备份克隆"
},
@@ -1099,7 +1091,6 @@
"welcomeEmail": {
"salutation": "<%= user %> 你好,",
"inviteLinkAction": "开始",
"expireNote": "请注意,邀请链接会在 7 天内失效。",
"invitor": "您收到了 <%= invitor %> 的邀请注册邮件。",
"inviteLinkActionText": "使用这个链接来开始注册:<%- inviteLink %>",
"subject": "欢迎来到 <%= cloudron %>",
@@ -1136,7 +1127,6 @@
"title": "账户已就绪",
"openDashboardAction": "打开控制面板"
},
"welcomeTo": "欢迎来到",
"description": "请设置你的账户",
"username": "用户名",
"password": "新密码",
+1 -1
View File
@@ -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 -1
View File
@@ -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>
+5 -4
View File
@@ -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>
+195 -196
View File
@@ -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...
@@ -267,7 +432,7 @@ onMounted(async () => {
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
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();
@@ -277,6 +442,10 @@ onMounted(async () => {
ready.value = true;
});
onUnmounted(() => {
window.removeEventListener('resize', checkForMobile);
});
</script>
<template>
@@ -284,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-screwdriver-wrench"></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-screwdriver-wrench"></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"/>
@@ -389,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>
+111
View 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>
+32 -32
View File
@@ -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,6 +98,7 @@ function onReset() {
tokenScope.value = 'rw';
tokenAllowedIpRanges.value = '';
tokenAllowedIpRangesError.value = '';
setTimeout(checkValidity, 100); // update state of the confirm button
}, 500);
}
@@ -125,13 +128,12 @@ onMounted(async () => {
<template>
<div>
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<Dialog ref="newDialog"
:title="$t('profile.createApiToken.title')"
:confirm-label="addedToken ? '' : $t('main.action.add')"
:confirm-active="isValid"
:confirm-active="isFormValid"
confirm-style="primary"
:reject-label="addedToken ? $t('main.dialog.close') : $t('main.dialog.cancel')"
reject-style="secondary"
@@ -141,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/>
@@ -192,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>
+76 -34
View File
@@ -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, mountlike } 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,35 +10,42 @@ 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 config = {};
const { prefix, remotePath } = parseFullBackupPath(fullPath.value);
// only set provider specific fields, this will clear them in the db
if (s3like(provider.value)) {
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) config.endpoint = providerConfig.value.endpoint;
@@ -83,9 +90,12 @@ async function onSubmit() {
} else if (provider.value === 'hetzner-objectstorage') {
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 = providerConfig.value.prefix;
config.prefix = prefix;
config.noHardlinks = !providerConfig.value.useHardlinks;
config.mountOptions = {};
@@ -113,21 +123,19 @@ async function onSubmit() {
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
}
} else if (provider.value === 'filesystem') {
const parts = remotePath.value.split('/');
backupPath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
config.backupDir = parts.join('/'); // this is dirname()
config.backupDir = prefix;
} else if (provider.value === 'gcs') {
config.bucket = providerConfig.value.bucket;
config.prefix = providerConfig.value.prefix;
config.projectId = providerConfig.value.projectId;
config.credentials = providerConfig.value.credentials;
config.prefix = prefix;
}
const data = {
format: format.value,
provider: provider.value,
config: config,
remotePath: backupPath
config,
remotePath
};
if (encrypted.value) {
@@ -188,37 +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;
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;
// translate for BackupProviderForm flattened object
providerConfig.value = {};
providerConfig.value.useHardlinks = !data.config.noHardlinks;
providerConfig.value.prefix = data.config.prefix;
providerConfig.value.chown = !!data.config.chown;
providerConfig.value.preserveAttributes = data.config.preserveAttributes;
providerConfig.value.mountOptionHost = data.config.mountOptions.host;
providerConfig.value.mountOptionPort = data.config.mountOptions.port;
providerConfig.value.mountOptionRemoteDir = data.config.mountOptions.remoteDir;
providerConfig.value.mountOptionSeal = !!data.config.mountOptions.seal;
providerConfig.value.mountOptionDiskPath = data.config.mountOptions.diskPath;
providerConfig.value.mountOptionUser = data.config.mountOptions.user;
providerConfig.value.mountOptionUsername = data.config.mountOptions.username;
providerConfig.value.mountOptionPassword = data.config.mountOptions.password;
providerConfig.value.mountOptionPrivateKey = '';
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]);
@@ -228,6 +264,10 @@ function onUploadBackupConfig() {
backupConfigInput.value.click();
}
watchEffect(() => {
if (providerConfig.value.credentials) setTimeout(checkValidity, 100);
});
defineExpose({
async open(id) {
appId.value = id;
@@ -235,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
}
});
@@ -253,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"
@@ -273,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"
+35 -9
View File
@@ -2,21 +2,24 @@
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();
@@ -159,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);
}
}
@@ -199,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;
@@ -243,8 +267,7 @@ defineExpose({
}
currentScreenshotPos = 0;
dialog.value.open();
step.value = STEP.DETAILS;
},
close() {
dialog.value.close();
@@ -254,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>
+19 -24
View File
@@ -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 = '';
@@ -158,12 +156,11 @@ 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-active="addedPassword || isFormValid"
:confirm-label="addedPassword ? '' : $t('main.action.add')"
confirm-style="primary"
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
@@ -174,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/>
@@ -183,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>
@@ -210,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>
+22 -17
View File
@@ -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 = {
@@ -134,6 +137,8 @@ defineExpose({
groups.value = result;
applinkDialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -147,16 +152,16 @@ defineExpose({
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
:confirm-active="isValid"
: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>
-37
View File
@@ -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>
@@ -103,7 +103,7 @@ defineExpose({
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupEdit.label') }}</div>
<div class="info-value">{{ backup.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>
@@ -124,7 +124,7 @@ defineExpose({
</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)</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">
@@ -133,11 +133,9 @@ defineExpose({
<div v-else class="info-value">{{ prettyDuration(backup.stats.upload.duration + backup.stats.copy.duration) }}</div>
</div>
<div v-if="backup.type === 'box'">
<br/>
<div>{{ $t('backups.backupDetails.list', { appCount: backup.appCount }) }}:</div>
<br/>
<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>
@@ -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,12 @@ 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');
@@ -125,7 +135,7 @@ 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 -->
@@ -189,13 +199,13 @@ onMounted(async () => {
<!-- 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')"/>
@@ -205,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' ||
@@ -241,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)">
@@ -258,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>
@@ -3,7 +3,7 @@
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';
@@ -205,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'">
@@ -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();
@@ -120,12 +133,13 @@ defineExpose({
<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>
@@ -3,7 +3,7 @@
import { ref, useTemplateRef, computed } from 'vue';
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' ]);
@@ -18,7 +18,7 @@ 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() {
@@ -67,6 +67,8 @@ defineExpose({
site.value = s;
busy.value = false;
formError.value = false;
days.value = [];
hours.value = [];
const currentRetentionString = JSON.stringify(site.value.retention);
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return JSON.stringify(x.id) === currentRetentionString; });
@@ -76,16 +78,9 @@ defineExpose({
scheduleType.value = 'never';
} else {
scheduleType.value = 'pattern';
const tmp = site.value.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); });
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();
+2 -2
View File
@@ -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;
}
@@ -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>
+12 -10
View File
@@ -1,6 +1,6 @@
<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';
@@ -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>
+43 -8
View File
@@ -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">
+10 -14
View File
@@ -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');
@@ -94,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()"/>
@@ -107,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>
@@ -37,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() {
+7 -3
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
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';
@@ -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
@@ -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" />
+16 -17
View File
@@ -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,7 +127,7 @@ 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>
@@ -152,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>
@@ -314,6 +312,7 @@ 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>
@@ -321,7 +320,7 @@ function onGcdnsFileInputChange(event) {
<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"/>
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name" required/>
</FormGroup>
</div>
+3 -2
View File
@@ -14,6 +14,7 @@ const props = defineProps({
multiline: { type: Boolean, default: false },
markdown: { type: Boolean, default: false },
rows: { type: Number, default: 2 },
maxlength: { type: Number, default: -1 },
});
const emit = defineEmits(['save']);
@@ -56,8 +57,8 @@ 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" :required="required ? true : null"/>
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving" :required="required ? true : null"></textarea>
<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>
+2 -2
View File
@@ -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,6 +1,6 @@
<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';
@@ -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>
+2 -2
View File
@@ -372,8 +372,8 @@ async function onRestartApp() {
const confirmed = await inputDialog.value.confirm({
message: t('filemanager.toolbar.restartApp') + '?',
confirmStyle: 'primary',
confirmLabel: t('app.repair.recovery.restartAction'),
confirmLabel: t('main.action.restart'),
confirmStyle: 'danger',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
+43 -8
View File
@@ -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">&#8943;</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>
+10 -9
View File
@@ -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();
}
@@ -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">{{ $t('users.group.allowedApps') }}</label>
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
<MultiSelect v-model="appIds" :options="allApps" option-key="id" :search-threshold="20"/>
</FormGroup>
</fieldset>
</form>
+46 -11
View File
@@ -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');
@@ -52,7 +54,7 @@ async function onMarkNotificationRead(notification) {
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);
@@ -85,7 +87,7 @@ function onSubscriptionRequired() {
const platformStatus = ref({
message: '',
isReady: true,
state: '',
});
let platformTimeoutId = 0;
@@ -95,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', {
@@ -105,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();
@@ -119,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">
@@ -158,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>
@@ -183,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);
+16 -11
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, useTemplateRef, computed } from 'vue';
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';
@@ -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,19 +105,19 @@ 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"/>
<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>
+16 -11
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, useTemplateRef, computed } from 'vue';
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';
@@ -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,19 +105,19 @@ 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>
+12 -13
View File
@@ -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>
@@ -73,9 +72,9 @@ onMounted(async () => {
<Section :title="$t('users.exposedLdap.title')">
<div>{{ $t('users.exposedLdap.description') }}</div>
<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"/>
@@ -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>
+125 -131
View File
@@ -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]&nbsp;' }</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]&nbsp;' }</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>
+26 -16
View File
@@ -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>
&nbsp;
<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>
&nbsp;
<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>
&nbsp;
<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>
&nbsp;
<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;
}
@@ -54,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() {
@@ -97,6 +97,8 @@ async function onShowDialog() {
}
async function onSubmit() {
if (!form.value.reportValidity()) return;
busy.value = true;
formError.value = '';
@@ -173,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>
+29 -28
View File
@@ -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
}
});
@@ -143,23 +146,22 @@ defineExpose({
: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>
<label for="nameInput">{{ $t('email.addMailboxDialog.name') }}</label>
<InputGroup>
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailbox ? true : undefined"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox"/>
<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 = {};
@@ -85,6 +89,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
: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 !== ''"
@@ -103,14 +108,14 @@ defineExpose({
<FormGroup>
<label for="nameInput">{{ $t('email.addMailinglistDialog.name') }}</label>
<InputGroup>
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="mailinglist ? true : undefined"/>
<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>
@@ -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>
+17 -11
View File
@@ -1,6 +1,6 @@
<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';
@@ -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 {
@@ -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>
+15 -9
View File
@@ -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>
+195
View 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
View 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>
+40 -39
View File
@@ -5,9 +5,10 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, Menu, FormGroup, TextInput, Checkbox, TableView, Dialog } from '@cloudron/pankow';
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';
@@ -22,16 +23,11 @@ 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'),
@@ -46,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),
@@ -71,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);
@@ -169,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);
@@ -206,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;
@@ -238,8 +237,6 @@ defineExpose({ refresh });
<template>
<Section :title="$t('backups.listing.title')">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<BackupInfoDialog ref="infoDialog" />
<Dialog ref="editDialog"
@@ -251,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>
@@ -260,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">&nbsp;<b>{{ backup.label }}</b></span>
<span>&nbsp;<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>
@@ -289,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>
+10 -35
View File
@@ -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();
@@ -347,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">
@@ -372,7 +347,7 @@ onMounted(async () => {
<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>
+3 -3
View File
@@ -139,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',
});
+12 -31
View File
@@ -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,26 +39,12 @@ 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) {
@@ -68,7 +53,7 @@ function onAvatarChanged(file) {
const isFormValid = ref(false);
function validateForm() {
isFormValid.value = form.value && form.value.checkValidity();
isFormValid.value = form.value ? form.value.checkValidity() : false;
}
async function onSubmit() {
@@ -146,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;
@@ -203,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);
@@ -222,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
}
});
@@ -240,15 +225,11 @@ defineExpose({
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()"
>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="!isFormValid" />
<input type="submit" style="display: none;" />
<div style="display: flex; justify-content: center;">
<div style="width: 80px;">
@@ -259,23 +240,23 @@ defineExpose({
<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 until one is set -->
<!-- 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="profileLocked ? true : null" :readonly="user?.username ? true : undefined" />
<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>
<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 && user.source) ? true : undefined" required />
<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" :readonly="(user && user.source) ? true : undefined"/>
<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>
@@ -295,7 +276,7 @@ defineExpose({
<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>
<!-- on add, this is hidden for now, until we figure why one would want to add an inactive user -->
+4 -2
View File
@@ -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">
+24 -27
View File
@@ -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';
@@ -17,6 +17,7 @@ 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();
@@ -25,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: {
@@ -53,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),
@@ -90,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,
// }, {
@@ -98,8 +97,6 @@ function onActionMenu(backup, event) {
// visible: props.app.accessLevel === 'admin',
// action: onCheckIntegrity.bind(null, backup),
}];
actionMenuElement.value.open(event, event.currentTarget);
}
const busy = ref(true);
@@ -297,7 +294,6 @@ onMounted(async () => {
<template>
<div>
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<AppRestoreDialog ref="cloneDialog"/>
<AppImportDialog ref="importDialog"/>
@@ -315,14 +311,14 @@ 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>
@@ -391,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">&nbsp;<b>{{ backup.label }}</b></span>
<span>&nbsp;<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 }}
@@ -405,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>
+4 -1
View File
@@ -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>
+1 -1
View File
@@ -132,7 +132,7 @@ onMounted(async () => {
<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 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">
+5 -2
View File
@@ -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">
+2 -2
View File
@@ -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: ''
});
}
+3 -3
View File
@@ -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>
+47 -10
View File
@@ -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,22 +75,27 @@ 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>
<div description>{{ $t('app.security.robots.description') }}</div>
<textarea id="robotsTxtInput" style="white-space: pre-wrap; font-family: monospace;" v-model="robotsTxt" rows="10"></textarea>
<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>
<FormGroup>
+2 -2
View File
@@ -191,8 +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 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>
@@ -211,7 +211,7 @@ 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 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>
+3 -2
View File
@@ -294,7 +294,7 @@ const STORAGE_PROVIDERS = [
{ name: 'EXT4 Disk', value: 'ext4' },
{ name: 'Exoscale SOS', value: 'exoscale-sos', regions: REGIONS_EXOSCALE },
{ name: 'Filesystem', value: 'filesystem' },
{ name: 'Filesystem (Mountpoint)', value: 'mountpoint' }, // legacy
{ name: 'Filesystem (Mount point)', value: 'mountpoint' }, // legacy
{ name: 'Google Cloud Storage', value: 'gcs' },
{ name: 'Hetzner Object Storage', value: 'hetzner-objectstorage', regions: REGIONS_HETZNER },
{ name: 'IDrive e2', value: 'idrive-e2' },
@@ -306,6 +306,7 @@ const STORAGE_PROVIDERS = [
{ name: 'S3 API Compatible (v4)', value: 's3-v4-compat' },
{ name: 'Scaleway Object Storage', value: 'scaleway-objectstorage', regions: REGIONS_SCALEWAY },
{ name: 'SSHFS Mount', value: 'sshfs' },
{ name: 'Synology C2', value: 'synology-c2-objectstorage' },
{ name: 'UpCloud Object Storage', value: 'upcloud-objectstorage' },
{ name: 'Vultr Object Storage', value: 'vultr-objectstorage', regions: REGIONS_VULTR },
{ name: 'Wasabi', value: 'wasabi', regions: REGIONS_WASABI },
@@ -331,7 +332,7 @@ const RELAY_PROVIDERS = [
{ provider: 'postmark-smtp', name: 'Postmark', host: 'smtp.postmarkapp.com', port: 587, spfDoc: 'https://postmarkapp.com/support/article/1092-how-do-i-set-up-spf-for-postmark' },
{ provider: 'sendgrid-smtp', name: 'SendGrid', host: 'smtp.sendgrid.net', port: 587, username: 'apikey', spfDoc: 'https://sendgrid.com/docs/ui/account-and-settings/spf-records/' },
{ provider: 'sparkpost-smtp', name: 'SparkPost', host: 'smtp.sparkpostmail.com', port: 587, username: 'SMTP_Injection', spfDoc: 'https://www.sparkpost.com/resources/email-explained/spf-sender-policy-framework/' },
{ provider: 'noop', name: 'Disabled' },
{ provider: 'noop', name: 'Disable outgoing email' },
];
// named exports
+2 -1
View File
@@ -113,6 +113,7 @@ function create() {
app.ssoAuth = app.sso && (app.manifest.addons['ldap'] || app.manifest.addons['oidc'] || app.manifest.addons['proxyAuth']); // checking app.sso first ensures app.manifest.addons is not null
app.type = app.manifest.id === PROXY_APP_ID ? APP_TYPES.PROXIED : APP_TYPES.APP;
app.iconUrl = app.iconUrl ? `${API_ORIGIN}${app.iconUrl}?ts=${new Date(app.ts).getTime()}` : `${API_ORIGIN}/img/appicon_fallback.png`; // calculate full icon url with cache busting
app.origin = 'https://' + app.fqdn;
// only fetch if we have permissions and a taskId is set/active
if (!app.taskId || (app.accessLevel !== 'operator' && app.accessLevel !== 'admin')) {
@@ -137,7 +138,7 @@ function create() {
text = text.replace(/\$CLOUDRON-APP-LOCATION/g, app.subdomain);
text = text.replace(/\$CLOUDRON-APP-DOMAIN/g, app.domain);
text = text.replace(/\$CLOUDRON-APP-FQDN/g, app.fqdn);
text = text.replace(/\$CLOUDRON-APP-ORIGIN/g, 'https://' + app.fqdn);
text = text.replace(/\$CLOUDRON-APP-ORIGIN/g, app.origin);
text = text.replace(/\$CLOUDRON-API-DOMAIN/g, config.adminFqdn);
text = text.replace(/\$CLOUDRON-API-ORIGIN/g, 'https://' + config.adminFqdn);
text = text.replace(/\$CLOUDRON-USERNAME/g, profile.username);
+3 -3
View File
@@ -39,16 +39,16 @@ function create() {
if (result.status !== 200) return [result];
return [null, result.body];
},
async mailboxCount(domain) {
async stats(domain) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/mailbox_count`, { access_token: accessToken });
result = await fetcher.get(`${API_ORIGIN}/api/v1/mail/${domain}/stats`, { access_token: accessToken });
} catch (e) {
return [e];
}
if (result.status !== 200) return [result];
return [null, result.body.count];
return [null, result.body];
},
async setCatchallAddresses(domain, addresses) {
let result;
+1 -1
View File
@@ -9,7 +9,7 @@ function create() {
async list(acknowledged = false) {
let result;
try {
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 100 });
result = await fetcher.get(`${API_ORIGIN}/api/v1/notifications`, { acknowledged, access_token: accessToken, per_page: 1000 });
} catch (e) {
return [e];
}
-5
View File
@@ -46,11 +46,6 @@ function create() {
error = e;
}
if (error || result.status !== 200) {
console.error('Failed to get profile.', error || result.status);
return [];
}
if (error || result.status !== 200) return [error || result];
result.body.isAtLeastUserManager = [ ROLES.OWNER, ROLES.ADMIN, ROLES.MAIL_MANAGER, ROLES.USER_MANAGER ].indexOf(result.body.role) !== -1;
+1 -1
View File
@@ -6,7 +6,7 @@ const mountTypes = [
{ name: 'CIFS', value: 'cifs' },
{ name: 'EXT4', value: 'ext4' },
{ name: 'Filesystem', value: 'filesystem' },
{ name: 'Filesystem (Mountpoint)', value: 'mountpoint' },
{ name: 'Filesystem (Mount point)', value: 'mountpoint' },
{ name: 'NFS', value: 'nfs' },
{ name: 'SSHFS', value: 'sshfs' },
{ name: 'XFS', value: 'xfs' },
+98 -10
View File
@@ -3,6 +3,7 @@ import { prettyBinarySize } from '@cloudron/pankow/utils';
import { RELAY_PROVIDERS, ISTATES, STORAGE_PROVIDERS } from './constants.js';
function prettyRelayProviderName(provider) {
if (provider === 'noop') return 'Disabled (no email will be sent)';
const tmp = RELAY_PROVIDERS.find(p => p.provider === provider);
return tmp ? tmp.name : provider;
}
@@ -31,7 +32,7 @@ function s3like(provider) {
|| provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'cloudflare-r2'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage'
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2'
|| provider === 'contabo-objectstorage';
|| provider === 'contabo-objectstorage' || provider === 'synology-c2-objectstorage';
}
function regionName(provider, endpoint) {
@@ -43,6 +44,30 @@ function regionName(provider, endpoint) {
return region.name;
}
function prettySiteLocation(site) {
switch (site.provider) {
case 'filesystem':
return site.config.backupDir + (site.config.prefix ? `/${site.config.prefix}` : '');
case 'disk':
case 'ext4':
case 'xfs':
case 'mountpoint':
return (site.config.mountOptions.diskPath || site.config.mountPoint) + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 'cifs':
case 'nfs':
case 'sshfs':
return site.config.mountOptions.host + ':' + site.config.mountOptions.remoteDir + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 's3':
return site.config.region + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 'minio':
return site.config.endpoint + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
case 'gcs':
return site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
default:
return regionName(site.provider, site.config.endpoint) + ' / ' + site.config.bucket + (site.config.prefix ? ` / ${site.config.prefix}` : '');
}
}
function eventlogDetails(eventLog, app = null, appIdContext = '') {
const ACTION_ACTIVATE = 'cloudron.activate';
const ACTION_PROVISION = 'cloudron.provision';
@@ -155,11 +180,6 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
return pre + (app.label || app.fqdn || app.subdomain) + ' (' + app.manifest.title + ') ';
}
function eventBy() {
if (eventLog.source && eventLog.source.username) return ' by ' + eventLog.source.username;
return '';
}
switch (eventLog.action) {
case ACTION_ACTIVATE:
return 'Cloudron was activated';
@@ -234,7 +254,7 @@ function eventlogDetails(eventLog, app = null, appIdContext = '') {
case ACTION_APP_INSTALL:
if (!data.app) return '';
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app) + eventBy();
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed ' + appName('at', data.app);
case ACTION_APP_RESTORE:
if (!data.app) return '';
@@ -576,7 +596,6 @@ function redirectIfNeeded(status, currentView) {
}
if (status.activated) {
console.log('Already activated');
if (currentView === 'dashboard') {
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
@@ -671,18 +690,80 @@ const cronDays = [
// generates 24h time sets (instead of american 12h) to avoid having to translate everything to locales eg. 12:00
const cronHours = Array.from({ length: 24 }).map(function (v, i) { return { id: i, name: (i < 10 ? '0' : '') + i + ':00' }; });
function prettySchedule(pattern) {
if (!pattern) return '';
const tmp = pattern.trim().split(/\s+/); // remove extra spaces between tokens, which is valid cron
if (tmp.length === 1) return pattern.charAt(0).toUpperCase() + pattern.slice(1); // case for 'never' - capitalize
if (tmp.length === 5) tmp.unshift('0'); // if seconds is missing, add it
if (tmp.length !== 6) return `Unrecognized pattern - ${pattern}`;
const hours = tmp[2].split(',').sort((a, b) => Number(a) - Number(b));
const days = tmp[5].split(',').sort((a, b) => Number(a) - Number(b));
try {
const prettyDay = (days.length === 7 || days[0] === '*') ? 'Every day' : days.map((day) => { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(', ');
const prettyHour = (hours.length === 24 || hours[0] === '*') ? 'hourly' : hours.map((hour) => { return cronHours[parseInt(hour, 10)]; }).sort((a,b) => a.id - b.id).map(h => h.name).join(', ');
return `${prettyDay} @ ${prettyHour}`;
} catch (error) {
console.error('Unable to build pattern.', error);
return `Unrecognized pattern - ${pattern}`;
}
};
function parseSchedule(pattern) {
const tmp = pattern.trim().split(/\s+/); // remove extra spaces between tokens, which is valid cron
if (tmp.length === 1) return console.error(`Never pattern should not be passed here - ${pattern}`);
if (tmp.length === 5) tmp.unshift('0'); // if seconds is missing, add it
if (tmp.length !== 6) return console.error(`Unrecognized pattern - ${pattern}`);
const tmpHours = tmp[2].split(',');
const tmpDays = tmp[5].split(',');
let days, hours;
if (tmpDays[0] === '*') days = cronDays.map((day) => { return day.id; });
else days = tmpDays.map((day) => { return parseInt(day, 10); });
if (tmpHours[0] === '*') hours = cronHours.map(h => h.id);
else hours = tmpHours.map((hour) => { return parseInt(hour, 10); });
return { days, hours };
}
function getColor(numOfSteps, step) {
const deg = 360/numOfSteps;
return `hsl(${deg*step} 70% 50%)`;
}
// split path into a prefix (anything before timestamp or 'snapshot') and the remaining remotePath
function parseFullBackupPath(fullPath) {
const parts = fullPath.split('/');
const timestampRegex = /^\d{4}-\d{2}-\d{2}-\d{6}-\d{3}$/; // timestamp (tag)
const idx = parts.findIndex(p => timestampRegex.test(p) || p === 'snapshot');
let remotePath, prefix;
if (idx === -1) {
remotePath = parts.pop() || parts.pop(); // if fs+rsync there may be a trailing slash, so this removes it. this is basename()
prefix = parts.join('/'); // this is dirname()
} else {
prefix = parts.slice(0, idx).join('/');
remotePath = parts.slice(idx).join('/');
}
return { prefix, remotePath };
}
// named exports
export {
prettyRelayProviderName,
download,
mountlike,
s3like,
regionName,
eventlogDetails,
eventlogSource,
taskNameFromInstallationState,
@@ -693,6 +774,10 @@ export {
cronDays,
cronHours,
getColor,
prettySchedule,
parseSchedule,
prettySiteLocation,
parseFullBackupPath
};
// default export
@@ -701,7 +786,6 @@ export default {
download,
mountlike,
s3like,
regionName,
eventlogDetails,
eventlogSource,
taskNameFromInstallationState,
@@ -712,4 +796,8 @@ export default {
cronDays,
cronHours,
getColor,
prettySchedule,
parseSchedule,
prettySiteLocation,
parseFullBackupPath
};
+13 -17
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import { ref, useTemplateRef, onMounted } from 'vue';
import { Button, Checkbox, FormGroup, TextInput, PasswordInput, EmailInput } from '@cloudron/pankow';
import ProvisionModel from '../models/ProvisionModel.js';
import { redirectIfNeeded } from '../utils.js';
@@ -16,18 +16,14 @@ const password = ref('');
const setupToken = ref('');
const acceptLicense = ref(false);
const isValid = computed(() => {
if (!displayName.value) return false;
if (!email.value) return false;
if (!username.value) return false;
if (!password.value) return false;
if (!acceptLicense.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 onOwnerSubmit() {
if (!isValid.value) return;
if (!form.value.reportValidity()) return;
busy.value = true;
formError.value = {};
@@ -85,12 +81,12 @@ onMounted(async () => {
<div class="has-error" v-if="formError.generic">{{ formError.generic }}</div>
<form @submit.prevent="onOwnerSubmit()" autocomplete="off">
<form @submit.prevent="onOwnerSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="busy || !isValid"/>
<input type="submit" style="display: none;"/>
<FormGroup :has-error="formError.displayName">
<label for="displayNameInput">Full Name</label>
<label for="displayNameInput">Full name</label>
<TextInput id="displayNameInput" v-model="displayName" required />
<small class="text-danger">{{ formError.displayName }}</small>
</FormGroup>
@@ -114,11 +110,11 @@ onMounted(async () => {
<small class="text-danger">{{ formError.password }}</small>
</FormGroup>
<Checkbox v-model="acceptLicense" label="Accept Cloudron License" helpUrl="https://www.cloudron.io/legal/terms.html" required />
<Checkbox v-model="acceptLicense" label="Accept Cloudron license" helpUrl="https://www.cloudron.io/legal/terms.html" required />
</fieldset>
<div class="actions">
<Button :disabled="busy || !isValid" :loading="busy" @click="onOwnerSubmit()">Create Admin</Button>
<Button :disabled="busy || !isFormValid" :loading="busy" @click="onOwnerSubmit()">Create admin</Button>
</div>
</form>
</div>
@@ -134,4 +130,4 @@ onMounted(async () => {
align-items: center;
}
</style>
</style>
+13 -14
View File
@@ -5,10 +5,11 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, Menu, TableView, InputDialog } from '@cloudron/pankow';
import { TableView, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import { API_ORIGIN } from '../constants.js';
import AppRestoreDialog from '../components/AppRestoreDialog.vue';
import ActionBar from '../components/ActionBar.vue';
import Section from '../components/Section.vue';
import ArchivesModel from '../models/ArchivesModel.js';
import { download } from '../utils.js';
@@ -29,19 +30,20 @@ const columns = {
hideMobile: true,
},
creationTime: {
label: t('main.table.date'),
label: t('main.table.created'),
sort: true,
hideMobile: true,
},
actions: {}
actions: {
width: '100px',
}
};
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(archive, event) {
actionMenuModel.value = [{
function createActionMenu(archive) {
return [{
icon: 'fa-solid fa-history',
label: t('backups.restoreArchiveDialog.restoreAction'),
quickAction: true,
action: onRestore.bind(null, archive),
}, {
separator: true,
@@ -56,8 +58,6 @@ function onActionMenu(archive, event) {
label: t('main.action.remove'),
action: onRemove.bind(null, archive),
}];
actionMenuElement.value.open(event, event.currentTarget);
}
const busy = ref(true);
@@ -117,12 +117,13 @@ onMounted(async () => {
<template>
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<Section :title="$t('backups.archives.title')">
<InputDialog ref="inputDialog"/>
<AppRestoreDialog ref="restoreDialog"/>
<div>{{ $t('archives.description') }}</div>
<br/>
<TableView :columns="columns" :model="archives" :busy="busy" :placeholder="$t('archives.listing.placeholder')">
<template #icon="archive">
<img :src="archive.iconUrl || 'img/appicon_fallback.png'" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'" height="24" width="24"/>
@@ -138,9 +139,7 @@ onMounted(async () => {
<template #creationTime="archive">{{ prettyLongDate(archive.creationTime) }}</template>
<template #actions="archive">
<div style="text-align: right;">
<Button tool plain secondary @click.capture="onActionMenu(archive, $event)" icon="fa-solid fa-ellipsis" />
</div>
<ActionBar :actions="createActionMenu(archive)"/>
</template>
</TableView>
</Section>
+1 -1
View File
@@ -295,7 +295,7 @@ onBeforeUnmount(() => {
<a class="applink" :href="link || null" target="_blank">{{ app.label || app.fqdn }}</a>
<div class="statelabel" v-if="app.error">{{ installationStateLabel(app) }} - {{ app.error.message }}</div>
<div class="statelabel" v-else>{{ installationStateLabel(app) }} {{ app.message ? ' - ' + app.message : '' }}</div>
<ProgressBar v-if="app.progress" :busy="true" slim :value="app.progress" :show-label="false" style="margin-top: 10px"/>
<ProgressBar v-if="app.progress" :busy="true" :show-track="false" slim :value="app.progress" :show-label="false" style="margin-top: 10px"/>
</h2>
</div>
-2
View File
@@ -1,13 +1,11 @@
<script setup>
import Branding from '../components/Branding.vue';
import Applinks from '../components/Applinks.vue';
</script>
<template>
<div class="content">
<Branding />
<Applinks />
</div>
</template>
+45 -23
View File
@@ -5,8 +5,9 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, computed, useTemplateRef, onActivated, onDeactivated, inject } from 'vue';
import { Button, Menu, SingleSelect, Icon, TableView, TextInput, ProgressBar } from '@cloudron/pankow';
import { Button, SingleSelect, Icon, TableView, TextInput, ProgressBar } from '@cloudron/pankow';
import { API_ORIGIN, APP_TYPES, HSTATES, ISTATES, RSTATES } from '../constants.js';
import ActionBar from '../components/ActionBar.vue';
import AppsModel from '../models/AppsModel.js';
import ApplinksModel from '../models/ApplinksModel.js';
import DomainsModel from '../models/DomainsModel.js';
@@ -95,20 +96,26 @@ const listColumns = {
},
},
checklist: {},
actions: {}
actions: {
width: '100px',
}
};
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(app, event) {
actionMenuModel.value = [{
function createAppActionMenu(app) {
return [{
icon: 'fa-solid fa-arrow-up',
label: t('app.updateAvailableTooltip'),
visible: !!app.updateInfo,
href: `#/app/${app.id}/updates`,
}, {
icon: 'fa-solid fa-cog',
label: t('app.configureTooltip'),
href: `#/app/${app.id}/info`,
quickAction: true,
visible: isOperator(app),
}, {
separator: true,
visible: !!app.updateInfo,
visible: isOperator(app) || !!app.updateInfo,
}, {
icon: 'fa-solid fa-align-left',
label: t('app.logsActionTooltip'),
@@ -128,8 +135,16 @@ function onActionMenu(app, event) {
target: '_blank',
href: '/filemanager.html#/home/app/' + app.id,
}];
}
actionMenuElement.value.open(event, event.currentTarget);
function createAppLinkActionMenu(app) {
return [{
icon: 'fa-solid fa-cog',
label: t('app.configureTooltip'),
quickAction: true,
visible: isOperator(app),
action: openAppEdit.bind(null, app),
}];
}
const filteredApps = computed(() => {
@@ -142,7 +157,8 @@ const filteredApps = computed(() => {
|| a.redirectDomains.some(rd => rd.fqdn.includes(filter.value))
|| a.aliasDomains.some(ad => ad.fqdn.includes(filter.value))
|| a.id.includes(filter.value)
|| a.manifest.title.toLocaleLowerCase().includes(filter.value.toLocaleLowerCase());
|| (a.label ? a.label.toLowerCase().indexOf(filter.value.toLocaleLowerCase()) !== -1 : false)
|| a.manifest.title?.toLocaleLowerCase().includes(filter.value.toLocaleLowerCase()); // title is optional in manifest
}
}).filter(a => {
if (!domainFilter.value) return true;
@@ -173,6 +189,7 @@ const filteredApps = computed(() => {
const applinkDialog = useTemplateRef('applinkDialog');
const postInstallDialog = useTemplateRef('postInstallDialog');
const searchInput = useTemplateRef('searchInput');
// hook for applinks otherwise it is a link
function openAppEdit(app, event) {
@@ -224,7 +241,8 @@ async function refreshApps() {
// amend properties to mimick full app
for (const applink of applinks) {
applink.type = APP_TYPES.LINK;
applink.fqdn = applink.upstreamUri.replace('https://', '');
applink.origin = applink.upstreamUri;
applink.fqdn = applink.upstreamUri;
applink.manifest = { addons: {}};
applink.installationState = ISTATES.INSTALLED;
applink.runState = RSTATES.RUNNING;
@@ -261,6 +279,10 @@ function setItemWidth() {
else itemWidth.value = '190px';
}
function onKeyDownHandler(event) {
if (event.key === 'Escape') filter.value = '';
}
onActivated(async () => {
setItemWidth();
@@ -279,11 +301,15 @@ onActivated(async () => {
refreshInterval = setInterval(refreshApps, 5000);
window.addEventListener('resize', setItemWidth);
window.addEventListener('keydown', onKeyDownHandler);
if (window.innerWidth > 575) setTimeout(() => searchInput.value.focus(), 0);
});
onDeactivated(() => {
filter.value = '';
window.removeEventListener('keydown', onKeyDownHandler);
clearInterval(refreshInterval);
});
@@ -291,14 +317,13 @@ onDeactivated(() => {
<template>
<div ref="view" class="content-large">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<ApplinkDialog ref="applinkDialog" @success="refreshApps"/>
<PostInstallDialog ref="postInstallDialog"/>
<h1 class="view-header">
{{ $t('apps.title') }}
<div style="display: flex; gap: 4px; flex-wrap: wrap; margin-top: 10px;">
<TextInput v-model="filter" :placeholder="$t('apps.searchPlaceholder')" />
<TextInput v-model="filter" ref="searchInput" :placeholder="$t('apps.searchPlaceholder')" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="tagFilterOptions" option-key="id" option-label="name" v-model="tagFilter" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="stateFilterOptions" option-key="id" v-model="stateFilter" />
<SingleSelect class="pankow-no-mobile" v-if="profile.isAtLeastAdmin" :search-threshold="10" :options="domainFilterOptions" option-key="id" option-label="domain" v-model="domainFilter" />
@@ -307,7 +332,7 @@ onDeactivated(() => {
</h1>
<div v-if="!ready">
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
<ProgressBar mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
</div>
<div v-else>
<div v-if="apps.length && filteredApps.length === 0" class="no-matches-placeholder">
@@ -315,13 +340,13 @@ onDeactivated(() => {
</div>
<TransitionGroup name="grid-animation" tag="div" class="grid" v-if="viewType === VIEW_TYPE.GRID">
<a v-for="app in filteredApps" :key="app.id" class="grid-item" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" @click="onOpenApp(app, $event)" :href="'https://' + app.fqdn" target="_blank" :style="{ width: itemWidth }">
<a v-for="app in filteredApps" :key="app.id" class="grid-item" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" @click="onOpenApp(app, $event)" :href="app.origin" target="_blank" :style="{ width: itemWidth }">
<img :alt="app.label || app.subdomain || app.fqdn" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
<div class="grid-item-label" v-fit-text>{{ app.label || app.subdomain || app.fqdn }}</div>
<div class="grid-item-task-label" v-if="app.type === APP_TYPES.LINK">{{ $t('app.appLink.title') }}</div>
<div class="grid-item-task-label" v-else>{{ AppsModel.installationStateLabel(app) }}</div>
<ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>
<ProgressBar v-if="app.progress && isOperator(app)" :busy="true" :show-track="false" :value="Math.max(10, app.progress)" :show-label="false" class="apps-progress"/>
<a class="config" v-show="isOperator(app)" @click="openAppEdit(app, $event)" :href="`#/app/${app.id}/info`" :title="$t('app.configureTooltip')"><Icon icon="fa-solid fa-cog" /></a>
<div class="grid-item-indictors">
@@ -335,12 +360,12 @@ onDeactivated(() => {
<TableView :columns="listColumns" :model="filteredApps">
<template #icon="app">
<a :href="'https://' + app.fqdn" target="_blank">
<a :href="app.origin" target="_blank">
<img :alt="app.label || app.subdomain || app.fqdn" class="list-icon" :class="{ 'item-inactive': app.runState === RSTATES.STOPPED }" :src="app.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
</a>
</template>
<template #label="app">
<a :href="'https://' + app.fqdn" target="_blank">
<a :href="app.origin" target="_blank">
{{ app.label || app.subdomain || app.fqdn }}
</a>
</template>
@@ -348,7 +373,7 @@ onDeactivated(() => {
{{ app.manifest.title }}
</template>
<template #fqdn="app">
<a :href="'https://' + app.fqdn" target="_blank">
<a :href="app.origin" target="_blank">
{{ app.fqdn }}
</a>
</template>
@@ -371,11 +396,8 @@ onDeactivated(() => {
</div>
</template>
<template #actions="app">
<div style="text-align: right;">
<!-- TODO v-tooltip="$t('app.configureTooltip')" but needs pankow fix -->
<Button class="action-button" tool plain secondary @click="openAppEdit(app, $event)" :href="`#/app/${app.id}/info`" icon="fa-solid fa-cog" />
<Button tool plain secondary @click.capture="onActionMenu(app, $event)" icon="fa-solid fa-ellipsis" />
</div>
<ActionBar v-if="app.type === APP_TYPES.LINK" :actions="createAppLinkActionMenu(app)" />
<ActionBar v-else :actions="createAppActionMenu(app)" />
</template>
</TableView>
</div>
+24 -21
View File
@@ -5,11 +5,12 @@ const i18n = useI18n();
const t = i18n.t;
import moment from 'moment';
import { ref, computed, useTemplateRef, onActivated, onDeactivated, inject, watch, nextTick } from 'vue';
import { TextInput, ProgressBar, InputDialog, SingleSelect } from '@cloudron/pankow';
import { ref, computed, useTemplateRef, onActivated, onDeactivated, inject, watch } from 'vue';
import { Button, TextInput, ProgressBar, InputDialog, SingleSelect } from '@cloudron/pankow';
import AppsModel from '../models/AppsModel.js';
import AppstoreModel from '../models/AppstoreModel.js';
import DomainsModel from '../models/DomainsModel.js';
import ApplinkDialog from '../components/ApplinkDialog.vue';
import AppInstallDialog from '../components/AppInstallDialog.vue';
import AppStoreItem from '../components/AppStoreItem.vue';
@@ -108,9 +109,6 @@ const categories = [
async function onAppInstallDialogClose() {
window.location.href = '#/appstore';
await nextTick();
if (searchInput.value) searchInput.value.$el.focus();
}
function onInstall(app) {
@@ -127,16 +125,6 @@ async function getAppList() {
apps.value = result;
}
async function getApp(id, version = '') {
const [error, result] = await appstoreModel.get(id, version);
if (error) {
console.error(error);
return null;
}
return result;
}
async function getInstalledApps() {
const [error, result] = await appsModel.list();
if (error) return console.error(error);
@@ -155,10 +143,10 @@ async function onHashChange() {
const params = new URLSearchParams(window.location.hash.slice(window.location.hash.indexOf('?')));
const version = params.get('version') || 'latest';
const app = await getApp(appId, version);
if (app) {
appInstallDialog.value.open(app, installedApps.value.length >= features.value.appMaxCount, domains.value);
} else {
try {
await appInstallDialog.value.open(appId, version, installedApps.value.length >= features.value.appMaxCount, domains.value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
inputDialog.value.info({
title: t('appstore.appNotFoundDialog.title'),
message: t('appstore.appNotFoundDialog.description', { appId, version }),
@@ -189,11 +177,23 @@ async function getDomains() {
domains.value = result;
}
const applinkDialog = useTemplateRef('applinkDialog');
function onAddAppLink() {
applinkDialog.value.open();
}
function onApplinkAdded() {
window.location.href = '#/apps';
}
onActivated(async () => {
setItemWidth();
await getAppList();
// only wait for applisting if we don't have one yet
if (apps.value.length) getAppList();
else await getAppList();
ready.value = true;
await getDomains();
@@ -221,15 +221,18 @@ onDeactivated(() => {
<template>
<div ref="view" class="content-large" style="width: 100%; height: 100%;">
<InputDialog ref="inputDialog"/>
<ApplinkDialog ref="applinkDialog" @success="onApplinkAdded"/>
<AppInstallDialog ref="appInstallDialog" @close="onAppInstallDialogClose"/>
<div class="filter-bar">
<SingleSelect v-model="category" :options="categories" option-key="id" option-label="label" :disabled="!ready"/>
<TextInput ref="searchInput" @keydown.esc="search = ''" v-model="search" :disabled="!ready" :placeholder="$t('appstore.searchPlaceholder')" style="flex-grow: 1;" autocomplete="off"/>
<Button tool outline href="/#/appstore/io.cloudron.builtin.appproxy">Add app proxy</Button>
<Button tool outline @click="onAddAppLink()">Add external link</Button>
</div>
<div v-if="!ready" style="margin-top: 15px">
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
<ProgressBar mode="indeterminate" :show-label="false" :slim="true" :show-track="false"/>
</div>
<div v-else-if="appstoreTokenError">
Cloudron not registered. Reset registration <a href="#/cloudron-account">here</a>.
+57 -58
View File
@@ -5,8 +5,9 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, reactive, inject } from 'vue';
import { Button, Menu, ProgressBar, InputDialog } from '@cloudron/pankow';
import { Button, ProgressBar, InputDialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import ActionBar from '../components/ActionBar.vue';
import Section from '../components/Section.vue';
import StateLED from '../components/StateLED.vue';
import BackupSiteScheduleDialog from '../components/BackupSiteScheduleDialog.vue';
@@ -17,12 +18,16 @@ import SystemBackupList from '../components/SystemBackupList.vue';
import { TASK_TYPES } from '../constants.js';
import BackupSitesModel from '../models/BackupSitesModel.js';
import TasksModel from '../models/TasksModel.js';
import { cronDays, cronHours, regionName } from '../utils.js';
import AppsModel from '../models/AppsModel.js';
import { prettySchedule, prettySiteLocation } from '../utils.js';
const profile = inject('profile');
const tasksModel = TasksModel.create();
const backupSitesModels = BackupSitesModel.create();
const appsModel = AppsModel.create();
const allApps = ref([]);
const inputDialog = useTemplateRef('inputDialog');
const systemBackupList = useTemplateRef('systemBackupList');
@@ -50,30 +55,6 @@ function onEditConfig(site) {
backupSiteConfigDialog.value.open(site);
}
function prettyBackupSchedule(pattern) {
if (!pattern) return '';
const tmp = pattern.split(' ');
if (tmp.length === 1) return pattern.charAt(0).toUpperCase() + pattern.slice(1); // case for 'never' - capitalize
const hours = tmp[2].split(',').sort((a, b) => Number(a) - Number(b));
const days = tmp[5].split(',').sort((a, b) => Number(a) - Number(b));
let prettyDay;
if (days.length === 7 || days[0] === '*') {
prettyDay = 'Everyday';
} else {
prettyDay = days.map(function (day) { return cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(',');
}
let prettyHour;
if (hours.length === 24 || hours[0] === '*') {
prettyHour = 'hourly';
} else {
prettyHour = hours.map(function (hour) { return cronHours[parseInt(hour, 10)].name; }).join(',');
}
return prettyDay + ' @ ' + prettyHour;
};
function prettyBackupRetention(retention) {
function stableStringify(obj) { return JSON.stringify(obj, Object.keys(obj).sort()); }
const tmp = BackupSitesModel.backupRetentions.find(function (p) { return stableStringify(p.id) === stableStringify(retention); });
@@ -82,8 +63,26 @@ function prettyBackupRetention(retention) {
function prettyBackupContents(contents) {
if (!contents) return 'Everything';
if (contents.include) return `Only ${contents.include.length} item(s)`;
if (contents.exclude) return `Exclude ${contents.exclude.length} item(s)`;
// compute include or exclude links
const links = [];
for (const appId of (contents.include || contents.exclude)) {
if (appId === 'box') {
links.unshift('System &amp; email'); // keep this as first item
} else {
const label = allApps[appId] ? (allApps[appId].label || allApps[appId].fqdn) : appId;
links.push(`<a href="#/app/${appId}/info">${label}</a>`);
}
}
if (contents.include) {
return `Only ${links.join(', ')}`;
}
if (contents.exclude) {
return `Everything except ${links.join(', ')}`;
}
return '';
}
@@ -138,8 +137,6 @@ async function onStartBackup(site) {
site.task = task;
setTimeout(waitForSiteTask.bind(null,site), 2000);
systemBackupList.value.refresh();
}
async function onStartCleanup(site) {
@@ -152,15 +149,10 @@ async function onStartCleanup(site) {
site.task = task;
setTimeout(waitForSiteTask.bind(null,site), 2000);
systemBackupList.value.refresh();
}
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(site, event) {
actionMenuModel.value = [{
function createActionMenu(site) {
return [{
icon: 'fa-solid fa-screwdriver-wrench',
label: t('backups.configAction'),
visible: profile.value.isAtLeastOwner,
@@ -181,15 +173,19 @@ function onActionMenu(site, event) {
}, {
icon: 'fa-solid fa-plus',
label: t('backups.listing.backupNow'),
disabled: !!site.task?.active,
quickAction: true,
action: onStartBackup.bind(null, site),
}, {
icon: 'fa-solid fa-broom',
label: t('backups.listing.cleanupBackups'),
disabled: !!site.task?.active,
action: onStartCleanup.bind(null, site),
}, {
icon: 'fa-solid fa-sync-alt',
label: t('backups.location.remount'),
visible: site.provider === 'sshfs' || site.provider === 'cifs' || site.provider === 'nfs' || site.provider === 'ext4' || site.provider === 'xfs',
quickAction: true,
action: onRemount.bind(null, site),
}, {
visible: profile.value.isAtLeastOwner,
@@ -200,8 +196,6 @@ function onActionMenu(site, event) {
visible: profile.value.isAtLeastOwner,
action: onRemoveSite.bind(null, site),
}];
actionMenuElement.value.open(event, event.currentTarget);
}
async function waitForSiteTask(site) {
@@ -214,6 +208,7 @@ async function waitForSiteTask(site) {
site.task = result;
setTimeout(waitForSiteTask.bind(null, site), 2000);
} else {
systemBackupList.value.refresh();
site.task = result;
}
}
@@ -274,6 +269,11 @@ async function refresh() {
}
onMounted(async () => {
const [error, result] = await appsModel.list();
if (error) return console.error(error);
allApps.value = {};
result.forEach(app => allApps[app.id] = app);
await refresh();
});
@@ -281,7 +281,6 @@ onMounted(async () => {
<template>
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<BackupSiteAddDialog ref="backupSiteAddDialog" @success="refresh()"/>
<BackupSiteContentDialog ref="backupSiteContentDialog" @success="refresh()"/>
@@ -293,6 +292,10 @@ onMounted(async () => {
<Button v-if="profile.isAtLeastOwner" @click="onAdd()"> {{ $t('main.action.add') }}</Button>
</template>
<div>{{ $t('backup.sites.description') }}</div>
<br/>
<div>
<ProgressBar mode="indeterminate" v-if="busy" slim :show-label="false" />
<div v-if="!busy && sites.length === 0" class="empty-placeholder">{{ $t('backup.sites.emptyPlaceholder') }}</div>
@@ -302,8 +305,8 @@ onMounted(async () => {
</div>
<div class="backup-site-details">
<div style="margin-bottom: 5px; display: flex; justify-content: space-between; align-items: baseline;">
<div><b style="font-size: 18px">{{ site.name }}</b><i style="margin-left: 10px">{{ prettyBackupContents(site.contents) }}</i></div>
<Button tool plain secondary @click.capture="onActionMenu(site, $event)" icon="fa-solid fa-ellipsis" />
<div><b style="font-size: 18px">{{ site.name }}</b></div>
<ActionBar :actions="createActionMenu(site)"/>
</div>
<div v-if="site.encrypted">
@@ -313,33 +316,29 @@ onMounted(async () => {
</div>
<div>
Storage: <b>{{ site.provider }} ({{ site.format }}) </b>
<span>at
<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>
</span>
<b>Storage:</b> {{ site.provider }} ({{ site.format }})
<span>at {{ prettySiteLocation(site) }}</span>
</div>
<div>
{{ $t('backups.schedule.schedule') }}: <b>{{ prettyBackupSchedule(site.schedule) }}</b>
<b>Content:</b> <span v-html="prettyBackupContents(site.contents)"></span>
</div>
<div>
<b>{{ $t('backups.schedule.schedule') }}:</b> {{ prettySchedule(site.schedule) }}
</div>
<div>
{{ $t('backups.schedule.retentionPolicy') }}: <b>{{ prettyBackupRetention(site.retention) }}</b>
<b>{{ $t('backups.schedule.retentionPolicy') }}:</b> {{ prettyBackupRetention(site.retention) }}
</div>
<div class="backup-site-task">
<div v-if="!site.task">
{{ $t('backup.sites.lastRun') }}:
<b v-if="site.taskLoaded">Never</b>
<b>{{ $t('backup.sites.lastRun') }}:</b>
<span v-if="site.taskLoaded">Never</span>
<span v-else>...</span>
</div>
<div v-if="site.task && site.task.success">{{ $t('backup.sites.lastRun') }}: <b>{{ prettyLongDate(site.task.ts) }}</b></div>
<div v-if="site.task && site.task.success"><b>{{ $t('backup.sites.lastRun') }}:</b> {{ prettyLongDate(site.task.ts) }}</div>
<div v-if="site.task && site.task.error">
{{ $t('backup.sites.lastRun') }}: <b>{{ prettyLongDate(site.task.ts) }}</b>
<b>{{ $t('backup.sites.lastRun') }}:</b> {{ prettyLongDate(site.task.ts) }}
<div style="margin-top: 5px">
<a :href="`/logs.html?taskId=${site.task.id}`" target="_blank"><span class="error-label">{{ site.task.error.message }} <Button small plain tool>Logs</Button></span></a>
</div>
+21 -11
View File
@@ -8,7 +8,6 @@ import { ref, onMounted, onUnmounted, useTemplateRef, computed, inject } from 'v
import { Button, ProgressBar, InputDialog, ClipboardAction } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import Section from '../components/Section.vue';
import SettingsItem from '../components/SettingsItem.vue';
import AppstoreModel from '../models/AppstoreModel.js';
import DashboardModel from '../models/DashboardModel.js';
@@ -24,6 +23,7 @@ const cloudronId = ref('');
const planId = ref('');
const planName = ref('');
const cancelAt = ref(0);
const renewsAt = ref(0);
const status = ref('');
const refreshFeatures = inject('refreshFeatures');
@@ -45,6 +45,7 @@ async function refresh() {
planId.value = result.plan.id;
planName.value = result.plan.name;
cancelAt.value = result.cancel_at;
renewsAt.value = result.current_period_end;
status.value = result.status;
await refreshFeatures();
@@ -127,8 +128,20 @@ onUnmounted(() => {
</div>
<div class="info-row">
<div class="info-label">{{ $t('settings.appstoreAccount.subscription') }} <span v-if="cancelAt" class="error-label">{{ $t('settings.appstoreAccount.subscriptionEndsAt') }} {{ prettyLongDate(cancelAt*1000) }}</span></div>
<div class="info-value">{{ planName }}</div>
<div class="info-label">{{ $t('settings.appstoreAccount.subscription') }}</div>
<div class="info-value">
{{ planName }}
</div>
</div>
<div class="info-row" v-if="planId !== 'free'">
<div class="info-label">Status</div>
<div class="info-value">
<span v-if="cancelAt"><b class="text-danger">Canceled</b> (Ends on {{ prettyLongDate(cancelAt*1000) }})</span>
<span v-else-if="status === 'canceled'"><b class="text-danger">Canceled</b></span>
<span v-else-if="status === 'past_due' || status === 'incomplete_expired'"><b class="text-danger">Expired</b></span>
<span v-else><b class="text-success">Active</b> (Renews on {{ prettyLongDate(renewsAt*1000) }})</span>
</div>
</div>
<div class="button-bar" style="margin-top: 20px">
@@ -142,14 +155,11 @@ onUnmounted(() => {
</div>
</div>
<SettingsItem v-else>
<div style="display: flex; align-items: center;">
Unknown Cloudron ID or invalid cloudron.io token.
</div>
<div style="display: flex; align-items: center;">
<Button @click="onUnlinkAccount">{{ $t('settings.appstoreAccount.unlinkAction') }}</Button>
</div>
</SettingsItem>
<div v-else>
<div>Unknown Cloudron ID or invalid cloudron.io token</div>
<br/>
<Button @click="onUnlinkAccount">{{ $t('settings.appstoreAccount.unlinkAction') }}</Button>
</div>
</div>
<div v-else>
<ProgressBar mode="indeterminate" slim :show-label="false"/>
+8 -14
View File
@@ -5,8 +5,9 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, useTemplateRef, computed, inject } from 'vue';
import { Button, TableView, TextInput, InputDialog, Menu } from '@cloudron/pankow';
import { Button, TableView, TextInput, InputDialog } from '@cloudron/pankow';
import Certificates from '../components/Certificates.vue';
import ActionBar from '../components/ActionBar.vue';
import SyncDns from '../components/SyncDns.vue';
import DomainDialog from '../components/DomainDialog.vue';
import DashboardDomain from '../components/DashboardDomain.vue';
@@ -72,23 +73,21 @@ const columns = ref({
hideMobile: true,
},
actions: {
label: '',
sort: false
width: '100px',
}
});
const actionMenuModel = ref([]);
const actionMenuElement = useTemplateRef('actionMenuElement');
function onActionMenu(domain, event) {
actionMenuModel.value = [{
function createActionMenu(domain) {
return [{
icon: 'fa-solid fa-pencil-alt',
label: t('main.action.edit'),
quickAction: true,
action: onEdit.bind(null, domain),
},{
separator: true,
}, {
icon: 'fa-solid fa-atlas',
label: t('domains.tooltipWellKnown'),
label: t('domains.wellknown.editAction'),
action: () => wellKnownDialog.value.open(domain),
}, {
separator: true,
@@ -98,8 +97,6 @@ function onActionMenu(domain, event) {
disabled: domain.domain === dashboardDomain.value,
action: onRemove.bind(null, domain),
}];
actionMenuElement.value.open(event, event.currentTarget);
}
const filteredDomains = computed(() => {
@@ -134,7 +131,6 @@ onMounted(async () => {
<template>
<div class="content">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<DomainDialog ref="domainDialog" @success="refreshDomains()"/>
<WellKnownDialog ref="wellKnownDialog" @success="refreshDomains()"/>
@@ -159,9 +155,7 @@ onMounted(async () => {
{{ DomainsModel.prettyProviderName(domain.provider) }}
</template>
<template #actions="domain">
<div style="text-align: right;">
<Button tool plain secondary @click.capture="onActionMenu(domain, $event)" icon="fa-solid fa-ellipsis" />
</div>
<ActionBar :actions="createActionMenu(domain)" />
</template>
</TableView>
</Section>
+1 -1
View File
@@ -45,7 +45,7 @@ async function onSendTestMail() {
const address = await inputDialog.value.prompt({
value: result.email,
title: t('emails.testMailDialog.title', { domain: domain.value }),
title: t('emails.testMailDialog.title'),
message: t('emails.testMailDialog.description', { domain: domain.value }),
confirmLabel: t('emails.testMailDialog.sendAction'),
rejectLabel: t('main.dialog.cancel'),
+4 -5
View File
@@ -13,7 +13,6 @@ const domainsModel = DomainsModel.create();
const mailModel = MailModel.create();
const domains = ref([]);
const busy = ref(true);
const searchFilter = ref('');
@@ -58,10 +57,10 @@ async function refreshUsage() {
domain.relayProvider = result.relay ? result.relay.provider : 'unset';
// do this even if no outbound since people forget to remove mailboxes
[error, result] = await mailModel.mailboxCount(domain.domain);
[error, result] = await mailModel.stats(domain.domain);
if (error) console.error(error);
domain.mailboxCount = result;
domain.mailboxCount = result.mailboxCount;
// this may temporarily fail while the mail server is restarting
while (true) {
@@ -112,7 +111,7 @@ onMounted(async () => {
<div class="content">
<Section :title="$t('emails.domains.title')">
<template #header-title-extra>
<span style="font-weight: normal; font-size: 14px">({{ busy ? '-' : filteredDomains.length }})</span>
<span style="font-weight: normal; font-size: 14px">({{ domains.length === 0 ? '-' : filteredDomains.length }})</span>
</template>
<template #header-buttons>
@@ -120,7 +119,7 @@ onMounted(async () => {
</template>
<div>
<div v-if="filteredDomains.length === 0" class="email-placeholder">{{ $t('domains.noMatchesPlaceholder') }}</div>
<div v-if="domains.length !== 0 && filteredDomains.length === 0" class="email-placeholder">{{ $t('domains.noMatchesPlaceholder') }}</div>
<a v-for="domain in filteredDomains" :key="domain.domain" :href="`#/email-domain/${domain.domain}`" class="email-domain">
<div style="display: flex; align-items: center;">
<StateLED :busy="domain.loadingStatus" :state="domain.status ? 'success' : 'danger'"/>

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