Compare commits

..

1183 Commits

Author SHA1 Message Date
Johannes Zellner cfe7bb53e6 Add local authserver to provide /verify-credentials route
This is used for apps which are using OpenID to login but still need to
be able to verify the users password or app password
2026-04-02 21:38:37 +02:00
Johannes Zellner b2ca6206cc Fix dashboard lock file to work with node 24.13.0 2026-04-02 20:09:29 +02:00
Johannes Zellner 918c2f8587 Move to @cloudron/safetydance 2026-04-01 09:49:34 +02:00
Girish Ramakrishnan 8f851164d6 reboot: fix dashboard link 2026-04-01 09:25:10 +02:00
Johannes Zellner d215d1998f Update docs link for tls provider 2026-03-31 13:51:08 +02:00
Girish Ramakrishnan 75e3256497 mail: update haraka to 3.1.4 2026-03-31 12:22:37 +02:00
Girish Ramakrishnan 58f5a17a83 mail: remove queue proxy
this has never worked well
2026-03-31 11:36:16 +02:00
Girish Ramakrishnan e7c3d797be rsync: reupload files with corrupt integrity
we found sha256: null as the integrity in some of the cache files.
not sure how this happenned. for now, we just mark files with invalid
or missing sha256 for re-upload.
2026-03-31 11:31:17 +02:00
Girish Ramakrishnan 34abd5b8f5 9.1.6 changes 2026-03-30 14:40:26 +02:00
Girish Ramakrishnan 8b138d14bb backup site: remove the local disk provider
we already have ext4, xfs, mountpoint and filesystem to cover all cases

fixes #879
2026-03-30 14:37:48 +02:00
Johannes Zellner e23abd69b5 Update frontend dependencies 2026-03-30 13:54:26 +02:00
Girish Ramakrishnan 9c16ad456d backups: set focus in the edit dialog 2026-03-30 13:52:54 +02:00
Girish Ramakrishnan 4b851afc6a location: show what DNS is being overwritten in location UI
fixes #858
2026-03-30 13:43:07 +02:00
Girish Ramakrishnan f333148afa Update translations 2026-03-30 13:07:56 +02:00
Girish Ramakrishnan 8d0160a3e7 app configure: refresh app when a task is started 2026-03-30 10:25:26 +02:00
Girish Ramakrishnan 4a02e988c1 location: fix duplication of port bindings on submit 2026-03-30 09:47:05 +02:00
Girish Ramakrishnan 134472cd4b cloudron-support: services could be lazy-stopped 2026-03-28 14:46:00 +01:00
Girish Ramakrishnan b40a10da7b restore: prune portBindings whose tcpPorts/udpPorts no longer exist
fixes #871
2026-03-27 18:47:52 +01:00
Girish Ramakrishnan 25f5b33d17 Remove unused secondaryDomains in update and restore code paths
fixes #814
2026-03-27 17:46:28 +01:00
Girish Ramakrishnan f57c39bba2 repair: rebuild image 2026-03-27 16:17:41 +01:00
Girish Ramakrishnan 99b234eca8 source install: persist buildConfig so restore, import, clone work correctly 2026-03-27 16:10:43 +01:00
Girish Ramakrishnan 9c3c8cc9d1 rename promise-retry to retry 2026-03-27 11:39:38 +01:00
Girish Ramakrishnan b08e3a5128 docker: attempt container recreate a few times
Docker Error: (HTTP code 500) server error - failed to set up container networking: driver failed programming external connectivity on endpoint a877975d-38be-4088-bc92-e0d7a486a818 (2e5adaa635a95bd65ca0f290712065d444528e3420c49f2f88323b40c62caaa5): failed to bind host port for 0.0.0.0:40014:172.18.16.130:40014/tcp: address already in use

This happens during app updates. Can only be two reasons:

- some race in docker not freeing up ports (unlikely)
- ephemeral port got reallocated between destroy and create as part of app update

A future commit will reserve net.ipv4.ip_local_reserved_ports as well
2026-03-27 10:29:26 +01:00
Girish Ramakrishnan e48cdc85f7 notifications: subscribe owner and users to all by default 2026-03-27 09:14:18 +01:00
Johannes Zellner a5da68a7f9 Fix overflow issue in eventlog 2026-03-26 16:15:47 +01:00
Johannes Zellner 7d594ab0d3 Also search for matches in app links labels for apps view filter 2026-03-25 22:58:55 +01:00
Johannes Zellner 9ed3d668ee Add .cursor to gitignore 2026-03-23 17:02:31 +01:00
Johannes Zellner 0da0a5e027 Show badges in header bar for expired or cancelled subs 2026-03-23 15:29:33 +01:00
Johannes Zellner 28eb0b65f4 Update dashboard dependencies 2026-03-23 11:44:12 +01:00
Johannes Zellner 1d29572ecd Wait for rest calls on app uninstall and archive 2026-03-23 11:43:25 +01:00
Johannes Zellner 07e8d242d1 fix vue warning, reactive() variables should not be const 2026-03-23 11:13:51 +01:00
Johannes Zellner 1586a286d8 Fix notifications scrolling 2026-03-23 10:42:30 +01:00
Girish Ramakrishnan 4859059eba source install: support dockerfileName and build options 2026-03-21 17:29:47 +01:00
Girish Ramakrishnan f2949c1836 notifications: send email when manual app update is required 2026-03-21 15:59:41 +01:00
Girish Ramakrishnan cd6acfb91d notifications: send email when manual platform update is required 2026-03-21 15:38:12 +01:00
Johannes Zellner 2d5dc9a6aa Fix wrong disabled state for devices config of apps 2026-03-21 08:54:42 +01:00
Girish Ramakrishnan 87e7da2aff community: auto focus to text input 2026-03-20 17:37:53 +01:00
Johannes Zellner 461eb38d88 Add comment why unused import exists 2026-03-18 14:49:18 +01:00
Johannes Zellner ba0bb62fa3 hardcode CLI name for cid-cli in device auth flow 2026-03-18 14:37:15 +01:00
Johannes Zellner 1ca62dd38e Restyle oidc device login views 2026-03-18 14:28:28 +01:00
Johannes Zellner 1b1328c601 Fix more ejs usage in oidc device login views 2026-03-18 11:13:21 +01:00
Johannes Zellner 9633036887 vueify OIDC device views 2026-03-18 10:58:12 +01:00
Girish Ramakrishnan e3d76ea9f4 uninstall: must continue to teardown other addons 2026-03-18 15:26:06 +05:30
Girish Ramakrishnan d7212e69b5 unprovision: clear the default backup site 2026-03-18 15:14:11 +05:30
Girish Ramakrishnan ead58bd6f6 test: use profile to check for passkey 2026-03-18 15:00:45 +05:30
Girish Ramakrishnan fbe13b75df passkey: fix tests 2026-03-18 14:53:00 +05:30
Girish Ramakrishnan 6085a8231f uninstall: ignore services error as services may never have started 2026-03-18 14:38:47 +05:30
Johannes Zellner e15cd190b3 Prevent user setup form if passwords dont match 2026-03-18 09:57:56 +01:00
Girish Ramakrishnan 3d55423deb Fix usage of safe() 2026-03-18 14:26:42 +05:30
Girish Ramakrishnan f62df52c1d passkey: disallow in demo mode 2026-03-18 12:28:57 +05:30
Girish Ramakrishnan 7829f94ac4 update changelog 2026-03-18 11:16:15 +05:30
Girish Ramakrishnan e9d42b9cdd migration: if no autoupdate setting, use defaults 2026-03-18 11:15:24 +05:30
Girish Ramakrishnan 1f05a8d92a network: fix crash 2026-03-18 07:04:45 +05:30
Johannes Zellner 69ae2b2997 Update eslint to v10 2026-03-17 16:16:00 +01:00
Johannes Zellner b86e47de02 Update pankow 2026-03-17 16:14:51 +01:00
Girish Ramakrishnan ea7647f43c oidcserver: fix jwks_rsaonly response 2026-03-17 17:49:52 +05:30
Johannes Zellner ae7df52780 Reduce font-weight on no apps placeholder 2026-03-17 10:27:09 +01:00
Girish Ramakrishnan bc5737b9b0 passkey: implement passwordless login 2026-03-16 20:10:59 +05:30
Girish Ramakrishnan d0745d1914 2fa: provider passkey or totp 2026-03-16 18:49:12 +05:30
Girish Ramakrishnan 2b4c926a70 only clear passkeys on location change
calling this on initialize makes it lose all passkeys
2026-03-16 18:49:01 +05:30
Girish Ramakrishnan d922c1c80f missing translation for passkey login failure 2026-03-16 17:30:14 +05:30
Girish Ramakrishnan 67500a7689 profile: hasPasskey 2026-03-16 17:20:22 +05:30
Girish Ramakrishnan 1c8aa7440c lint 2026-03-16 17:05:44 +05:30
Girish Ramakrishnan d128dbec4c fix 2fa translations 2026-03-16 17:04:30 +05:30
Girish Ramakrishnan 676cb8810b loginview: remove reset password from tab order 2026-03-16 16:40:54 +05:30
Girish Ramakrishnan 189e3d5599 allow totp and passkey to co-exist 2026-03-16 16:38:48 +05:30
Girish Ramakrishnan 009d0b39f9 rename twoFactor* to totp 2026-03-16 16:38:42 +05:30
Girish Ramakrishnan 81a8aa7c3d login: move forgot password on top of the password
making way for passkey login
2026-03-16 15:59:00 +05:30
Johannes Zellner 6c6761d14b Update to vite 8 2026-03-16 08:37:19 +01:00
Johannes Zellner 7d2e3df929 Update frontend dependencies 2026-03-16 08:33:23 +01:00
Girish Ramakrishnan f334c696cb update: add policy to update apps separately from platform 2026-03-16 10:19:18 +05:30
Girish Ramakrishnan db974d72d5 oidcserver: permit origin "*" from localhost testing 2026-03-16 07:21:55 +05:30
Girish Ramakrishnan c15e342bb8 webadmin: remove the implicit flow
we now use pkce . main advantage is that we don't see the access token
in the url anymore.

in pkce, the auth code by itself is useless. need the verifier.

fixes #844
2026-03-15 17:38:27 +05:30
Girish Ramakrishnan dc1449c7b6 oidcserver: convert to trace 2026-03-15 17:32:03 +05:30
Girish Ramakrishnan 0b305caf58 sites: add conflict detection
Fixes #863
2026-03-15 14:59:35 +05:30
Girish Ramakrishnan 8f1f3645b2 app update: if backup fails, provide a notification
fixes #851
2026-03-15 14:48:07 +05:30
Girish Ramakrishnan 0079162efe notifications: only title is bold 2026-03-15 14:26:30 +05:30
Girish Ramakrishnan 7afec06d4c apps: operators can now view backup logs and manage the backup task
we spun off the app backup as a separate task and this is not tracked
by app.taskId .

fixes #856
2026-03-15 10:18:31 +05:30
Girish Ramakrishnan 29f85a8fd2 test: fix debug 2026-03-15 09:54:55 +05:30
Girish Ramakrishnan 6e0dc24eca rsync: escape U+2028/U+2029
JSON strings can contain unescaped U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR
characters while ECMAScript strings cannot. ES2019 now allows those unescaped.

The integrity code assumes that each JSON is a single line but that assumption does
not hold true when these characters are there in the string. Fix is to escape them.
2026-03-15 09:20:40 +05:30
Girish Ramakrishnan cee1180aa7 9.1.4 changes 2026-03-14 19:10:04 +05:30
Girish Ramakrishnan 6db2b55e63 oidcserver: custom templates for device login
the default one uses google fonts :/
2026-03-13 13:25:57 +05:30
Girish Ramakrishnan a3c038781f oidc: implement Device Authorization Grant 2026-03-13 12:44:39 +05:30
Girish Ramakrishnan 59c9e5397e ldapserver, directoryserver: all traces 2026-03-12 23:30:12 +05:30
Girish Ramakrishnan a4c253b9a9 users: modify verify log calls to trace
this is only useful when we are debugging and not useful to end user
without further context
2026-03-12 23:27:13 +05:30
Girish Ramakrishnan f12b4faf34 lint 2026-03-12 23:23:23 +05:30
Girish Ramakrishnan ff49759f42 promise-retry: rename api to log 2026-03-12 23:11:16 +05:30
Girish Ramakrishnan 01d0c738bc replace debug() with our custom logger
mostly we want trace() and log(). trace() can be enabled whenever
we want by flipping a flag and restarting box
2026-03-12 23:08:35 +05:30
Girish Ramakrishnan d57554a48c backup logs: make them much terse and concise
these are making the rsync logs massive. instead resort to reporting
progress based on file count. there is also a heartbeat timer for
"stuck" or "long downloading" files, every minute.
2026-03-12 19:40:46 +05:30
Girish Ramakrishnan b16b57f38b rsync: throttle log messages during download 2026-03-12 13:47:26 +05:30
Girish Ramakrishnan 12177446a2 backupcleaner: remove cleanupSnapshotSuperfluous
this removes the workaround for the rsync bug to make integrity
checks work. see ec15f29e40 and
2c12bee79b
2026-03-12 08:07:25 +05:30
Girish Ramakrishnan 61b15db958 Update translations 2026-03-12 07:39:45 +05:30
Girish Ramakrishnan 349e8f5139 notifications: add empty text, progress bar and inifinite scroll 2026-03-12 07:33:22 +05:30
Johannes Zellner f30482808b Workaround chrome quirks on file drop handling 2026-03-11 15:11:16 +01:00
Girish Ramakrishnan 79cdecdff6 graphite: fix aggregation of block/network read/write 2026-03-10 22:56:00 +05:30
Girish Ramakrishnan 336dee53cd metrics: pick last item in series
picking the first item for "max" is not correct
2026-03-10 22:25:15 +05:30
Girish Ramakrishnan 77022bbd7f restore: apply blocklist 2026-03-10 21:34:26 +05:30
Johannes Zellner df96df776d Wait for dashboard reload when version has changed 2026-03-10 16:28:17 +01:00
Girish Ramakrishnan 67bc803859 troubleshoot: ignore package.json 2026-03-10 20:24:32 +05:30
Girish Ramakrishnan 8ef56c6d91 reverseproxy: fix restore of trusted ips 2026-03-10 17:28:06 +05:30
Girish Ramakrishnan d377d1e1cf remove deprecated url 2026-03-10 15:15:17 +05:30
Girish Ramakrishnan 4209e4d90d Some progressbars for various views 2026-03-08 19:14:30 +05:30
Girish Ramakrishnan 83c85d02ee services: load all the view services together
box/cloudron service was appearing first and the rest were appearing
a while later
2026-03-08 18:56:57 +05:30
Girish Ramakrishnan 866b72d029 services: distinguish error state and idle state for stopped containers 2026-03-08 18:36:24 +05:30
Girish Ramakrishnan 4bc0f44789 services: lazy start services / on demand services
services are now stopped when no app is using them.

on start up, services are always created and run. they are later
stopped when unused. it's this way to facilitate the upgrade code
path. the database meta files have to be upgraded even if no app is using them.
the other hook to stop unused services is at the end of an app task.

maybe mail container is a candidate for the future where all sending is no-op.
But give this is rare, it's not implemented.
2026-03-08 18:35:50 +05:30
Girish Ramakrishnan 99c55cb22f services: enforce min memory limit 2026-03-05 21:25:31 +05:30
Girish Ramakrishnan 74c73c695f mongodb: set min memory to 2GB 2026-03-05 21:25:31 +05:30
Johannes Zellner b972891337 If we have no released version of an app also show an error instead of the app install dialog 2026-03-05 16:27:59 +01:00
Girish Ramakrishnan 57515d54db 9.1.3 changes 2026-03-05 16:43:53 +05:30
Johannes Zellner 0ff8dcc8e9 Remove 'Dashboard' from dashboard page title 2026-03-05 12:09:23 +01:00
Girish Ramakrishnan 38efa6a2ba integrity: show failure messages 2026-03-05 16:24:46 +05:30
Johannes Zellner 6306625184 Add vue/no-root-v-if linter rule 2026-03-05 11:53:53 +01:00
Johannes Zellner 1803ab303f Do not use v-if in toplevel nodes of a component 2026-03-05 11:41:23 +01:00
Johannes Zellner e72dd7c845 Fix warning about missing dom id 2026-03-05 11:40:50 +01:00
Johannes Zellner 87288caeb9 Stop using vue-i18n legacy api 2026-03-05 11:14:18 +01:00
Elias Hackradt 79b519e462 Fix: add label to user.value object 2026-03-05 10:05:02 +00:00
Girish Ramakrishnan 5f8ea2aecc integrity: skip check of backups with no integrity info 2026-03-04 21:18:20 +05:30
Girish Ramakrishnan 94bc52a0c3 rsync: typo 2026-03-04 20:48:00 +05:30
Girish Ramakrishnan fed51bdcd9 backupintegrity: add percent progress 2026-03-04 18:30:16 +05:30
Girish Ramakrishnan 5fc9689645 backup: fix alignment of progressbar and logs button 2026-03-04 17:24:46 +05:30
Girish Ramakrishnan 7c6a783fc8 tasks: fix progress percents
mostly rebalancing the percents
2026-03-04 16:37:34 +05:30
Girish Ramakrishnan 764d479d7f 9.1.2 changes 2026-03-04 15:26:45 +05:30
Girish Ramakrishnan ec15f29e40 syncer: clean up superfluous files 2026-03-04 14:00:54 +05:30
Girish Ramakrishnan 2c12bee79b syncer: fix bug with a file and dir having same prefix
if we had say "app/" and "application.json", the syncer logic
was skipping over application.json because it starts with "app".
This would lead to application.json not getting deleted in the snapshot
and result in superfluous files.
2026-03-04 13:59:35 +05:30
Johannes Zellner 1120866b75 Make renewalInfo explicitly fallback to null to be visible in stringify 2026-03-04 08:46:27 +01:00
Johannes Zellner b362c069e5 fix crash, where result may be undefined on error 2026-03-04 08:39:31 +01:00
Johannes Zellner 4b6b18c182 Try to detect and set the content type of app icons 2026-03-03 22:06:50 +01:00
Johannes Zellner 80efc8c60c Update dashboard dependencies 2026-03-03 21:07:33 +01:00
Girish Ramakrishnan 99168157fc integrity: size is not a function 2026-03-03 20:24:26 +05:30
Girish Ramakrishnan 23c3263562 integrity: show log link
in the previous approach, we used to clear the taskId after
the integrity check completes. for one, we lose track of the
task (to show the logs). for another, we have to clear these
taskId on platform startup to handle crashes.

in the new approach, we keep the taskId and use the task's
active flag to determine if task is active.
2026-03-03 18:41:57 +05:30
Girish Ramakrishnan 1179a78fe1 integrity: center align the indicator 2026-03-03 17:23:16 +05:30
Girish Ramakrishnan 82677ddd85 backup: show integrity column for dependsOn backups 2026-03-03 17:18:28 +05:30
Girish Ramakrishnan 31f29e9086 integrity: show status in the info dialog 2026-03-03 16:57:55 +05:30
Girish Ramakrishnan 3b3e606573 integrity: better log messages 2026-03-03 16:13:44 +05:30
Girish Ramakrishnan 18b713cec3 oom: fix crash when source build ooms 2026-03-02 21:10:47 +05:30
Johannes Zellner 2a6d385cea Support and prefer Dockerfile.cloudron in local builds 2026-03-02 12:06:27 +01:00
Johannes Zellner 4cc1926899 Also increase body upload for app update route to work well with source builds 2026-03-02 11:45:03 +01:00
Johannes Zellner 15b2c2b739 Add Czech translations 2026-03-02 11:36:48 +01:00
Johannes Zellner 197bf56271 Update translations 2026-03-02 11:36:48 +01:00
Girish Ramakrishnan 4110f4b8ce lint 2026-03-02 10:07:31 +05:30
Girish Ramakrishnan becbaca858 appstore: better tag/cateogry mapping 2026-03-02 09:59:43 +05:30
Girish Ramakrishnan add50257f6 appstore: add ai category 2026-03-02 09:14:49 +05:30
Girish Ramakrishnan f061ee5f88 Update modules 2026-03-02 08:40:19 +05:30
Girish Ramakrishnan 480b81b3dd postgres: update pgvector to 0.8.2 2026-03-02 08:37:26 +05:30
Girish Ramakrishnan 61d4a795ae apps: enable storage view in all error states 2026-02-28 02:20:25 +01:00
Girish Ramakrishnan cd89883dbb apps: move to error state if a volume is unavailable 2026-02-28 01:02:22 +01:00
Girish Ramakrishnan d5a729a2ba remove the placeholder 2026-02-27 17:10:11 +01:00
Johannes Zellner b41533c278 Set appsview filters early to avoid flickering 2026-02-27 11:11:11 +01:00
Girish Ramakrishnan 04758587b4 update: throw error when update failed 2026-02-26 11:09:55 +01:00
Johannes Zellner b6b0969879 Allow to reset app filters when clicking on the menu entry 2026-02-26 11:05:08 +01:00
Johannes Zellner 18ef97fae6 Preserver app search and filter in query args to make back and reload work 2026-02-26 10:55:17 +01:00
Girish Ramakrishnan 333f052f86 backupsites: show warning if no site is selected for automatic backups 2026-02-26 09:03:39 +01:00
Girish Ramakrishnan 7dd40eccf3 backupsites: display automatic update backups flag 2026-02-26 08:25:40 +01:00
Girish Ramakrishnan db728840a0 backupsite: add space after last run 2026-02-26 08:00:30 +01:00
Girish Ramakrishnan 8906436824 mail: fix css styling of expected value 2026-02-26 02:03:21 +01:00
Johannes Zellner e8dedb04a5 Clear oidc client add or edit error when dialog opens 2026-02-25 21:16:17 +01:00
Elias Hackradt d4b581c007 Make MailDomainStatus expected value flexible and add copy button 2026-02-25 17:19:39 +00:00
Johannes Zellner a900beb3fd First delete mailpassword for oidc clients then the client itself 2026-02-25 18:02:31 +01:00
Johannes Zellner 19a0f77c53 Do not add empty mailclient claim unless requested 2026-02-25 16:15:35 +01:00
Johannes Zellner 6dbd97ba14 Only generate mailpassword and fetch mailboxes if the oidc client wants the mailclient scope 2026-02-25 16:07:46 +01:00
Girish Ramakrishnan d2fbea8e39 update: typo 2026-02-25 15:53:03 +01:00
Girish Ramakrishnan 86a49c6223 Fix update check translation 2026-02-25 15:24:10 +01:00
Girish Ramakrishnan e97f9b47d7 weblate: add find 2026-02-25 15:14:38 +01:00
Girish Ramakrishnan ee3ed5f660 Update lock 2026-02-25 14:53:55 +01:00
Girish Ramakrishnan 3446f3d1e0 add to changes 2026-02-25 14:52:43 +01:00
Girish Ramakrishnan 69d03a7a42 Update pankow 2026-02-25 14:49:56 +01:00
Johannes Zellner bab95cbefa Use pagination in model code for mailbox listing 2026-02-25 11:24:40 +01:00
Johannes Zellner 5ef23fa49a Bump mailbox list page size up to 10k for now 2026-02-25 10:14:23 +01:00
Girish Ramakrishnan f4ff63485a domains: validate well known 2026-02-25 05:55:14 +01:00
Girish Ramakrishnan c20fbe8635 domains: set caldav/cardav correctly on dialog open 2026-02-25 05:46:41 +01:00
Johannes Zellner 662cf65ff2 Only update domains list if component is still mounted 2026-02-24 10:58:07 +01:00
Johannes Zellner 7ded517b20 Protect against crash in EventlogList component on quick unloads 2026-02-24 10:28:13 +01:00
Girish Ramakrishnan 4be31b0dad update: if no backup site, show error message 2026-02-24 05:55:56 +01:00
Girish Ramakrishnan dc439ba5be install/update: ui must always set the appStoreId or versionsUrl 2026-02-24 05:31:13 +01:00
Girish Ramakrishnan 5ba8a05450 cifs: use rsync instead cp -aRl
for some reason, cp fails on synology cifs for some paths - immich's typesense dir
2026-02-23 17:47:05 +01:00
Girish Ramakrishnan 7ef19b318a add weblate script 2026-02-23 14:45:22 +01:00
Girish Ramakrishnan 2ac76ad852 2fa dialog translations updates 2026-02-23 14:45:14 +01:00
Girish Ramakrishnan f4598f81c9 debug: add CLOUDRON_DEBUG=1 env var 2026-02-23 10:53:06 +01:00
Girish Ramakrishnan c432dbb5bc Update translations 2026-02-23 10:50:29 +01:00
Girish Ramakrishnan d0f0bb799e 2fa: refactor into separate dialog
also rename routes to totp
2026-02-22 10:43:15 +01:00
Girish Ramakrishnan a98dbfdf4f integrity: link to the logs 2026-02-22 10:24:32 +01:00
Girish Ramakrishnan a71909acd3 Update lock file 2026-02-21 20:34:24 +01:00
Girish Ramakrishnan ea5953a397 Fix test 2026-02-21 20:27:55 +01:00
Girish Ramakrishnan 4ad9ccabe0 apps: refactor the updateInfo properties 2026-02-21 20:01:05 +01:00
Girish Ramakrishnan 17640d44fa Update changelog 2026-02-21 19:43:23 +01:00
Girish Ramakrishnan 812d471573 add backupCommand, restoreCommand, persistentDirs 2026-02-21 19:42:52 +01:00
Girish Ramakrishnan fa981d5a83 release: replace url with URL 2026-02-21 16:46:29 +01:00
Girish Ramakrishnan 202f2c6cb0 updater: keep updateInfo response same as in the app object 2026-02-21 16:43:03 +01:00
Girish Ramakrishnan 55359bfa24 Update haraka to 3.1.3 2026-02-21 14:13:50 +01:00
Girish Ramakrishnan 95fcfce9cd add "community" to packager info 2026-02-21 12:12:36 +01:00
Girish Ramakrishnan 3120a2c43f appstore: more state information 2026-02-21 12:05:56 +01:00
Girish Ramakrishnan 7ba3a59dea eventlog: add flag for source builds 2026-02-21 11:48:39 +01:00
Girish Ramakrishnan eb5f8fcfa1 community: better resolution of url 2026-02-21 11:17:47 +01:00
Girish Ramakrishnan 5014227028 community: validateForm on dialog open 2026-02-21 11:11:34 +01:00
Girish Ramakrishnan 7a76de2e4c reorder menu items to be alphabetical 2026-02-21 11:11:34 +01:00
Johannes Zellner de5692c1af Immediately refresh the app after checking for updates 2026-02-20 22:27:30 +01:00
Johannes Zellner 555e4f0e65 Only set postinstall pending in localstorage if a message exists in the manifest 2026-02-20 22:17:10 +01:00
Girish Ramakrishnan 723c670100 reverseproxy: fix crash 2026-02-20 19:46:43 +01:00
Johannes Zellner 2f951dc272 Support card/cal dav well-known endpoints 2026-02-20 15:46:43 +01:00
Girish Ramakrishnan 0daabdc21c reverseproxy: for large payloads, turn off proxy buffering 2026-02-20 09:59:21 +01:00
Girish Ramakrishnan 38a187e9fc apps: install route needs more upload limit 2026-02-20 09:54:13 +01:00
Girish Ramakrishnan 5a613231e0 apps: remove deprecated /api/v1/apps/install route 2026-02-20 09:48:07 +01:00
Girish Ramakrishnan 28a35e7260 community: handle unstable flag 2026-02-19 22:48:16 +01:00
Girish Ramakrishnan 461a5a780d community: resolve user provided url 2026-02-19 22:25:43 +01:00
Girish Ramakrishnan 207260821b reverseproxy: add forceRenewal check if only for completeness 2026-02-19 22:25:43 +01:00
Johannes Zellner 466527884f Fix acl logic for token inspection endpoint 2026-02-19 19:09:02 +01:00
Johannes Zellner 9d03eb2643 Check internal ACL during token introspection 2026-02-19 18:05:49 +01:00
Johannes Zellner c801202642 Fix variable usage after no-shadow fixes 2026-02-19 18:05:36 +01:00
Girish Ramakrishnan 95952fae75 update manifestformat 2026-02-19 14:40:29 +01:00
Girish Ramakrishnan f62629b513 mailpassword: fix test, remove unused function 2026-02-19 14:22:36 +01:00
Girish Ramakrishnan f04087815c Fix failing test 2026-02-19 13:48:48 +01:00
Girish Ramakrishnan 255b1c63d0 Update modules 2026-02-19 13:36:26 +01:00
Girish Ramakrishnan 9b5b8ddc22 dockerproxy: close all connections 2026-02-19 13:33:02 +01:00
Girish Ramakrishnan d0a66f1701 Back to mocha!
sorry i ever left you dear mocha

node:test has two major issues:
* --bail does not work and requires strange modules and incantations.
I was able to work around this with a custom module.

* the test reporter reports _after_ the suite is run. this makes debugging
really hard. the debugs that we print all happen before the test suite summary.
poor design overall.
2026-02-19 13:24:14 +01:00
Girish Ramakrishnan c176ac600b migrate tests to node:test 2026-02-19 10:13:22 +01:00
Girish Ramakrishnan cf0ab16533 fix test 2026-02-18 20:20:26 +01:00
Girish Ramakrishnan 03d0e2157e Update modules 2026-02-18 19:59:58 +01:00
Girish Ramakrishnan cdd5137ebe Update addons to latest builds 2026-02-18 17:44:18 +01:00
Johannes Zellner 0a924b2c29 Cleanup mailPasswords when oidc client is removed not only when an app is uninstalled 2026-02-18 17:15:06 +01:00
Johannes Zellner 43acecfc6e mailPasswords table should work with oidc clients not apps 2026-02-18 15:17:08 +01:00
Johannes Zellner 5e7e739589 Enable token inspection endpoints in oidc 2026-02-18 15:00:02 +01:00
Elias Hackradt 0b968b6a98 Use branding.getCloudronName(); for totp secret name metadata 2026-02-18 13:19:27 +00:00
Johannes Zellner f14dfb6c17 Fix typo 2026-02-18 11:27:45 +01:00
Johannes Zellner cb5ccd8166 Also auth against mailPasswords in ldapserver.js 2026-02-18 10:12:34 +01:00
Johannes Zellner bfbcbb686d Send an email accessToken alongside the mailclient claims 2026-02-18 10:12:34 +01:00
Johannes Zellner 744300744c Fix claim name to mailclient 2026-02-18 10:12:34 +01:00
Johannes Zellner 9bac099339 Add mailPassword table
This table stores email credentials for users using apps which use the
email addon
2026-02-18 10:12:34 +01:00
Johannes Zellner 135c9fb64d Support mailclient oidc claim
Only apps with addon email have access to the claims' scopes
2026-02-18 10:12:34 +01:00
Girish Ramakrishnan 4ed6fbbd74 eslint: add no-shadow 2026-02-18 08:18:37 +01:00
Girish Ramakrishnan 4d3e9dc49b Fix shadow of pipeline variable 2026-02-18 07:58:47 +01:00
Girish Ramakrishnan 319360f8d0 lint 2026-02-17 19:51:09 +01:00
Girish Ramakrishnan 3ef990b0bf Fix typo 2026-02-17 18:48:07 +01:00
Girish Ramakrishnan b8ae46b6df add passkey tests 2026-02-17 18:05:14 +01:00
Girish Ramakrishnan 113aba0897 remove the cors test 2026-02-17 17:15:30 +01:00
Girish Ramakrishnan a51672f3ee security: remove cors
I traced this back to a commit from 2014! 781495e662
2026-02-17 17:10:18 +01:00
Girish Ramakrishnan f08b3eb006 mail: bring eventlog up to speed 2026-02-17 16:18:37 +01:00
Girish Ramakrishnan 66f65093fc eventlog: fix the description in app context 2026-02-17 15:41:09 +01:00
Girish Ramakrishnan d78944e03b eventlog: add volume remount 2026-02-17 15:32:22 +01:00
Girish Ramakrishnan 2fe31b876f Add EVENTS to constants 2026-02-17 15:30:44 +01:00
Girish Ramakrishnan 9949ea364a eventlog: move to table view 2026-02-17 15:12:45 +01:00
Girish Ramakrishnan 77b7f7bfad eventlog: make a component and use it in app and system 2026-02-17 14:42:40 +01:00
Girish Ramakrishnan 8d4b458a22 eventlog: fix date range and empty string search 2026-02-17 13:49:10 +01:00
Girish Ramakrishnan 2df8e77733 eventlog: implement contextual highlight 2026-02-17 13:21:06 +01:00
Johannes Zellner c21011a17a support ID_CLI oidc client 2026-02-16 23:19:37 +01:00
Girish Ramakrishnan a11a691788 eventlog: add to/from date picker 2026-02-16 22:03:18 +01:00
Girish Ramakrishnan 81659d4bf2 eventlog: add params for from and to date 2026-02-16 20:52:02 +01:00
Girish Ramakrishnan aab20fd23e filemanager: move the title to top left like terminal 2026-02-16 14:21:46 +01:00
Johannes Zellner 5fad4dd034 Ensure users without totp nor passkey are forced to setup one if mandatory2FA is enabled 2026-02-16 14:01:14 +01:00
Johannes Zellner 7bc19e8185 Cleanup passkeys on user deletion 2026-02-16 13:52:04 +01:00
Johannes Zellner 45d0928ff9 Ask the user for confirmation on dashboard domain change 2026-02-16 12:33:09 +01:00
Johannes Zellner 9b768273f4 provide a global InputDialog for dashboard views 2026-02-16 12:32:43 +01:00
Johannes Zellner ef24b17a70 Drop all passkeys if the dashboard domain changes 2026-02-16 12:06:12 +01:00
Girish Ramakrishnan dfbe5aaa16 filemanager: remove breadcrumb now that we have treeview 2026-02-16 11:29:35 +01:00
Girish Ramakrishnan f499c9ada9 integrity: add eventlog status for historic checks 2026-02-15 23:40:23 +01:00
Girish Ramakrishnan c1a73aa62a integrity: just clear last info on a (re)start
this way if a user stops it midway, the old info is cleared
2026-02-15 23:26:06 +01:00
Girish Ramakrishnan 601e787500 rsync: fix integrity check 2026-02-15 23:17:23 +01:00
Girish Ramakrishnan d24bfabdc1 info: only show packager for custom apps 2026-02-15 20:14:05 +01:00
Girish Ramakrishnan 2c559d63f5 Fix bugs in esm migration 2026-02-15 20:11:58 +01:00
Girish Ramakrishnan b5a1554631 Fix various linter errors 2026-02-15 19:37:30 +01:00
Girish Ramakrishnan 510e1c7296 Fix missing import 2026-02-15 19:21:06 +01:00
Girish Ramakrishnan c6d8af5dc3 add to changelog 2026-02-15 19:05:40 +01:00
Girish Ramakrishnan adf884c2c4 add integrity check to system backups 2026-02-15 14:59:27 +01:00
Girish Ramakrishnan c7b321315c integrity: add stats 2026-02-15 14:41:10 +01:00
Girish Ramakrishnan 9f2eefcbb3 embed integrity check task in backup API responses
The UI is polling for the taskId, might as well attach it
2026-02-15 14:11:56 +01:00
Girish Ramakrishnan fc2e39f41b Rename getByIdentifierAndStatePaged to listByIdentifierAndStatePaged 2026-02-15 12:22:43 +01:00
Girish Ramakrishnan eae86d15ef Update modules 2026-02-14 19:49:42 +01:00
Girish Ramakrishnan 361d80da17 make tests work again 2026-02-14 19:45:10 +01:00
Girish Ramakrishnan 2597402496 make build work across server restart
tmp files disappear on server restart
2026-02-14 19:37:14 +01:00
Girish Ramakrishnan c8bc6f9ffe Update translations 2026-02-14 18:33:50 +01:00
Girish Ramakrishnan b0ef9238ff Show proper title/description for the new start/stop section 2026-02-14 18:31:06 +01:00
Girish Ramakrishnan b71e503a01 more ESM cleanups 2026-02-14 16:52:16 +01:00
Girish Ramakrishnan e9f96593c3 reorder functions for no-use-before-define 2026-02-14 16:34:34 +01:00
Girish Ramakrishnan 36aa641cb9 migrate to "export default"
also, set no-use-before-define in linter
2026-02-14 15:43:24 +01:00
Girish Ramakrishnan ddb46646fa remove esm migration files 2026-02-14 15:12:07 +01:00
Girish Ramakrishnan 96dc79cfe6 Migrate codebase from CommonJS to ES Modules
- Convert all require()/module.exports to import/export across 260+ files
- Add "type": "module" to package.json to enable ESM by default
- Add migrations/package.json with "type": "commonjs" to keep db-migrate compatible
- Convert eslint.config.js to ESM with sourceType: "module"
- Replace __dirname/__filename with import.meta.dirname/import.meta.filename
- Replace require.main === module with process.argv[1] === import.meta.filename
- Remove 'use strict' directives (implicit in ESM)
- Convert dynamic require() in switch statements to static import lookup maps
  (dns.js, domains.js, backupformats.js, backupsites.js, network.js)
- Extract self-referencing exports.CONSTANT patterns into standalone const
  declarations (apps.js, services.js, locks.js, users.js, mail.js, etc.)
- Lazify SERVICES object in services.js to avoid circular dependency TDZ issues
- Add clearMailQueue() to mailer.js for ESM-safe queue clearing in tests
- Add _setMockApp() to ldapserver.js for ESM-safe test mocking
- Add _setMockResolve() wrapper to dig.js for ESM-safe DNS mocking in tests
- Convert backupupload.js to use dynamic imports so --check exits before
  loading the module graph (which requires BOX_ENV)
- Update check-install to use ESM import for infra_version.js
- Convert scripts/ (hotfix, release, remote_hotfix.js, find-unused-translations)
- All 1315 tests passing

Migration stats (AI-assisted using Cursor with Claude):
- Wall clock time: ~3-4 hours
- Assistant completions: ~80-100
- Estimated token usage: ~1-2M tokens

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-14 15:11:45 +01:00
Girish Ramakrishnan e0e9f14a5e mail: increase solr timeout 2026-02-14 01:23:23 +01:00
Johannes Zellner b24e1142f8 Move app stop/start into uninstall again and add restart button in main toolbar 2026-02-13 18:37:49 +01:00
Johannes Zellner 0543b16de9 Update frontend dependencies 2026-02-13 17:51:36 +01:00
Johannes Zellner 8d46c09f95 Update pankow 2026-02-13 17:38:56 +01:00
Johannes Zellner 5724ca73b4 Add passkey support 2026-02-13 17:18:56 +01:00
Girish Ramakrishnan 3e09bef613 folderview: implement drop handler 2026-02-12 23:44:23 +01:00
Girish Ramakrishnan 627b1fe33f filemanager: implement tree view on the left 2026-02-12 22:55:24 +01:00
Girish Ramakrishnan 1aa270485c add changelog 2026-02-12 20:31:51 +01:00
Girish Ramakrishnan ae09c19b69 filemanager: open terminal in cwd 2026-02-12 20:30:57 +01:00
Johannes Zellner c5cf8eef1a Update pankow to v4 to fix TableView bug 2026-02-12 20:28:12 +01:00
Girish Ramakrishnan e76d4b3474 tests: fix app passwords test 2026-02-12 19:57:34 +01:00
Girish Ramakrishnan 88a44ee065 oidc: add alg to the jwks keys 2026-02-12 19:42:00 +01:00
Girish Ramakrishnan 51e02da277 spaces: add missing regions 2026-02-12 17:15:08 +01:00
Girish Ramakrishnan e9c3e42aa6 appPassword: add expiry 2026-02-12 16:23:31 +01:00
Girish Ramakrishnan 93a0063941 backups: add stop_integrity_check route 2026-02-09 22:00:40 +01:00
Girish Ramakrishnan 26a3cf79c5 backup: show last integrity in info dialog 2026-02-09 21:46:04 +01:00
Johannes Zellner 26999afc22 Reduce SaveIndicator timeout to 1500ms 2026-02-09 20:43:44 +01:00
Johannes Zellner 81729e4b2a Add SaveIndicator to email settings 2026-02-09 20:43:34 +01:00
Johannes Zellner 4bae5ee2fb Add SaveIndicator to user directory settings 2026-02-09 20:34:18 +01:00
Johannes Zellner a786e6c8f5 Do not crash if we have a new user without a username yet 2026-02-09 20:34:05 +01:00
Johannes Zellner 3803f36aa5 Fix language change to han chinese 2026-02-09 20:27:12 +01:00
Johannes Zellner 55bc26bd09 Add error state for SaveIndicator and put it in more places 2026-02-09 20:25:13 +01:00
Girish Ramakrishnan d84037a0dd add to changes 2026-02-09 18:24:56 +01:00
Girish Ramakrishnan 1ce5fcafd9 apppassword: display error 2026-02-09 17:52:40 +01:00
Johannes Zellner 281233f48b Unset align-items for section header toolbar to make buttons fill whole height 2026-02-09 17:29:12 +01:00
Johannes Zellner 68d73e088d Move notification settings to notifications view 2026-02-09 17:06:17 +01:00
Girish Ramakrishnan b433191b35 community: skip revoked versions 2026-02-09 16:11:55 +01:00
Girish Ramakrishnan d75ad44315 mail: fix haraka crash 2026-02-09 16:04:13 +01:00
Girish Ramakrishnan c3d3c3a6e9 app: if repo changes, do not autoupdate 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan b9b8ccb8ae community: stable/unstable 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan 5a56a7c8af community: show packager info 2026-02-09 15:51:47 +01:00
Girish Ramakrishnan d4efb63f3d Update packages 2026-02-09 15:51:47 +01:00
Johannes Zellner 2ec349e919 Remove duplicate code 2026-02-09 15:33:10 +01:00
Johannes Zellner 772770273a Set tls provider upon dns provider change, not in a watcher, which has side-effects 2026-02-09 14:32:02 +01:00
Girish Ramakrishnan fa5cbfc304 mail: allow comma in email display name 2026-02-09 11:01:47 +01:00
Girish Ramakrishnan 5276321ade integrity: add integrity check fields and initial UI 2026-02-08 23:26:57 +01:00
Girish Ramakrishnan 6303602323 graphite: update go-carbon to 0.19.1 2026-02-07 12:01:25 +01:00
Girish Ramakrishnan 486fb0d10a collectd is gone 2026-02-07 11:45:12 +01:00
Girish Ramakrishnan 74b8a08251 add community app warning 2026-02-06 22:49:08 +01:00
Girish Ramakrishnan 2a244bb8d4 Update versions format to have a root 2026-02-06 22:39:29 +01:00
Girish Ramakrishnan 84e73943f7 typo 2026-02-06 22:34:42 +01:00
Girish Ramakrishnan ace09ca5a7 add versions url in the menu 2026-02-06 19:19:04 +01:00
Girish Ramakrishnan a9ae34b149 community: download iconUrl
also rename existing db field appStoreIcon to packageIcon
2026-02-06 19:13:55 +01:00
Girish Ramakrishnan cff778fe6a set fallback image url 2026-02-06 19:08:23 +01:00
Girish Ramakrishnan be69f9f8a3 minor package updates 2026-02-06 18:23:30 +01:00
Girish Ramakrishnan 5ca2078461 add iconUrl to manifest 2026-02-06 18:04:47 +01:00
Girish Ramakrishnan 4461e7225f validate versions file 2026-02-06 17:35:59 +01:00
Girish Ramakrishnan 49d5d10d77 typo 2026-02-06 16:17:22 +01:00
Girish Ramakrishnan 84374f03e9 cloudron-setup: fix the echo 2026-02-06 12:10:09 +01:00
Girish Ramakrishnan f8a44014f7 installer: check for process instead of locks 2026-02-06 12:02:46 +01:00
Girish Ramakrishnan 6befb64691 cloudron-setup: wait for apt setup 2026-02-06 12:01:14 +01:00
Girish Ramakrishnan 1ff2c21c61 Move custom app actions to a dropdown 2026-02-06 10:32:29 +01:00
Girish Ramakrishnan c79d4a24c4 community: allow version in the url input 2026-02-06 10:19:13 +01:00
Girish Ramakrishnan 3d7a5676d8 lint 2026-02-05 23:22:26 +01:00
Girish Ramakrishnan aa362477e8 community: validate the url in the dialog 2026-02-05 22:40:37 +01:00
Johannes Zellner 13b524e8a5 Fix bold/strong font weight in markdown 2026-02-05 21:57:20 +01:00
Girish Ramakrishnan d6eb6d3e3e community: store versionsUrl in the database 2026-02-05 19:32:29 +01:00
Girish Ramakrishnan 91b8f1a457 oidc: do not fail on notification failure 2026-02-05 18:26:14 +01:00
Johannes Zellner c8cdcfc99f Fix const usage of reactive() variable 2026-02-05 15:14:15 +01:00
Johannes Zellner fe20e738cd Update vite and vue related dependencies 2026-02-05 15:11:09 +01:00
Johannes Zellner e23856bf10 Fixup fonts 2026-02-05 15:08:33 +01:00
Girish Ramakrishnan a7de7fb286 initial implementation of community packages 2026-02-05 14:21:50 +01:00
Johannes Zellner a931d2a91f Attempt to improve fonts on chrome 2026-02-05 13:33:37 +01:00
Girish Ramakrishnan c94c66b71e do not use displayName since it may not be unique 2026-02-04 18:32:45 +01:00
Girish Ramakrishnan cb89c30591 Fix help url 2026-02-04 18:16:38 +01:00
Elias Hackradt 1012c0f654 Fix Applink ACL 2026-02-04 16:57:51 +00:00
Girish Ramakrishnan 9b5fb9ae8f applink: do not send label when not set 2026-02-04 14:53:50 +01:00
Girish Ramakrishnan c4055271a8 render email as plain text in checklist and postinstall 2026-02-04 14:45:50 +01:00
Johannes Zellner cd1df37ed3 Show save indicator for language and timezone submission 2026-02-04 12:43:58 +01:00
Johannes Zellner 3d8d4fd921 Show error overlay with link to services view if filemanager addon is down 2026-02-04 12:00:03 +01:00
Girish Ramakrishnan 17b0c3e48d services: Graphite -> Metrics 2026-02-04 11:33:40 +01:00
Johannes Zellner f364257db9 Do not send username for profile updates if it isn't set yet 2026-02-03 19:57:23 +01:00
Johannes Zellner 6b0d9f8551 Fix vue warning about wrong props type 2026-02-03 18:47:17 +01:00
Johannes Zellner f0fb420a8d Skip checklist db migration script if no owner exists 2026-02-03 18:41:39 +01:00
Johannes Zellner 8aa2695263 Avoid horizontal scrolling in volumes table 2026-02-03 18:32:11 +01:00
Johannes Zellner b9af8ee6be Send the service name when listing services 2026-02-03 18:32:11 +01:00
Girish Ramakrishnan 7077289840 Fix doc link 2026-02-03 18:26:15 +01:00
Johannes Zellner bdd35fb02a Go to app configure view after installation started 2026-02-03 11:30:35 +01:00
Girish Ramakrishnan 47660c5679 Update translations 2026-02-03 08:53:00 +01:00
Johannes Zellner 28573f9676 Right align table headers for backup content info 2026-02-02 16:41:47 +01:00
Johannes Zellner 375b7f6dd7 Let appstore filter bar wrap for mobile 2026-02-02 16:32:58 +01:00
Johannes Zellner 99ec2d5ce7 Use Section component for NotificationView to work better on mobile 2026-02-02 16:31:25 +01:00
Johannes Zellner f2afd654f8 Bring back reboot button in reboot required notification 2026-02-02 16:21:31 +01:00
Johannes Zellner d42919285b Add user filter for roles and invited status 2026-02-02 12:25:09 +01:00
Johannes Zellner 33a1f135e0 Hide quickactions on mobile 2026-02-02 11:41:50 +01:00
Johannes Zellner 214b836d13 Hide user roles on mobile 2026-02-02 11:36:19 +01:00
Johannes Zellner 408a07e8b9 Update pankow for tooltip fix 2026-02-02 10:41:02 +01:00
Girish Ramakrishnan b247731062 doc links have changed 2026-02-02 10:19:19 +01:00
Johannes Zellner dcaa484929 Show user roles always as separate column 2026-02-02 10:11:15 +01:00
Girish Ramakrishnan 35886633e5 appinstall: align the header text and icon 2026-02-02 06:40:43 +01:00
Johannes Zellner d04afc26e7 Add db migration to ensure done checklist items have an owner and timestamp 2026-02-01 13:57:55 +01:00
Johannes Zellner bb5f1b703e Update lock file 2026-02-01 13:20:10 +01:00
Girish Ramakrishnan dfe2d27709 comment out some logs 2026-01-31 12:09:55 +01:00
Girish Ramakrishnan 1dec4f0070 cloudron-support: try ipv4 and ipv6 image pull 2026-01-30 17:20:35 +01:00
Girish Ramakrishnan 89b6513217 installer: need at least ubuntu 22 2026-01-30 09:55:53 +01:00
Girish Ramakrishnan 16a8caa8db installer: better log messages 2026-01-30 09:54:33 +01:00
Girish Ramakrishnan 1594d190eb update: skip backup site check when skipBackup is true 2026-01-30 09:52:52 +01:00
Girish Ramakrishnan 3333f70a64 9.0.18 changes
(cherry picked from commit fd881b4c61)
2026-01-29 17:38:55 +01:00
Johannes Zellner 5bd803e6b4 hetznercloud wants name instead of search_name for zones 2026-01-29 15:35:29 +01:00
Girish Ramakrishnan b5f5b096d4 ami: do not set domain provider by default 2026-01-29 14:20:53 +01:00
Girish Ramakrishnan dce05140bf ami: add instanceId input box 2026-01-29 13:50:27 +01:00
Girish Ramakrishnan 29d5ac94b2 activation: remove unused setupToken 2026-01-29 13:17:12 +01:00
Girish Ramakrishnan 2b80c6c1ad setup: setupToken is not used anymore 2026-01-29 13:15:09 +01:00
Girish Ramakrishnan 94a62b040b setup: set initial value for tls config 2026-01-29 13:13:35 +01:00
Johannes Zellner 2bb9c50db9 Condense info in app install dialog 2026-01-29 11:58:09 +01:00
Girish Ramakrishnan 6533ba4581 appstore: include provider as part of state 2026-01-29 11:46:57 +01:00
Girish Ramakrishnan 081909572e Fix casing 2026-01-28 15:52:15 +01:00
Johannes Zellner aa84cb0079 Actually make multiplart also optional 2026-01-28 14:17:22 +01:00
Girish Ramakrishnan a66c3700b3 Fix doc urls 2026-01-28 12:07:04 +01:00
Johannes Zellner 70476bd168 Use jsonOrMultipart instead of jsonOptional and multipart 2026-01-27 22:01:18 +01:00
Johannes Zellner a7929e142f Build local image for updates in apptask 2026-01-27 22:01:18 +01:00
Johannes Zellner fd0d65b8ce Keep the app source archive with the app instance data dir 2026-01-27 22:01:18 +01:00
Johannes Zellner ef2a94c2c8 use local/id:version-ts as docker image tag for locally built apps 2026-01-27 22:01:18 +01:00
Johannes Zellner b43daf2f08 Use the uploaded app source tarball to build a local docker image 2026-01-27 22:01:18 +01:00
Johannes Zellner 280f628746 Accept json body or formdata in app install route 2026-01-27 22:01:18 +01:00
Johannes Zellner 713774c03f Attempt to parse formdata fields as json 2026-01-27 22:01:18 +01:00
Johannes Zellner 0889c1531e Optionally allow json middleware to not check the content-type 2026-01-27 22:01:18 +01:00
Girish Ramakrishnan dbd5810a08 Add to changes 2026-01-26 22:52:57 +01:00
Johannes Zellner c8722e9945 Update translations 2026-01-23 23:34:41 +01:00
Girish Ramakrishnan 87780a2fc8 sftp: update modules 2026-01-23 16:52:52 +01:00
Johannes Zellner ab03256db0 Update translations 2026-01-23 15:00:17 +01:00
Johannes Zellner e26640c80e Use translations in notification view 2026-01-23 15:00:08 +01:00
Johannes Zellner e6806453e1 Add pagination to NotificationsView 2026-01-23 14:24:31 +01:00
Johannes Zellner d0fb2583a5 Show unread notifications by default and purge them on read 2026-01-23 14:08:46 +01:00
Johannes Zellner c4f8f318af Add unread/showall filter for notifications 2026-01-23 13:57:15 +01:00
Girish Ramakrishnan a6286bb67e mongodb: fcv fix
have to upgrade mongodb to 8.0 first
2026-01-23 13:48:01 +01:00
Johannes Zellner 90aea9708c Update lock file 2026-01-23 13:03:41 +01:00
Johannes Zellner cb076123b3 Only run quickaction action if set 2026-01-23 13:03:12 +01:00
Johannes Zellner 70a9a66ae9 Do not order notifcations by check/unchecked 2026-01-23 13:03:12 +01:00
Girish Ramakrishnan 8521a47cfa mysql: pipework update 2026-01-23 12:32:47 +01:00
Johannes Zellner 106cc5238e Keep unread notifications in sync with headerbar 2026-01-23 12:26:12 +01:00
Girish Ramakrishnan 2040eb22a2 mail: update modules 2026-01-23 12:14:33 +01:00
Girish Ramakrishnan b6075a9765 redis: update to 8.4.0 2026-01-23 11:34:41 +01:00
Girish Ramakrishnan daacbcb89d mongodb: update to 8.2.3 2026-01-23 11:26:30 +01:00
Girish Ramakrishnan 6d622bbd14 postgres: pipework update 2026-01-23 11:18:06 +01:00
Girish Ramakrishnan f355da4874 Fix up changelog 2026-01-23 10:03:25 +01:00
Girish Ramakrishnan 4b36de5200 Update docker to 29.1.5 2026-01-23 10:02:57 +01:00
Girish Ramakrishnan 88d37e99aa docker: compare debian package version instead of docker version
this way after an ubuntu upgrade, we update it with the correct
docker package on the next cloudron upgrade
2026-01-23 09:30:44 +01:00
Girish Ramakrishnan 1608fc3fdc Update packages 2026-01-23 09:13:11 +01:00
Girish Ramakrishnan 057fd18139 Update nodejs to 24.13.0 2026-01-23 09:04:57 +01:00
Johannes Zellner b6371a0bdf Add standalone NotificationsView 2026-01-22 18:56:06 +01:00
Girish Ramakrishnan 03fe72e0b1 services: fix usage of pipework 2026-01-22 18:09:41 +01:00
Johannes Zellner 3bf4bddc10 Remove very old ubuntu 16 and 18 from cloudron-setup 2026-01-22 12:23:45 +01:00
Johannes Zellner 92dcf19511 Fix form submission for app clone dialog 2026-01-22 10:26:13 +01:00
Girish Ramakrishnan b238443a9d services: switch to using @cloudron/pipework
also improve the error messages along the way
2026-01-21 21:32:02 +01:00
Girish Ramakrishnan 021a39a964 services: destroy the read/write stream on error 2026-01-21 17:23:27 +01:00
Girish Ramakrishnan 72c494e9dc backuptask: fix usage of upload API 2026-01-21 09:48:21 +01:00
Girish Ramakrishnan 42cefd56eb Fix uploader API to handle write stream errors
When the upload is aborted/abandoed because the source file is missing,
the write stream is never destroyed. This dangling write stream can
later error and cause a crash.

Instead, create the write stream on demand and nearer to pipeline() to
make sure we do 'error' handling.
2026-01-20 22:26:08 +01:00
Johannes Zellner 944f163882 Fix QuickAction width in app backup view 2026-01-20 18:08:35 +01:00
Girish Ramakrishnan 11a8a73723 app install: Fix status of install button 2026-01-20 17:49:35 +01:00
Girish Ramakrishnan e34cf8f6a6 location: fix up form validation 2026-01-20 17:23:08 +01:00
Girish Ramakrishnan 7f8143f06f services: set some width to avoid column shifting
(cherry picked from commit 529d227e74)
2026-01-18 18:27:51 +01:00
Girish Ramakrishnan 472e513a9f postgresql: reindex fix
(cherry picked from commit 6b56efcf14)
2026-01-18 18:27:44 +01:00
Girish Ramakrishnan 1cbacab3a2 services: keep quick actions consistent
(cherry picked from commit f23c8a9243)
2026-01-18 18:27:38 +01:00
Girish Ramakrishnan 49bbb8588d 9.0.17 changes
(cherry picked from commit 98660567e5)
2026-01-18 11:48:30 +01:00
Girish Ramakrishnan 23e0fe5791 postgres: fix hook that upgrades vectorchord 2026-01-18 11:36:48 +01:00
Girish Ramakrishnan 6877dfb772 acme: ARI support
ARI is a hint from the cert issuer about when to renew a cert. We will
use it when the API is available.

It provides a mechanim for CAs to revoke certs and signal to clients
that cert should be renewed.

https://www.rfc-editor.org/rfc/rfc9773.txt
https://letsencrypt.org/2024/04/25/guide-to-integrating-ari-into-existing-acme-clients
2026-01-17 23:28:47 +01:00
Girish Ramakrishnan f65b33f3fc Fix key type typo 2026-01-17 22:20:39 +01:00
Girish Ramakrishnan 3daddf2fe6 update superagent 2026-01-17 22:18:27 +01:00
Girish Ramakrishnan efccf2729b start moving openssl commands into openssl.js 2026-01-17 15:28:44 +01:00
Girish Ramakrishnan 3a1cd8f67f acme2: do not rely on db 2026-01-17 13:21:50 +01:00
Girish Ramakrishnan 53c90429d3 acme: rename variable 2026-01-17 10:26:04 +01:00
Girish Ramakrishnan 7b5384a7d5 acme2: add profile support
this adds acme profile support which was GA today.

https://letsencrypt.org/2026/01/15/6day-and-ip-general-availability
https://letsencrypt.org/docs/profiles/
2026-01-17 09:51:29 +01:00
Girish Ramakrishnan 2b362d8eaf users: add note about invitationToken
this is a one time token that is valid until the account is set up.
this is the reason it has no expiry time.
2026-01-17 09:44:43 +01:00
Girish Ramakrishnan ce0024a43c Update translations 2026-01-16 15:07:52 +01:00
Girish Ramakrishnan 888696975d diskusage: hide last updated when refreshing 2026-01-16 15:07:48 +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 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
Girish Ramakrishnan 11c5a3f050 9.0.10 changes 2025-11-14 14:20:17 +01:00
Girish Ramakrishnan 10645b1b94 Update translations 2025-11-14 14:18:08 +01:00
Girish Ramakrishnan e106dcd76a storage: pass limits object to backend 2025-11-14 13:18:21 +01:00
Girish Ramakrishnan cb30a57a59 backupcleaner: backupSite -> site 2025-11-14 13:10:27 +01:00
Girish Ramakrishnan 98da4c0011 storage: apiConfig -> config
to keep this in sync with site.config
2025-11-14 13:03:14 +01:00
Girish Ramakrishnan fc0c316ef2 s3: also pick region from the config 2025-11-14 09:37:03 +01:00
Elias Hackradt eaf363635e Remove collectd from send_diagnostics 2025-11-13 23:07:38 +01:00
Girish Ramakrishnan b91aa0668f access: fix spacing 2025-11-13 23:06:37 +01:00
Johannes Zellner 53c2f5885a Only autofocus appstore search on desktop 2025-11-13 19:54:24 +01:00
Johannes Zellner 5717f77e00 Require display name to not be empty when changed from the profile view 2025-11-13 17:42:43 +01:00
Girish Ramakrishnan 3f8dfdd938 refactor backup info into separate component
app backups now have the size and duration information
2025-11-13 17:22:35 +01:00
Johannes Zellner 9e1fbedc4d Only enable LdapServer input fields if feature is enabled 2025-11-13 17:00:58 +01:00
Girish Ramakrishnan f9eb588d4c move up all the dialog components 2025-11-13 16:12:19 +01:00
Johannes Zellner 181ee43107 Improve user add form validation 2025-11-13 16:09:40 +01:00
Johannes Zellner cc30bc1897 class text-error does not exist 2025-11-13 16:09:40 +01:00
Girish Ramakrishnan 1232b30e29 More 9.0.9 changes 2025-11-13 15:31:27 +01:00
Girish Ramakrishnan 03aae46880 update: show update error 2025-11-13 15:05:59 +01:00
Girish Ramakrishnan 25ce947df5 access control: always show the user management section 2025-11-13 14:42:44 +01:00
Girish Ramakrishnan b8f486d8e4 backuptask: fix crash when (old) stats object has no copy field 2025-11-13 14:42:44 +01:00
Girish Ramakrishnan 6305ff7410 incoming mail: remove cloudflare warning, will make this a check 2025-11-13 13:19:33 +01:00
Girish Ramakrishnan b2941894cd Fix amdinDomain not passed to the MailRelaySettingsItem 2025-11-13 12:59:02 +01:00
Johannes Zellner 83056519ec fs.existsSync always returns a boolean and does not throw 2025-11-13 12:26:51 +01:00
Johannes Zellner 3cdfbbac56 Fix volume dialog form validation 2025-11-13 12:11:35 +01:00
Girish Ramakrishnan f61e85c2d6 Fix ldap server translations 2025-11-13 11:55:28 +01:00
Girish Ramakrishnan 217ebf8c33 i18n: show which string is bombing 2025-11-13 11:33:40 +01:00
Girish Ramakrishnan b32114f2f2 backup site: fix translations 2025-11-13 11:33:40 +01:00
Johannes Zellner 6209cdbe0e Add api token dialog can only be submitted if name is given 2025-11-13 11:26:59 +01:00
Johannes Zellner afde81ef3e Use a temporary identity file for remote ssh copy 2025-11-13 10:27:33 +01:00
Johannes Zellner fbbd71e7f2 validate functions are not async 2025-11-13 10:09:34 +01:00
Johannes Zellner 54cf168b4d Remove removeCacheFiles() backup sites are immutable now 2025-11-13 10:08:33 +01:00
Girish Ramakrishnan c25b14976c Fix title of uninstall and archive dialog 2025-11-13 09:23:30 +01:00
Girish Ramakrishnan 39c68075fb Use sentence case whenever possible 2025-11-13 09:12:42 +01:00
Girish Ramakrishnan ce15958a9a minio: fix issue with accepting selfsigned certs 2025-11-12 14:18:34 +01:00
Girish Ramakrishnan 8d06defbcb update dialog: fix translations 2025-11-12 12:50:53 +01:00
Girish Ramakrishnan 0d807a37d6 applink: fix button text in edit mode 2025-11-12 12:14:44 +01:00
Girish Ramakrishnan 9a0a2d84da Fix test of unlink account dialog 2025-11-12 12:08:21 +01:00
Girish Ramakrishnan 29e2be47d0 password reset: show error message if any 2025-11-12 11:55:29 +01:00
Johannes Zellner b2e1f66dbb Fix opening app link edit dialog in app list view 2025-11-12 10:22:33 +01:00
Girish Ramakrishnan bfe9ee457d Fix formatting for plural 2025-11-12 09:00:16 +01:00
Girish Ramakrishnan a034b70449 More translation updates 2025-11-11 23:44:42 +01:00
Johannes Zellner 4226654772 Fixup access control component to cover all cases 2025-11-11 19:40:07 +01:00
Johannes Zellner 4ea8ab08a3 Only allow service configuration once we have fetched all service states 2025-11-11 18:18:50 +01:00
Johannes Zellner 702fc120af Actually setr the defaultMemoryLimit from the service 2025-11-11 18:01:04 +01:00
Johannes Zellner 9453084481 Update translations 2025-11-11 17:45:51 +01:00
Girish Ramakrishnan c6dbbc4135 services: edit -> configure 2025-11-11 17:09:10 +01:00
Girish Ramakrishnan ddc53bcb6f app: set eventlog header style like in other views 2025-11-11 16:48:17 +01:00
Girish Ramakrishnan e50509ac45 Translation updates 2025-11-11 16:39:13 +01:00
Girish Ramakrishnan 2ddba469b2 9.0.8 changelog 2025-11-11 09:21:39 +01:00
Girish Ramakrishnan 4e1b2ccbaa dashboard module updates 2025-11-11 09:01:28 +01:00
Girish Ramakrishnan e0b8a2400a Update marked 2025-11-11 08:59:57 +01:00
Girish Ramakrishnan 151ba569a7 Update pankow and friends 2025-11-11 08:59:12 +01:00
Johannes Zellner 2cb755fe44 Format ssh private key on input 2025-11-10 17:25:38 +01:00
Girish Ramakrishnan eeef49fd19 email: fix masquerade toggle 2025-11-10 17:13:58 +01:00
Girish Ramakrishnan 6b2626120c Translation fixes 2025-11-10 16:19:06 +01:00
Johannes Zellner e77ab26516 Update pankow 2025-11-10 15:52:03 +01:00
Johannes Zellner dbaf6c6ce2 Use full URLs for page preview icons and favicon 2025-11-10 15:21:22 +01:00
Johannes Zellner 5e295f9f1e Cloudron avatar URL comes from the meta header 2025-11-10 15:21:22 +01:00
Girish Ramakrishnan 8d3b655517 Fix incorrect padding 2025-11-10 13:30:39 +01:00
Girish Ramakrishnan 64cefd52c8 search: fix domain search to include redirect/alias/secondary domains 2025-11-10 13:30:39 +01:00
Johannes Zellner edb92ed0a5 ImagePicker should always return a png data url 2025-11-10 11:53:40 +01:00
Girish Ramakrishnan a8513cc0fa search: also search in manifest title 2025-11-10 11:26:51 +01:00
Girish Ramakrishnan 20d4ce6632 add fsused to block_devices output 2025-11-10 11:01:19 +01:00
Girish Ramakrishnan d8c3ce30ca lint 2025-11-10 10:27:24 +01:00
Girish Ramakrishnan d894de0784 cloudflare: ensure defaultProxyStatus in older configs
in Cloudron 9, we introduced an automated domain credentials check.
when checking with older cloudflare configs, this fails.
2025-11-10 10:18:32 +01:00
Girish Ramakrishnan 572bd19df6 Yet more translation fixes 2025-11-07 19:03:07 +01:00
Girish Ramakrishnan 4fd399eae9 Fix dialog titles 2025-11-07 17:49:51 +01:00
Johannes Zellner f7f55710d1 Do not share relay provider setting with view and form
Fixes #866
2025-11-07 13:11:07 +01:00
Johannes Zellner 18815b97ce Explicitly define busy ref in EmailDomainsView 2025-11-07 12:46:04 +01:00
Johannes Zellner c4fce32a6a Fix warning as ClipboardAction needs a string as value 2025-11-07 12:11:01 +01:00
Girish Ramakrishnan 9ed5f43ea1 More translation fixes 2025-11-07 12:09:38 +01:00
Johannes Zellner 232bce0a2d Fix size props in ImagePicker 2025-11-07 12:04:48 +01:00
Johannes Zellner 27f975f3c5 Ensure we pass users and groups to the AccessControl component 2025-11-07 11:03:02 +01:00
Girish Ramakrishnan 5b834b4396 user add: hide active checkbox 2025-11-07 10:15:22 +01:00
Girish Ramakrishnan 52b46e2b3e Fix typo that allowed primary domain to be deleted 2025-11-07 09:44:06 +01:00
Girish Ramakrishnan 044fb72da9 change placeholder as helper-text 2025-11-07 09:41:04 +01:00
Girish Ramakrishnan 0cf911bcdd more translation fixes 2025-11-07 09:08:56 +01:00
Girish Ramakrishnan 829512dd13 Fix tests 2025-11-06 18:01:35 +01:00
Johannes Zellner fa886c71b8 Avoid overflowing when textarea does not fit but also don't break lines 2025-11-06 16:50:45 +01:00
Johannes Zellner 21191bdc50 Give sshfs identity files unique filenames across mounts
If the same host was mounted as volume and backup or as a temporary
backup import, sharing the filename of the identify file would mean it
will get removed while still in use
2025-11-06 16:25:06 +01:00
Johannes Zellner 1bf2fe16a2 Fix AppImport dialog prefill from config to match BackupProviderForm inputs 2025-11-06 14:35:12 +01:00
Johannes Zellner c35543af92 Fix mailbox usage and quota sorting 2025-11-06 13:51:39 +01:00
Johannes Zellner 9bb71bd066 helpPopover is not notificationPopover 2025-11-06 12:30:16 +01:00
Girish Ramakrishnan f24e4f291d remove fullstops in some phrases 2025-11-06 11:37:29 +01:00
Girish Ramakrishnan 32ab9a9d32 location: fix various spacing issues 2025-11-06 11:36:58 +01:00
Girish Ramakrishnan 8b520dec48 portbindings: only show portCount when > 1 2025-11-06 10:31:42 +01:00
Girish Ramakrishnan 70c539ac4d mounts: remove loopback type
this is left over code from trying to implement size restricted data dir
2025-11-05 18:29:47 +01:00
Johannes Zellner 610651066a Fix tgz app backup download
Fixes #868
2025-11-05 18:14:48 +01:00
Johannes Zellner aaa750dbbc email eventlog only has 5 columns 2025-11-05 17:55:11 +01:00
Girish Ramakrishnan a518ee83cc backups: show same filesystem warning
fixes #867
2025-11-05 16:58:22 +01:00
Girish Ramakrishnan de84b5113c mounts: always return message when getting status 2025-11-05 16:52:32 +01:00
Girish Ramakrishnan 2ea7847d4f Add explicit option to disable automatic backups
Fixes #869
2025-11-05 15:51:15 +01:00
Girish Ramakrishnan 0650fca1cf Add description tag 2025-11-05 15:39:07 +01:00
Girish Ramakrishnan 1b5bd0d379 Enclose form in FormGroup 2025-11-05 15:36:55 +01:00
Girish Ramakrishnan 5b6f796606 Rename BackupScheduleDialog.vue to BackupSiteScheduleDialog.vue 2025-11-05 13:41:13 +01:00
Girish Ramakrishnan 9d6a755486 backup site: make config the first option 2025-11-05 13:37:59 +01:00
Girish Ramakrishnan 9470654394 9.0.7 changes 2025-11-04 09:22:15 +01:00
Girish Ramakrishnan 28feadd6c5 typo: forgot to amend previous commit 2025-11-04 09:20:12 +01:00
Girish Ramakrishnan af3ed04b7f externalldap: only set group members if they changed 2025-11-04 09:12:25 +01:00
Girish Ramakrishnan 2da99673cd do not store "null" as string in database
in other news, JSON.parse('null') returns null.
2025-11-04 09:02:58 +01:00
Girish Ramakrishnan 476adcb029 show upstreamVersion and not package version 2025-11-03 17:04:03 +01:00
Johannes Zellner b2c8f87276 Auto-dismiss notifications popover if no unread notifications exist 2025-11-03 15:32:01 +01:00
Girish Ramakrishnan bd4e132709 More changes 2025-11-03 13:24:15 +01:00
Johannes Zellner fa8fcf8761 Support wildcard domain aliases in app location form
fixes #870
2025-11-03 12:00:00 +01:00
Johannes Zellner 8e92b53d9f Show app icons in the grid in grayscale if app is stopped 2025-11-03 11:28:54 +01:00
Girish Ramakrishnan 6f90bd3db0 9.0.6 changes 2025-11-03 10:45:52 +01:00
Johannes Zellner a261d8b754 Do not allow unlinking from cloudron.io account in demo mode 2025-10-31 08:47:05 +01:00
Johannes Zellner 9643b7ed1b Filter dropdowns are searchable with more than 10 entries 2025-10-30 16:06:47 +01:00
Johannes Zellner ec191d51bc Sort apps in the grid by label 2025-10-30 16:01:03 +01:00
Johannes Zellner a5452e4b15 Fix filemanager for custom apps 2025-10-27 16:29:31 +01:00
Johannes Zellner 8522802f85 Update translations 2025-10-27 08:48:24 +01:00
Girish Ramakrishnan 6f2e3afe07 email: Fix display of inbound domains 2025-10-22 19:31:59 +02:00
Girish Ramakrishnan 70dfb41d95 email domains: fix display of stats 2025-10-22 19:23:15 +02:00
Girish Ramakrishnan 34f04828c5 Fix casing in translations
dashboard/README.md has information of the casing style
2025-10-22 18:40:20 +02:00
Girish Ramakrishnan a78799973d translation string typo 2025-10-22 18:33:12 +02:00
Girish Ramakrishnan 1797148951 warning label should appear above advanced 2025-10-22 16:43:33 +02:00
Girish Ramakrishnan 67caa89591 Treescale is gone 2025-10-22 14:53:24 +02:00
Girish Ramakrishnan e3a88e9f5b change default dns provider to digitalocean
hetzner provider is getting obsoleted and hetznercloud provider is in beta
2025-10-22 13:30:34 +02:00
Girish Ramakrishnan e9910c9b95 fix casing in a few places 2025-10-22 12:37:50 +02:00
Johannes Zellner 45e058bdc1 Use translated string for outbound in email domains view 2025-10-22 12:17:05 +02:00
Girish Ramakrishnan 9af5404921 add translation text notes 2025-10-22 11:34:08 +02:00
Johannes Zellner 5c4ca1b699 Make backup content list a TableView so we can sort it by size and fileCount 2025-10-21 23:56:16 +02:00
Johannes Zellner b6827736db All settings in sidebar should be same icon 2025-10-21 22:53:37 +02:00
Johannes Zellner aada3f3979 Autofocus search in appstore view 2025-10-21 22:33:37 +02:00
Girish Ramakrishnan dc07078fd4 set label for alias 2025-10-21 17:00:57 +02:00
Girish Ramakrishnan ae8278bdb3 Use dashboard domain as default and not [0] 2025-10-21 16:44:38 +02:00
Girish Ramakrishnan 286de8cdcb Update manifest format 2025-10-21 14:19:45 +02:00
Girish Ramakrishnan ca11d5af94 9.0.5 changes 2025-10-21 13:57:15 +02:00
Girish Ramakrishnan fb04f78112 backupcleaner: fix listing of backups by site 2025-10-21 13:56:08 +02:00
Girish Ramakrishnan 75fa2dfd67 remove unused import 2025-10-21 13:41:12 +02:00
Johannes Zellner 137267e604 Update pankow 2025-10-21 12:44:21 +02:00
Johannes Zellner 642487f4c5 Handle validitiy state in backup site adding dialog 2025-10-21 12:44:04 +02:00
Girish Ramakrishnan 783ad9ecda Fix hourly display 2025-10-21 11:11:40 +02:00
Johannes Zellner 0213a368b9 Use normal buttons for app start/stop 2025-10-21 10:10:26 +02:00
Girish Ramakrishnan f1e7594b79 Remove deleted users and groups in operators and access control
Fixes #857
2025-10-20 21:18:35 +02:00
Girish Ramakrishnan 02fd52e366 Remove any deleted group and user from operators and accessRestriction
part of #857
2025-10-20 16:51:23 +02:00
Girish Ramakrishnan 2d5e0a51bd add more to changelog 2025-10-20 15:23:57 +02:00
Johannes Zellner 1cd82dcd4c Revert old hetzner dns api file 2025-10-20 15:17:02 +02:00
Johannes Zellner 5ba30d0236 add hetznercloud DNS provider 2025-10-20 15:05:19 +02:00
Girish Ramakrishnan c0ea5c31eb Fix typo in app count 2025-10-20 15:03:15 +02:00
Johannes Zellner adee5fa25f Allow fonts loaded as inline data URI for the dashboard
Fixes #859
2025-10-20 15:01:16 +02:00
Girish Ramakrishnan f9af84fd85 9.0.4 changes 2025-10-20 14:58:44 +02:00
Girish Ramakrishnan 41cb381a2e backups: display the size and duration in info 2025-10-20 14:58:06 +02:00
Johannes Zellner 50ca07bfb8 login.signInAction is actually called login.loginAction 2025-10-20 14:53:57 +02:00
Girish Ramakrishnan 07732310c1 backuptask: track copy and upload statistics 2025-10-20 14:09:12 +02:00
Girish Ramakrishnan 854661e2d4 backuptask: print the upload statistics 2025-10-20 11:22:28 +02:00
Johannes Zellner 8cac83ed98 Add script to find and purge unused translations 2025-10-20 09:55:19 +02:00
Johannes Zellner 5ee8e9da80 Bring back filemanager translations 2025-10-20 09:53:49 +02:00
Johannes Zellner f5c81f5882 Use browser locales API to generate language labels 2025-10-20 09:04:29 +02:00
Girish Ramakrishnan a415b70adf Use marked.parseInline to not generate top level <p> 2025-10-18 11:00:46 +02:00
Johannes Zellner 800a7e26e9 Move update checker button back down 2025-10-18 09:47:36 +02:00
Johannes Zellner 1bc9dc30f6 Render oidc error page instead of showing a httperror if interaction is invalid
Fixes #862
2025-10-17 23:43:21 +02:00
Johannes Zellner 7d538ee1b8 wait for next eventloop to focus on login error 2025-10-17 23:18:02 +02:00
Johannes Zellner ac5f4cca19 Update frontend dependencies 2025-10-17 23:13:53 +02:00
Johannes Zellner 54a5d5b9aa Improve the app list a bit 2025-10-17 21:13:10 +02:00
Girish Ramakrishnan 5c4ec5afc0 More 9.0.3 changes 2025-10-17 20:44:07 +02:00
Girish Ramakrishnan 5bd6001f95 boxerror: details is not a subobject 2025-10-17 20:42:19 +02:00
Johannes Zellner 0fb8914b67 App list is for pro-users they need config action without extra click 2025-10-17 20:25:17 +02:00
Johannes Zellner 1f6ac49686 Fix spacing on location domain error 2025-10-17 19:50:27 +02:00
Johannes Zellner 42887fb1d9 app.error.details is gone, should have never happened
Check BoxError.toPlainObject() for more
2025-10-17 19:46:08 +02:00
Girish Ramakrishnan f14a7808cb move update notification and eventlog after the task update 2025-10-17 19:11:02 +02:00
Johannes Zellner a781a46f13 Do not sort dashboard domain first in the REST api 2025-10-17 18:55:22 +02:00
Johannes Zellner 6941a12314 Give domains list a larger max-height 2025-10-17 18:45:43 +02:00
Johannes Zellner f0e70a97bc Move configure action to the top of the app list menu 2025-10-17 18:32:31 +02:00
Johannes Zellner c59e3ef4ae Fix app list label sorting 2025-10-17 18:30:55 +02:00
Girish Ramakrishnan 2bfdc7c1ac Add "Cloudron Dashboard" in index.html
cloudron-support --troubleshoot relies on this
2025-10-17 18:18:52 +02:00
Johannes Zellner d831e7d765 Purge all unused translations 2025-10-17 18:02:13 +02:00
Johannes Zellner fe8ef5b922 Only show the section filter-bar container div if used 2025-10-17 17:41:38 +02:00
Girish Ramakrishnan 2c150eee33 9.0.3 changes 2025-10-17 17:15:13 +02:00
Girish Ramakrishnan a4d6bafe1a Change default footer to not have the forum link
it looks better without it
2025-10-17 17:04:34 +02:00
Johannes Zellner 78017b8adb Update translations 2025-10-17 16:46:56 +02:00
Girish Ramakrishnan ea822f66ca reload: fix issue where the version is null on first visit 2025-10-17 16:41:33 +02:00
Girish Ramakrishnan a55adf12db More robust root disk detection 2025-10-17 16:34:51 +02:00
Johannes Zellner 84c016490c Link apps and volumes in disk usage listing 2025-10-17 14:30:16 +02:00
Johannes Zellner bb7056d614 Revert "Add dynamic app grid spacing to always fill full width"
This reverts commit f37dd03e4b.
2025-10-17 13:22:54 +02:00
Johannes Zellner 462b490d05 Revert "css styles need units..."
This reverts commit 15c8f84960.
2025-10-17 13:22:44 +02:00
Girish Ramakrishnan 084050bb2f network: fix ip caching bug
when the promise request errors, it is not cleared. this means that
future requests always fail.
2025-10-17 12:40:28 +02:00
Girish Ramakrishnan 8d2ea7e736 Fix styling in public page
make the cloudron name bolder

on mobile, form fields must be aligned left. make logo smaller to
not make the left aligned form fields better.
2025-10-17 11:45:42 +02:00
Girish Ramakrishnan fe8d5b0d3e Login -> Log in 2025-10-17 11:13:10 +02:00
Johannes Zellner de724319aa Move logo and cloudron name slightly up in login page 2025-10-17 09:17:17 +02:00
Johannes Zellner ac91b417c3 Attempt to improve public view layout 2025-10-17 00:10:09 +02:00
Johannes Zellner 229863d7ff Make eventlog and email eventlog table layouts a lot more predictable 2025-10-16 23:26:58 +02:00
Johannes Zellner 8dcb3f2f85 Fix update schedule configuration 2025-10-16 23:17:05 +02:00
Johannes Zellner 15c8f84960 css styles need units... 2025-10-16 22:49:04 +02:00
Johannes Zellner f37dd03e4b Add dynamic app grid spacing to always fill full width 2025-10-16 22:42:11 +02:00
Johannes Zellner 82c97f7e1c Move app start/stop back to the main toolbar 2025-10-16 22:26:26 +02:00
Johannes Zellner 91078f7a7e Uninstall close is only a secondary button 2025-10-16 22:07:39 +02:00
Johannes Zellner d2775956e0 Hide non-owner actions for backup sites 2025-10-16 21:50:43 +02:00
Johannes Zellner 00b52fa3af Fix diskusage item margins 2025-10-16 21:37:36 +02:00
Girish Ramakrishnan 1ac0ed3c18 Use util.getColor to generate colors 2025-10-16 17:39:52 +02:00
Johannes Zellner 6ec8246b46 Add missing autocomplete attributes on forms 2025-10-16 16:09:22 +02:00
Johannes Zellner f5978a524d Refresh backup site status and task in the background 2025-10-16 15:41:52 +02:00
Girish Ramakrishnan 72030ee8fc backups: display mail backup stats 2025-10-16 14:51:33 +02:00
Girish Ramakrishnan d6a4dd6965 backup sites: fix listing when status call errors
* fix backend to not retry in status call
* fix frontend to continue loading view if status errors
* fix connect-lastmile to show the exact path that is timing out
2025-10-16 14:13:31 +02:00
Johannes Zellner 8aa5dc85af Move ip settings buttons into the Section for consistency 2025-10-16 13:19:00 +02:00
Johannes Zellner 5c7f99c0ee Show current Cloudron version and if on latest in update view 2025-10-16 12:48:49 +02:00
Girish Ramakrishnan 847cb91759 backuptask: fix crash when accessing stats of old backups 2025-10-16 12:32:32 +02:00
Johannes Zellner 9e92d08261 Avoid flickering of SystemUpdate view when update is busy 2025-10-16 12:12:59 +02:00
Johannes Zellner bf8e03aa0c Indicate app title in configure view is a link 2025-10-15 23:38:09 +02:00
Johannes Zellner fcd05f3bb4 Fix submit state for login form 2025-10-15 23:38:09 +02:00
Girish Ramakrishnan a14dfc171d add current release file 2025-10-15 23:00:31 +02:00
Girish Ramakrishnan b8b445eb24 Update lock file 2025-10-15 22:49:18 +02:00
Girish Ramakrishnan fbf4a53a1b Add 9.0.2 changes 2025-10-15 22:47:51 +02:00
Girish Ramakrishnan 0c7e810bd3 graphs: set x-axis using absolute time in advance()
setInterval() won't be reliably fired by the browser when the tab
is backgrounded!
2025-10-15 22:43:58 +02:00
Johannes Zellner 0502779a29 Ensure the email size range slider fits the screen on mobile 2025-10-15 22:23:27 +02:00
Johannes Zellner 576d9ca894 Add getColor() to utils 2025-10-15 21:51:17 +02:00
Johannes Zellner d8771509cd Fix diskusage colors 2025-10-15 21:47:29 +02:00
Girish Ramakrishnan b139749198 graphs: rebuild container on combo box close 2025-10-15 20:56:21 +02:00
Johannes Zellner bdcb5c502c Add new filter bar slot for Section component which teleports on mobile 2025-10-15 20:33:37 +02:00
Johannes Zellner dc72df1dbd Show error dialog if manual cloudron update failed 2025-10-15 18:02:07 +02:00
Johannes Zellner 8be834d0c8 Always start with a fresh domains list for the apps filter 2025-10-15 15:52:25 +02:00
Johannes Zellner c995454f69 Make sure the no apps placeholder does not take up layout space 2025-10-15 15:44:24 +02:00
Girish Ramakrishnan 854e0ebe3f sidebar: email domains, eventlog, settings is only for admins 2025-10-15 14:56:55 +02:00
Girish Ramakrishnan f01d2631dd sidebar: ldap/openid/directory should not be visible to non-admins 2025-10-15 14:37:27 +02:00
Girish Ramakrishnan 60f8cdf3b4 email settings: fix description spacing 2025-10-15 14:17:55 +02:00
Girish Ramakrishnan 8e5bf14623 login: fix spacing around the demo note 2025-10-15 14:02:41 +02:00
Girish Ramakrishnan b063ebd6d7 reload dashboard on version change 2025-10-15 13:46:52 +02:00
Johannes Zellner eb7d7a2d1b Show disk usage content name delivered from the backend 2025-10-15 12:19:15 +02:00
Girish Ramakrishnan f9ee088592 Add 9.1.0 changes 2025-10-15 12:08:39 +02:00
Girish Ramakrishnan 1f32d4b4dd sysinfo: if product name is empty use product family 2025-10-15 12:06:32 +02:00
Girish Ramakrishnan d3b4c2f394 add note on confusing naming 2025-10-15 11:59:35 +02:00
Girish Ramakrishnan 41c00eda74 metrics: fix root device detection
the existing logic does not work for device like /dev/md1 (on the dedis)
2025-10-15 11:32:09 +02:00
Johannes Zellner 155af33b0c Revert to not use a 2 column grid for disk items 2025-10-15 11:12:10 +02:00
Johannes Zellner b289146aeb Only check once for the default backup location for metrics 2025-10-15 11:06:42 +02:00
Johannes Zellner d2e32a4fd0 Only allow to submit apppassword dialog if valid 2025-10-15 10:19:16 +02:00
Johannes Zellner 6631c95166 backup.stats may be null 2025-10-14 20:33:47 +02:00
Johannes Zellner 7adabcc203 Avoid much flickering on disk graph item hover 2025-10-14 17:23:45 +02:00
Johannes Zellner de35a935a6 Ensure mail server location does not overflow the view 2025-10-14 16:54:52 +02:00
Girish Ramakrishnan d3d668d930 archive: display the site name of latest backup 2025-10-14 16:54:42 +02:00
Girish Ramakrishnan 1f60c6dd21 Remove max-height from the users view and groups view tables 2025-10-14 16:20:34 +02:00
Johannes Zellner 1431700642 Improve mailbox owner type detection for showing the icon in the multiselect 2025-10-14 15:48:27 +02:00
Girish Ramakrishnan 12a1de56fd backupsite: only owner can add a site 2025-10-14 15:46:47 +02:00
Girish Ramakrishnan 4267208f28 Revert "move dnsExists error below location field"
This reverts commit b2fc7df06d.

This doens't always work because we can have multiple location fields
2025-10-14 15:23:25 +02:00
Johannes Zellner 25d6ec157f Check for statsLine not blockDevice 2025-10-14 15:22:57 +02:00
Girish Ramakrishnan b2fc7df06d move dnsExists error below location field 2025-10-14 14:48:52 +02:00
Girish Ramakrishnan c875371db9 Update pankow 2025-10-14 14:41:19 +02:00
Girish Ramakrishnan dd7818b960 hide separator if not admin 2025-10-14 14:29:16 +02:00
Girish Ramakrishnan 0dd6446f3e apps: add route to list sites for operators
the main backup listing route is only available for admins
2025-10-14 14:22:41 +02:00
Johannes Zellner 579dc4959b Move ephemeral port help link out of the translation 2025-10-14 10:23:34 +02:00
Johannes Zellner 27c3170c80 Hide attempt to show favorite actions in users view for now 2025-10-14 10:12:14 +02:00
Girish Ramakrishnan 7b275008b5 Just say "Copied" 2025-10-14 09:12:13 +02:00
Girish Ramakrishnan 2cfea229fd Make app installation dialog smaller 2025-10-13 14:34:37 +02:00
Girish Ramakrishnan e349193745 Fix tests 2025-10-13 13:14:50 +02:00
Girish Ramakrishnan 45d68c4da8 shell: set utf8 encoding explicity for spawn and as the default for sudo 2025-10-13 13:14:50 +02:00
Girish Ramakrishnan 36d443fa36 9.0.1 changes 2025-10-13 09:45:29 +02:00
Girish Ramakrishnan 8549663ed0 backupsites: do not mutate caller's config object 2025-10-13 09:39:06 +02:00
Johannes Zellner 73b2feae59 Refresh backup list view when backup site is removed 2025-10-10 17:23:28 +02:00
Johannes Zellner 0871b416a7 Center login view elements on mobile 2025-10-10 17:16:50 +02:00
Johannes Zellner 767bfba670 Calculate apps view item width early to avoid flickering 2025-10-10 16:51:15 +02:00
Johannes Zellner 019cbeb617 Use css grid for DiskItems to avoid reordering 2025-10-10 16:50:14 +02:00
Girish Ramakrishnan a7d8a8fd1f godaddy: add missing customNameservers check 2025-10-10 16:31:01 +02:00
Johannes Zellner f7e073c857 Move custom nameserver checkbox into the shared domain config component 2025-10-10 16:30:47 +02:00
Girish Ramakrishnan 2fdb3668e2 Fix misleading comment 2025-10-10 16:26:40 +02:00
Johannes Zellner f1aee1d9a4 Support custom certificates for domains again 2025-10-10 16:10:16 +02:00
Johannes Zellner 057f75ca5f Move domain zoneName into common advanced component 2025-10-10 14:47:15 +02:00
Johannes Zellner 314654f319 Fix system update link, which curiously had /docker 2025-10-10 14:28:02 +02:00
Girish Ramakrishnan 961959d361 add aggregated size to system backups 2025-10-10 13:14:49 +02:00
Girish Ramakrishnan d603ea50e2 Fix email incoming enable translation 2025-10-10 11:16:04 +02:00
Johannes Zellner 3b7bc6beba Calculate better app grid item widths based on view width 2025-10-10 10:55:26 +02:00
Johannes Zellner e437168e87 Add invisible button to location input to align the domain input widths 2025-10-10 09:53:19 +02:00
Johannes Zellner cf7d735c56 Fix table flickering in services view 2025-10-09 17:43:51 +02:00
Girish Ramakrishnan b2a41cc4d5 Add site name for backup events 2025-10-09 17:30:02 +02:00
Johannes Zellner 5bc3cb6353 Only update the backup site name if changed 2025-10-09 17:25:26 +02:00
Girish Ramakrishnan e6024ac85b eventlog: rename backup site logs and fix eventlog summar 2025-10-09 17:24:33 +02:00
Johannes Zellner 4f6fac2336 Show link to task logsviewer from eventlog if event has a taskId 2025-10-09 16:59:31 +02:00
Johannes Zellner 12feb68bf0 Show explicit logs button for backup task logs 2025-10-09 16:45:03 +02:00
Johannes Zellner d5d325bf4e Show email relay info in mail domain listing 2025-10-09 16:16:28 +02:00
Johannes Zellner 47ae1443d1 Make email relay provider info more usable in other components 2025-10-09 15:56:00 +02:00
Johannes Zellner 63b51d2fca Rework dashboard domain component to not depend on external domains list prop 2025-10-09 15:18:00 +02:00
Girish Ramakrishnan 3547be3401 Update redis to 8.2.2
https://redis.io/blog/security-advisory-cve-2025-49844/
2025-10-09 14:35:52 +02:00
Johannes Zellner a038028c3b Move app backup listing below the backup create button 2025-10-09 14:22:00 +02:00
Johannes Zellner 40cc12e568 Make app backup progress message a link to the logsviewer 2025-10-09 14:14:27 +02:00
Johannes Zellner 4a1a915add Show explicit label for backup and filename encryption if enabled 2025-10-09 13:26:20 +02:00
Girish Ramakrishnan 3096b67b76 init dialog with correct tls config 2025-10-09 13:08:52 +02:00
Johannes Zellner 3acceca32f Add support to configure preserveAttributes and chown for backup sites 2025-10-09 13:07:04 +02:00
Johannes Zellner e8e05159d8 Support changing hardlinks if applicable 2025-10-09 12:44:25 +02:00
Johannes Zellner ae0c7390f2 Remove handling of non-ediable fields in backupsite config dialog 2025-10-09 12:31:31 +02:00
Girish Ramakrishnan 11f2acd702 init data with initial config and only mutate what we allow editing 2025-10-09 12:20:59 +02:00
Johannes Zellner 1759e257c4 Handle backup site name error 2025-10-09 12:09:13 +02:00
Johannes Zellner 3e86c59607 Always submit S3 config even if nothing has changed 2025-10-09 12:05:10 +02:00
Johannes Zellner dfe5b6b1f2 Fixup backup site config dialog to handle S3 2025-10-09 11:58:20 +02:00
Girish Ramakrishnan b0026eafb5 remove various ng- 2025-10-09 10:58:58 +02:00
Johannes Zellner 67519fb203 Add missing backup site translations 2025-10-09 10:33:56 +02:00
Johannes Zellner 7f4a9d6016 Split backup site config and contents 2025-10-09 10:26:54 +02:00
Girish Ramakrishnan 317f6e77d4 Add space between description and port count 2025-10-09 09:43:14 +02:00
Girish Ramakrishnan 5fcfd32f6c add ephemeral port warning 2025-10-09 09:31:35 +02:00
Girish Ramakrishnan 0fd056dff9 add note why the backupinfo is unencrypted 2025-10-09 09:04:22 +02:00
Girish Ramakrishnan 10cf8f1d0e rsync: integrity fixes 2025-10-09 00:23:44 +02:00
Girish Ramakrishnan 67d95cda76 store integrity signature in hex 2025-10-08 22:35:39 +02:00
Johannes Zellner 3ac66f9dd4 Make backup task progress message a link to the task logs 2025-10-08 21:52:55 +02:00
Girish Ramakrishnan 43e426ab9f Revert "Add no-use-before-define linter rule"
This reverts commit fdcc5d68a2.

Unfortunately, this requires us to move exports to the bottom.
This in turn causes circular dep issues and also access of
exports.GLOBAL_VAR in the global context
2025-10-08 21:17:52 +02:00
Johannes Zellner a5224258c3 Allow to change backup provider secrets pending error feedback 2025-10-08 20:40:40 +02:00
Girish Ramakrishnan ab3a2911c3 do not attach v-if to the form since we cannot find it by ref anymore 2025-10-08 20:06:05 +02:00
Girish Ramakrishnan 1cdcab6047 SECRET_PLACEHOLDER is not used anymore 2025-10-08 20:01:31 +02:00
Girish Ramakrishnan 2ad8ed5550 relay: remove usage of secret placeholder 2025-10-08 20:01:18 +02:00
Girish Ramakrishnan 9226baa63c externalldap: remove usage of secret placeholder 2025-10-08 19:42:35 +02:00
Girish Ramakrishnan b790d085bb volumes: remove usage of secret placeholder 2025-10-08 18:05:30 +02:00
Johannes Zellner 933d5c9139 Use consistent style to only disable dialog close button if dialog submission is busy 2025-10-08 16:49:05 +02:00
Girish Ramakrishnan 2c5fd7effa Fix test 2025-10-08 16:47:30 +02:00
Girish Ramakrishnan f5a5bebae6 dockerregistries: remove usage of secret placeholder 2025-10-08 16:40:43 +02:00
Girish Ramakrishnan f2316ec84e storage: remove usage of secret placeholder 2025-10-08 15:49:56 +02:00
Girish Ramakrishnan 84165e5342 Add required to SingleSelect but pankow does not support this 2025-10-08 15:28:34 +02:00
Johannes Zellner f65dab114e Reduce Package Version to Version for apps backups 2025-10-08 15:23:30 +02:00
Girish Ramakrishnan 4ad7504be6 remove inline style in Checkbox 2025-10-08 14:59:15 +02:00
Girish Ramakrishnan e9318d7f11 cloudflare: when proxying is enabled, use a valid IP 2025-10-08 14:50:00 +02:00
Girish Ramakrishnan 01d7d41c17 domains: remove SECRET_PLACEHOLDER from responses 2025-10-08 14:32:24 +02:00
Girish Ramakrishnan f1fb5f2530 pass a copy when opening dialog
otherwise, any changes the dialog makes will reflect in the calling
view's object
2025-10-08 14:06:32 +02:00
Johannes Zellner ea28c10a39 Ensure SystemBackupList is refreshed and tracks new backup or cleanup tasks 2025-10-08 13:45:18 +02:00
Johannes Zellner 6c84681f35 Show list instead of table for app eventlogs on mobile 2025-10-08 12:06:30 +02:00
Johannes Zellner edb3aea880 Rename platform backups to system backups 2025-10-08 11:21:12 +02:00
Girish Ramakrishnan f898925bc0 tasks: update the state of crashed tasks 2025-10-08 11:16:26 +02:00
Girish Ramakrishnan f3be7aa763 lint 2025-10-08 11:06:24 +02:00
Johannes Zellner 71c52a87ec Add backup task cancel button if task is active 2025-10-08 11:01:59 +02:00
Johannes Zellner 8c786f38ab Translate last backup run for sites 2025-10-08 11:01:59 +02:00
Girish Ramakrishnan 6bc5826c86 use prettyLongDate in the eventlog timestamps 2025-10-08 10:54:11 +02:00
Girish Ramakrishnan 90e36c4552 add more description tags 2025-10-08 10:43:38 +02:00
Johannes Zellner f83bd2e3b7 No need to show extra error label in backup site last run on error 2025-10-08 10:34:30 +02:00
Girish Ramakrishnan 367eb1b1e1 checkbox has the needed spacing now 2025-10-08 10:32:08 +02:00
Johannes Zellner 91e21d69db Remove toplevel backup list view 2025-10-08 09:10:04 +02:00
Johannes Zellner 5bcfea161f Move platform backup listing to backup sites view 2025-10-08 09:07:54 +02:00
Girish Ramakrishnan a9e0ee81ce backup site: init prefix on open 2025-10-07 22:22:57 +02:00
Girish Ramakrishnan c37c513067 Update pankow 2025-10-07 21:18:33 +02:00
Girish Ramakrishnan c0972b3e14 restore: add ... animation 2025-10-07 21:07:30 +02:00
Girish Ramakrishnan 1b91ae1ab3 restore: display error on top 2025-10-07 20:51:57 +02:00
Girish Ramakrishnan 4a462597fd restore: fix display of error 2025-10-07 20:42:14 +02:00
Girish Ramakrishnan 505c71855e retry download only 3 times 2025-10-07 20:42:04 +02:00
Girish Ramakrishnan 2ba0d716d1 do not validate password when restoring 2025-10-07 19:57:20 +02:00
Girish Ramakrishnan 35ab03c392 restore: do not hide encryption in advanced section 2025-10-07 19:54:20 +02:00
Girish Ramakrishnan ddb7551b92 integrity: store signature as base64 2025-10-07 18:42:51 +02:00
Johannes Zellner fdcc5d68a2 Add no-use-before-define linter rule 2025-10-07 17:43:35 +02:00
Girish Ramakrishnan ac7c32ad4c backups: log encryption and format in upload 2025-10-07 17:42:51 +02:00
Girish Ramakrishnan e93898d2ec typo that broke encryption 2025-10-07 17:42:51 +02:00
Johannes Zellner 22fe27da9c Avoid table column width jumping when table-actions have favorites 2025-10-07 17:34:21 +02:00
Johannes Zellner e31164140c Revert "Improve showing favorites on desktop"
This reverts commit d3731ff339.
2025-10-07 17:26:57 +02:00
Johannes Zellner 7d53eeb7f5 Add better grouping to mailbox owner select 2025-10-07 17:20:03 +02:00
Johannes Zellner d3731ff339 Improve showing favorites on desktop 2025-10-07 17:20:03 +02:00
Girish Ramakrishnan 02ba2fe59b encryption: do not allow password and hint to be the same 2025-10-07 16:52:39 +02:00
Johannes Zellner 55091cfe8d Attempt to add table row action favorites in users view 2025-10-07 14:54:25 +02:00
Johannes Zellner f6e14a5420 Support overwrite DNS in app install dialog 2025-10-07 14:54:25 +02:00
Johannes Zellner b38371400c Avoid annoying crash with hotreload 2025-10-07 14:54:25 +02:00
Girish Ramakrishnan 0458fee326 Fix SingleSelect usage for configureRetention 2025-10-07 14:53:44 +02:00
Girish Ramakrishnan 15256ebbdf sort the days and hours 2025-10-07 14:44:47 +02:00
Girish Ramakrishnan 773435fb7f move helper functions out of the model and into the view 2025-10-07 14:41:10 +02:00
Girish Ramakrishnan 1a674a30ac move cronDays, cronHours to utils 2025-10-07 14:37:53 +02:00
Girish Ramakrishnan 491617e41c replace select with Select to have consistent style 2025-10-07 14:09:07 +02:00
Girish Ramakrishnan 24fc480f7b Use ClipboardButton everywhere 2025-10-07 13:57:16 +02:00
Girish Ramakrishnan 677f11ba1a add description field in various FormGroups 2025-10-07 13:42:06 +02:00
Girish Ramakrishnan aa7e307205 use the description style of FormGroup 2025-10-07 13:32:39 +02:00
Girish Ramakrishnan 6ac914904e backups: make listing by site only return box backups 2025-10-07 12:13:14 +02:00
Girish Ramakrishnan 68dd1fbedb rename function to listByTypePaged 2025-10-07 12:07:46 +02:00
Girish Ramakrishnan 6869380500 Fix tests 2025-10-07 10:01:35 +02:00
Girish Ramakrishnan 3a0febe760 convert console.log to debug 2025-10-07 09:36:07 +02:00
Girish Ramakrishnan 46d195557b app graphs: push 0 when stopped 2025-10-06 23:15:35 +02:00
Girish Ramakrishnan 548418ecfe graphs: show correct metrics when container stops 2025-10-06 23:05:49 +02:00
Girish Ramakrishnan 28c0d63f89 app archive: fix download config 2025-10-06 22:34:28 +02:00
Girish Ramakrishnan b3eb6ccde3 x 2025-10-06 21:47:25 +02:00
Girish Ramakrishnan 0e09df797b provision: fix issues when refreshing 2025-10-06 21:47:25 +02:00
Girish Ramakrishnan 7fae0751b9 add whirpool to restore view 2025-10-06 21:29:12 +02:00
Girish Ramakrishnan e6cdd1ed7a remove double error in restore view 2025-10-06 21:07:32 +02:00
Girish Ramakrishnan 9d8c079d37 restore: send siteId 2025-10-06 20:03:38 +02:00
Girish Ramakrishnan 9bb58b1649 typo 2025-10-06 19:54:19 +02:00
Girish Ramakrishnan d732adf34b restore: complete the latest full backup task of site id
when creating a full backup, the task is still running and the database
dump which is part of the backup, still says it is still running.

during restore, we mark the taskId as completed. detection of the task
is using the siteId in config (when restoring via backup config) or
just blindly setting the last backup task as completed (in manual form fill).
2025-10-06 19:50:58 +02:00
Girish Ramakrishnan 8718e7efd2 tasks: list by prefix 2025-10-06 19:32:06 +02:00
Girish Ramakrishnan b21d29098b tasks: refactor listByTypePaged into list with options
this way we can list by prefix (coming commit)
2025-10-06 19:28:51 +02:00
Girish Ramakrishnan d010330b58 Use task type enum 2025-10-06 18:25:21 +02:00
Girish Ramakrishnan 4562024e72 remove unused function 2025-10-06 18:20:38 +02:00
Girish Ramakrishnan 34043d5c97 backupsites: reinit sites after restore 2025-10-06 18:20:38 +02:00
Johannes Zellner a468b6ff39 Do not call graph.update() on every new dataset 2025-10-06 16:38:37 +02:00
Girish Ramakrishnan 534aa7423e setup: default to hetzner dns 2025-10-06 15:52:44 +02:00
Johannes Zellner be416fd335 Only wrap app configure titlebar on mobile 2025-10-06 15:44:09 +02:00
Girish Ramakrishnan 43962c4a5a add route to list backups by site 2025-10-06 14:52:29 +02:00
Girish Ramakrishnan 1b33b2c48c migrate old backup tasks to new type 2025-10-06 14:52:29 +02:00
Johannes Zellner efa4c09306 At least make backuptarget.add event known for eventlogs 2025-10-06 12:59:49 +02:00
Johannes Zellner 181b9cdee6 Fix vertical layout for Section headers 2025-10-06 12:49:48 +02:00
Johannes Zellner a1c3cd272b Fix indentation 2025-10-06 12:40:15 +02:00
Johannes Zellner c67b4a4e51 Do not give focus to appstore search input if subscription required
dialog is shown
2025-10-06 12:38:09 +02:00
Johannes Zellner 72c97ba224 remount() needs a backupSite object 2025-10-06 11:10:18 +02:00
Girish Ramakrishnan ca96e40397 Use text-warning when showing warning below input elements 2025-10-06 11:09:35 +02:00
Johannes Zellner 758daee0c9 Better layout app install dialog on mobile to avoid overflow 2025-10-05 18:21:49 +02:00
Johannes Zellner 37b6a2568d Show X close button in app install dialog 2025-10-05 18:14:30 +02:00
Johannes Zellner 4ee2fd8b14 Set absolute URL when appinstall dialog is closing 2025-10-05 17:41:28 +02:00
Johannes Zellner 7706be3e2f Set location.href instead of hash for the fallback view 2025-10-05 17:40:42 +02:00
Johannes Zellner 16c2316183 Only fetch apps for backup info when info dialog is opened 2025-10-05 17:30:37 +02:00
Johannes Zellner ce22100b02 Align mobile menu drawer open/close with header bar 2025-10-05 17:01:59 +02:00
Johannes Zellner d2cc38c8ec Instead of app status label show static external link label for app links 2025-10-05 16:53:39 +02:00
Johannes Zellner c0a31ccb55 Use VITE_CACHE_ID to bust translation file cache for new versions 2025-10-05 12:14:02 +02:00
Girish Ramakrishnan 8ddc9e3138 Fix ldap view href 2025-10-05 11:34:48 +02:00
Girish Ramakrishnan 7a24c23bdd Make LDAP view that contains the server and external ldap 2025-10-05 11:16:23 +02:00
Girish Ramakrishnan 32f4b72d68 rename views 2025-10-05 10:45:38 +02:00
Girish Ramakrishnan 282df5c2e6 Fix the LDAP and OpenID views 2025-10-05 10:39:40 +02:00
Girish Ramakrishnan 6713ba3798 refactor Appearance into two components - Applinks and Branding 2025-10-05 10:30:34 +02:00
Girish Ramakrishnan 355edda058 More menu reordering 2025-10-05 10:14:26 +02:00
Girish Ramakrishnan b1141f8cbb More reordering 2025-10-04 23:45:12 +02:00
Girish Ramakrishnan eaad527e32 rework the sidebar ordering and grouping 2025-10-04 23:22:34 +02:00
Girish Ramakrishnan 810922de5e friendlier hash/urls 2025-10-04 23:11:30 +02:00
Girish Ramakrishnan da3414e3bc make enum for the sidebar groups 2025-10-04 23:07:00 +02:00
Girish Ramakrishnan 99a0c78fe4 Use href as the view value to simplify logic 2025-10-04 22:58:50 +02:00
Girish Ramakrishnan 444d3eeb7c remove duplicate error message 2025-10-04 10:38:46 +02:00
Girish Ramakrishnan a3c00c5f75 Update lock file 2025-10-03 18:38:20 +02:00
Girish Ramakrishnan dfb465ef77 Fix date formatting in system graphs 2025-10-03 18:38:09 +02:00
Girish Ramakrishnan b20107ad2a Fix date formatting in graph tooltips 2025-10-03 18:25:32 +02:00
Girish Ramakrishnan 6977556984 Fix graph tooltip rendering 2025-10-03 17:50:49 +02:00
Girish Ramakrishnan 676f25962d add annotation label for the high marks 2025-10-03 17:27:23 +02:00
Girish Ramakrishnan 02fe971f0b Remove graph subtext and move to footer 2025-10-03 17:14:40 +02:00
Girish Ramakrishnan 0b310f849a rename Metrics to SystemInfo , Graphs 2025-10-03 17:04:48 +02:00
Girish Ramakrishnan c9c00d2b9f Make search by app id work 2025-10-03 12:58:13 +02:00
Johannes Zellner 910087e37a Improve spacing in DiskUsageItems 2025-10-03 12:37:21 +02:00
Johannes Zellner 9d99794242 Also show queued icon in email event log for outgoing mails 2025-10-03 12:02:27 +02:00
Johannes Zellner 3218d7c64d Reverse color fade in public pages left pane in dark mode 2025-10-03 11:55:56 +02:00
Girish Ramakrishnan 3364e5c876 ldapserver: fix crash when app not found 2025-10-03 11:55:20 +02:00
Girish Ramakrishnan a6355e1945 remove spammy log 2025-10-03 11:49:40 +02:00
Girish Ramakrishnan a3a8e67ce2 add some spacing between warning and changelog 2025-10-03 11:22:19 +02:00
Girish Ramakrishnan ac388e1daf Split the ubuntu version and cloudron version 2025-10-03 10:41:48 +02:00
543 changed files with 46193 additions and 35320 deletions
+2
View File
@@ -4,3 +4,5 @@ installer/src/certs/server.key
# vim swap files
*.swp
.cursor
+253
View File
@@ -2966,3 +2966,256 @@
* sqlite: fix issue where dump was also logged when backing up
* backups: remove noop backend
[9.0.1]
* redis: update to 8.2.2
* Split the ubuntu version and cloudron version
* Restructure sidebar menu items
* eventlog: fix display of backup (sites) events
* app archive: fix download config
* graphs: fix performance issue when selecting apps
* Support overwrite DNS in app install dialog
* encryption: do not allow password and hint to be the same
* Add better grouping to mailbox owner select
* eventlog: display task log link when available
* add ephemeral port warning
* rsync: fix integrity computation
[9.0.2]
* backupsite: only owner can add a site
* remove max-height from the users view and groups view tables
* backups: fix listing when stats is null
* graphs: fix detection of rootfs block device
* sidebar: ldap/openid/directory should not be visible to non-admins
* sidebar: email domains, eventlog, settings is only for admins
* reload dashboard on Cloudron version change
* Always start with a fresh domains list for the apps filter
* sysinfo: fallback to product family if product vendor is empty
* archive: display the site name of latest backup
* graphs: fix flickering of disk graph item
* graphs: fix issue with live graph time calculation
[9.0.3]
* Fix submit state for login form
* Avoid flickering of SystemUpdate view when update is busy
* backuptask: fix crash when accessing stats of old backups
* backup sites: fix listing when status call errors
* backups: display mail backup stats
* Add missing autocomplete attributes on forms
* Refresh backup site status and task in the background
* Hide non-owner actions for backup sites
* Move app start/stop back to the main toolbar
* Fix styling in public page
* network: fix ip caching bug
* Change default footer to not have the forum link
* Fix troubleshooting tool
* Give domains list a larger max-height
* Make app error compatible with previous releases
[9.0.4]
* filemanager: fix missing translations
* display backup duration
* add hetznercloud DNS provider
[9.0.5]
* access control/operators: remove deleted users and groups
* backupcleaner: fix scoping of cleanup by site id
* Use normal buttons for app start/stop
* site schedule: Fix hourly display
[9.0.6]
* Autofocus search in appstore view
* All settings in sidebar should be same icon
* Make backup content list a TableView so we can sort it by size and fileCount
* Fix filemanager for custom apps
* Sort apps in the grid by label
* Filter dropdowns are searchable with more than 10 entries
* Show app icons in the grid in grayscale if app is stopped
* Support wildcard domain aliases in app location
[9.0.7]
* externalldap: only set group members if they changed
* Fix issue where backups remote paths were incorrectly migrated
[9.0.8]
* Add explicit option to disable automatic backups
* backups: show same filesystem warning
* Fix tgz app backup download
* Fix mailbox usage and quota sorting
* Give sshfs identity files unique filenames across mounts
* Do not share relay provider setting with view and form
* cloudflare: ensure defaultProxyStatus in older configs
* filter: fix domain search to include redirect/alias/secondary domains
* Use full URLs for page preview icons and favicon
* email: fix masquerade toggle
[9.0.9]
* minio: fix issue with accepting selfsigned certs
* applink: fix button text in edit mode
* password reset: show error message if any
* sshfs: use a temporary identity file for remote ssh copy
* access control: always show the user management section
* update: show the last update error, if any
[9.0.10]
* Only enable LdapServer input fields if feature is enabled
* Require display name to not be empty when changed from the profile view
* access control: fix spacing
* storage: pass limits object to backend
[9.0.11]
* mail: fix count indicator when loading
* mailinglist: fix search on name
* backup site: fix migration with mixed formats
[9.0.12]
* eventlog: always fetch enough event logs to fill the screen
* mail: check for outbound ipv6 connectivity
* store actual appId not oidc clientId for log in events
* Add english labels for eventlog filtering
* mail: when deferred, show reason
* mail: prefer ipv4 for outbound mail
[9.0.13]
* Fix issue where footer/name can break templates
* rsync: bump empty dir limit to 80k
* nginx: do not log query params
* Fetch mailbox usage in the background to not delay mailbox listing
* cloudron-support: add --check-services and add it to troubleshoot
* Do not poll services if they are in recoveryMode
* restore/import: fix issue where prefix was empty
[9.0.14]
* Also use a temporary SSH identity file for optimized ssh remote rm -rf
* app search: title is optional manifest
* network: detect default ipv6 interface when no ipv4 interface
* mail status: fix rbl display
* platform: show any container upgrade errors in the UI
* users: make remove 2fa separate dialog
* mandatory 2fa: show undismissable dialog and warning
* restore: validate ipv6 config
* location: use the domain where app is installed as default
* s3: remove leading slash in CopySource
* gcs: fix copy operation
* restore: fix crash when trying to mount fs volumes
* restore: teardown pseudo backup site
* oidc: add separate jwks key route for cloudflare access
[9.0.15]
* sshfs: Use unique temporary ssh key file for each ssh remote operation
[9.0.16]
* Update mongodb to 7.0.28 (also fixes mongobleed)
* docker: do not use auth for appstore images
* backup: add synology C2
* mail: update haraka to 3.1.2
* csp/robots: add common patterns
[9.0.17]
* Update mongodb to 7.0.28 (also fixes mongobleed)
* UI: add favorites for list views
* UI: add collapsible sidebar
* docker: do not use auth for appstore images
* backup: add synology C2
* mail: update haraka to 3.1.2
* csp/robots: add common patterns
[9.0.18]
* ami & cloud images: fix setup
[9.1.0]
* acme: ARI support . https://www.rfc-editor.org/rfc/rfc9773.txt
* Update nodejs to 24.13.0
* Update docker to 29.1.5
* Update mongodb to 8.0.17
* Update redis to 8.4.0
* Add notification view. settings have moved to this new view.
* updater: skip backup site check when user skips backup
* community packages
* source builds
* backups: add integrity check UI
* Fix fonts on chrome
* applinks: fix acl UI
* services: rename sftp to filemanager, graphite to metrics
* app passwords: add expiry
* DO Spaces: add missing ATL1, BLR1, SYD1 regions
* filemanager: the terminal button automatically cds into the cwd
* filemanager: add a tree view
* passkey support
* security: remove cors
* support card/cal dav well-known endpoints
* add backupCommand, restoreCommand, persistentDirs
* Update Haraka to 3.1.3
[9.1.1]
* cli: use web based browser login flow
[9.1.2]
* apps: avoid flickering with filters
* apps: move to error state if a volume is unavailable
* apps: enable storage view in all error states
* postgres: update pgvector to 0.8.2
* appstore: add ai category
* appstore: better tag/cateogry mapping
* i18n: add Czech translations
* Support and prefer Dockerfile.cloudron in local builds
* integrity: show status in the info dialog
* backup: show integrity column for dependsOn backups
* integrity: show log link
* syncer: fix bug with a file and dir having same prefix
[9.1.3]
* Remove 'Dashboard' from dashboard page title
* integrity: skip check of backups with no integrity info
* backupintegrity: add percent progress
* apps: fix acl display
[9.1.4]
* services: lazy start services / on demand services
* restore: fix restore of trusted ips and blocklist
* dashboard: wait for dashboard reload when version has changed
* graphite: fix aggregation of block/network read/write
* Workaround chrome quirks on file drop handling
* notifications: add empty text, progress bar and inifinite scroll
* rsync: throttle log messages during download
* backup logs: make them much terse and concise
* oidc: implement Device Authorization Grant
* operator: fix viewing of backup progress and logs
* notification: automatic app update failure notification
* backup sites: identify conflicting site locations
* update: add policy to update apps and platform separately
* passkey: fix issue where passkeys were lost on restart
* passkey: implement passwordless login
* oidcserver: fix jwks_rsaonly response
[9.1.5]
* services: lazy start services / on demand services
* restore: fix restore of trusted ips and blocklist
* dashboard: wait for dashboard reload when version has changed
* graphite: fix aggregation of block/network read/write
* Workaround chrome quirks on file drop handling
* notifications: add empty text, progress bar and inifinite scroll
* rsync: throttle log messages during download
* backup logs: make them much terse and concise
* oidc: implement Device Authorization Grant
* operator: fix viewing of backup progress and logs
* notification: automatic app update failure notification
* backup sites: identify conflicting site locations
* update: add policy to update apps and platform separately
* passkey: fix issue where passkeys were lost on restart
* passkey: implement passwordless login
* oidcserver: fix jwks_rsaonly response
[9.1.6]
* apps: fix wrong disabled state for devices config
* notifications: send email when manual platform and app update required
* source install: support dockerfileName and build options
* source install: persist buildConfig so restore, import, clone work correctly
* search for matches in app links labels for apps view filter
* restore: prune portBindings whose tcpPorts/udpPorts no longer exist
* location: fix duplication of port bindings on submit
* Update translations
* location: show what DNS is being overwritten in location UI
* backup site: remove the local disk provider
* mail: update haraka to 3.1.4, tika to 3.3.0
* solr: dynamically allocate java heap based on container mem
+47 -50
View File
@@ -1,17 +1,19 @@
#!/usr/bin/env node
'use strict';
import constants from './src/constants.js';
import fs from 'node:fs';
import ldapServer from './src/ldapserver.js';
import net from 'node:net';
import authServer from './src/authserver.js';
import oidcServer from './src/oidcserver.js';
import paths from './src/paths.js';
import proxyAuth from './src/proxyauth.js';
import safe from '@cloudron/safetydance';
import server from './src/server.js';
import directoryServer from './src/directoryserver.js';
import logger from './src/logger.js';
const constants = require('./src/constants.js'),
fs = require('node:fs'),
ldapServer = require('./src/ldapserver.js'),
net = require('node:net'),
oidcServer = require('./src/oidcserver.js'),
paths = require('./src/paths.js'),
proxyAuth = require('./src/proxyauth.js'),
safe = require('safetydance'),
server = require('./src/server.js'),
directoryServer = require('./src/directoryserver.js');
const { log } = logger('box');
let logFd;
@@ -54,50 +56,45 @@ async function startServers() {
if (conf.enabled) await directoryServer.start();
}
async function main() {
const [error] = await safe(startServers());
if (error) return exitSync({ error, code: 1, message: 'Error starting servers' });
const [error] = await safe(startServers());
if (error) exitSync({ error, code: 1, message: 'Error starting servers' });
// require this here so that logging handler is already setup
const debug = require('debug')('box:box');
process.on('SIGHUP', async function () {
log('Received SIGHUP. Re-reading configs.');
const conf = await directoryServer.getConfig();
if (conf.enabled) await directoryServer.checkCertificate();
});
process.on('SIGHUP', async function () {
debug('Received SIGHUP. Re-reading configs.');
const conf = await directoryServer.getConfig();
if (conf.enabled) await directoryServer.checkCertificate();
});
process.on('SIGINT', async function () {
log('Received SIGINT. Shutting down.');
process.on('SIGINT', async function () {
debug('Received SIGINT. Shutting down.');
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
await authServer.stop();
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
setTimeout(() => {
log('Shutdown complete');
process.exit();
}, 2000); // need to wait for the task processes to die
});
setTimeout(() => {
debug('Shutdown complete');
process.exit();
}, 2000); // need to wait for the task processes to die
});
process.on('SIGTERM', async function () {
log('Received SIGTERM. Shutting down.');
process.on('SIGTERM', async function () {
debug('Received SIGTERM. Shutting down.');
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
await authServer.stop();
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldapServer.stop();
await oidcServer.stop();
setTimeout(() => {
log('Shutdown complete');
process.exit();
}, 2000); // need to wait for the task processes to die
});
setTimeout(() => {
debug('Shutdown complete');
process.exit();
}, 2000); // need to wait for the task processes to die
});
process.on('uncaughtException', (error) => exitSync({ error, code: 1, message: 'From uncaughtException handler.' }));
}
main();
process.on('uncaughtException', (uncaughtError) => exitSync({ error: uncaughtError, code: 1, message: 'From uncaughtException handler.' }));
+99
View File
@@ -0,0 +1,99 @@
## Translations
This documents the convention used for the text in the UI.
### Tale of Two Cases
**Title Case**
All words are capitalized. In title case, articles (a/an/the), conjunctions (and/but/or/...)
and prepositions (on/at/...) inside a phrase are not capitalized. Everything else is capitalized
- noun, pronoun, verb, adverb.
Examples:
* "Sign In to Your Account"
* "Terms and Conditions"
* "Getting Started with GraphQL"
* "Between You and Me"
**Sentence Case**
Only first word is capitalized.
### UI Conventions
Keeping as much as possible in Sentence Case helps in sharing the same strings.
| Element | Recommended Style | Example |
| -------------- | ---------------------- | -------------------------------- |
| Headings | Title Case | Manage Account |
| Sub heading | Title Case | Create Admin Account |
| Section/Card | Title Case | System Information |
| Form Labels | Sentence case | Email address |
| Form Groups | Sentence case | Volume mounts, Data directory |
| Table headings | Sentence case | Memory limit |
| Info sections | Sentence case | Cloudron version |
| Buttons | Sentence case | Save changes |
| Radio Buttons | Sentence case | Option one / Option two |
| Checkbox | Sentence case | Use CIFS encryption |
| Menu action | Sentence case | Select all |
| Switches | Sentence case | Allow users to edit email |
| Descriptions | Sentence case | Enter your password to continue. |
| Tooltips | Sentence case | Click to edit. |
| Error Messages | Sentence case | Password is too short |
| Notifications | Sentence case | Settings saved successfully. |
| Legend (graph) | Sentence case | Docker volume, Box data. |
| Placeholders | Sentence case | Comma separated IPs or subnets |
Hints in brackets are small case. Like "(comma separated)".
### Full Stops
Sentence fragments like form hints and tooltips (which are always visible) do not need a full stop.
All other full sentences do.
Description has a full stop unless it's a hint/phrase.
instructional heading in dialogs (like the object being configured) should not have a full stop.
Switch UI description does not have a fullstop.
Setting item description does not need a fullstop (usually).
Checkbox labels do not have a full stop at the end.
No full stop → short labels, commands, headings, or action text (“Configure Service {{serviceName}}”).
Full stop → descriptive text or sentences explaining a setting (“The IPv4 address used for DNS A records.”).
### Dialog Buttons
'Add' for addition
'Cancel' to cancel
'Save' for edit/update
'Remove' for non-destructive/less destructive things (app password remove)
'Delete' for destructive (user delete)
'Close' - Only for dialogs with the only button
### Dialog Text
When asking for confirmation simply ask 'Remove app password "xxx"' . Don't use "really"
or other emotional terms. Quote the password/domain name.
In general, we put just "Delete User" in Title and provide the username in the context.
Title = action (what youre doing)
Description = context (to whom it applies)
### Description Text
| Context | Verb form | Example |
| --------------------------------- | ------------------------ | ---------------------------------------------------------------------- |
| **Action / Button / Instruction** | **Imperative** → “Add” | Button: **Add**, Tooltip: “Add a new link” |
| **Section / View description** | **Imperative** → “Add” | Description: **Adds shortcuts to external services on the dashboard.** |
We use plural when possible. "Admins can ..." , "Operators can ..."
+51 -11
View File
@@ -2,19 +2,59 @@
<script>
const tmp = window.location.hash.slice(1).split('&');
(async function () {
const params = new URLSearchParams(window.location.search);
const code = params.get('code');
// FIXME: implicit flow (response_type=code token) results in access_token query param. this is not secure
tmp.forEach(function (pair) {
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
});
if (!code) {
console.error('No authorization code in callback URL');
window.location.replace('/');
return;
}
let redirectTo = '/';
if (localStorage.getItem('redirectToHash')) {
redirectTo += localStorage.getItem('redirectToHash');
localStorage.removeItem('redirectToHash');
}
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
const clientId = sessionStorage.getItem('pkce_client_id') || 'cid-webadmin';
const apiOrigin = sessionStorage.getItem('pkce_api_origin') || '';
window.location.replace(redirectTo); // this removes us from history
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('pkce_client_id');
sessionStorage.removeItem('pkce_api_origin');
try {
const response = await fetch(apiOrigin + '/openid/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: code,
client_id: clientId,
redirect_uri: window.location.origin + '/authcallback.html',
code_verifier: codeVerifier
})
});
const data = await response.json();
if (!response.ok || !data.access_token) {
console.error('Token exchange failed', data);
window.location.replace('/');
return;
}
localStorage.token = data.access_token;
} catch (e) {
console.error('Token exchange error', e);
window.location.replace('/');
return;
}
let redirectTo = '/';
if (localStorage.getItem('redirectToHash')) {
redirectTo += localStorage.getItem('redirectToHash');
localStorage.removeItem('redirectToHash');
}
window.location.replace(redirectTo);
})();
</script>
+5
View File
@@ -2,6 +2,11 @@
set -eu
# Check if the API origin is set, if not prompt the user to enter it
if [[ -z "${DASHBOARD_DEVELOPMENT_ORIGIN:-}" ]]; then
read -p "Enter the API origin (e.g. http://localhost:3000): " DASHBOARD_DEVELOPMENT_ORIGIN
fi
echo "=> Set API origin"
export VITE_API_ORIGIN="${DASHBOARD_DEVELOPMENT_ORIGIN}"
+2
View File
@@ -14,6 +14,8 @@ export default [
"prefer-const": "error",
"vue/no-reserved-component-names": "off",
"vue/multi-word-component-names": "off",
"vue/no-undef-components": "error",
'vue/no-root-v-if': "error",
}
}
];
+1 -1
View File
@@ -1,7 +1,7 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title><%= name %> Dashboard</title>
<title><%= name %></title>
</head>
<body>
<div id="app" style="overflow: hidden; height: 100%;"></div>
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenID Confirm</title>
<script>
window.cloudron = <%- JSON.stringify({ name, clientName, userCode, form }) %>;
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/oidcdeviceconfirm.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenID Device Sign-in</title>
<script>
window.cloudron = <%- JSON.stringify({ name, message, form }) %>;
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/oidcdeviceinput.js"></script>
</body>
</html>
+16
View File
@@ -0,0 +1,16 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>OpenID Device Success</title>
<script>
window.cloudron = <%- JSON.stringify({ name }) %>;
</script>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/oidcdevicesuccess.js"></script>
</body>
</html>
+1 -6
View File
@@ -4,12 +4,7 @@
<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, name, errorMessage, footer, 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>
+10 -7
View File
@@ -4,13 +4,16 @@
<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,
passkeyAuthOptionsUrl: passkeyAuthOptionsUrl,
passkeyLoginUrl: passkeyLoginUrl,
footer: footer,
language: language
}) %>;
</script>
</head>
+1699 -2621
View File
File diff suppressed because it is too large Load Diff
+18 -17
View File
@@ -7,26 +7,27 @@
},
"type": "module",
"dependencies": {
"@cloudron/pankow": "^3.3.1",
"@simplewebauthn/browser": "^13.3.0",
"@cloudron/pankow": "^4.1.8",
"@fontsource/inter": "^5.2.8",
"@fortawesome/fontawesome-free": "^7.0.1",
"@vitejs/plugin-vue": "^6.0.1",
"@xterm/addon-attach": "^0.11.0",
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0",
"anser": "^2.3.2",
"@fortawesome/fontawesome-free": "^7.2.0",
"@vitejs/plugin-vue": "^6.0.5",
"@xterm/addon-attach": "^0.12.0",
"@xterm/addon-fit": "^0.11.0",
"@xterm/xterm": "^6.0.0",
"anser": "^2.3.5",
"async": "^3.2.6",
"chart.js": "^4.5.0",
"chart.js": "^4.5.1",
"chartjs-plugin-annotation": "^3.1.0",
"eslint": "^9.36.0",
"eslint-plugin-vue": "^10.5.0",
"marked": "^16.3.0",
"eslint": "^10.1.0",
"eslint-plugin-vue": "^10.8.0",
"marked": "^17.0.5",
"moment": "^2.30.1",
"moment-timezone": "^0.6.0",
"vite": "^7.1.7",
"vite-plugin-singlefile": "^2.3.0",
"vue": "^3.5.22",
"vue-i18n": "^11.1.12",
"vue-router": "^4.5.1"
"moment-timezone": "^0.6.1",
"vite": "^8.0.3",
"vite-plugin-singlefile": "^2.3.2",
"vue": "^3.5.31",
"vue-i18n": "^11.3.0",
"vue-router": "^5.0.4"
}
}
+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>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-19
View File
@@ -3,22 +3,10 @@
"rebootDialog": {
"title": "本当にサーバーを再起動しますか?"
},
"clipboard": {
"clickToCopyBackupId": "バックアップIDをクリックしてコピー",
"clickToCopy": "クリックしてコピー",
"copied": "クリップボードにコピーしました"
},
"action": {
"logs": "ログ",
"reboot": "再起動"
},
"table": {
"date": "日付"
},
"pagination": {
"next": "次",
"prev": "前"
},
"displayName": "表示名",
"username": "ユーザー名",
"dialog": {
@@ -32,14 +20,7 @@
"offline": "Cloudronはオフラインです。再接続中…"
},
"apps": {
"tagsFilterHeaderAll": "タグ一覧",
"domainsFilterHeader": "ドメイン一覧",
"tagsFilterHeader": "タグ: {{ tags }}",
"searchPlaceholder": "アプリを探す",
"adminPageActionTooltip": "管理者ページ",
"infoActionTooltip": "情報",
"logsActionTooltip": "ログ",
"configActionTooltip": "設定",
"noAccess": {
"description": "アクセス権のあるアプリは、ここにに表示されます。",
"title": "アプリへのアクセス権がありません。"
File diff suppressed because it is too large Load Diff
+1 -45
View File
@@ -15,33 +15,11 @@
"userManagementNone": "Ta aplikacja posiada własne zarządzanie użytkownikami.",
"userManagement": "Zarządanie użytkownikami",
"manualWarning": "Manualnie dodaj rekord A dla <b>{{ location }}</b> do publicznego IP tego Cloudrona",
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>.",
"lowOnResources": "Ten Cloudron jest blisko wyczerpania dostępnych zasobów."
"configuredForCloudronEmail": "Ta aplikacja jest przygotowana aby używała <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Cloudrona</a>."
},
"unstable": "Niestabilne",
"appMissing": "Szukasz innej aplikacji? Daj nam znać.",
"noAppsFound": "Nie znaleziono żadnych aplikacji.",
"searchPlaceholder": "Szukaj alternatyw jak Github, Dropbox, Slack, Trello, ...",
"category": {
"vpn": "VPN",
"wiki": "Wiki",
"project": "Zarządzanie projetkami",
"sync": "Synchronizacja plików",
"learning": "Nauka",
"notes": "Notatki",
"media": "Media",
"git": "Hostowanie kodu",
"hosting": "Web Hosting",
"game": "Gry",
"email": "Email",
"finance": "Finanse",
"gallery": "Galeria",
"forum": "Forum",
"crm": "CRM",
"document": "Dokumenty",
"blog": "Blog",
"chat": "Czat",
"analytics": "Analityka",
"newApps": "Nowe aplikacje",
"popular": "Popularne",
"all": "Wszystko"
@@ -52,26 +30,12 @@
"rebootDialog": {
"rebootAction": "Zrestartuj teraz",
"description": "Restartuj serwer by sfinalizowac instalacje aktualizacji bezpieczeństwa lub w przypadku nieoczekiwanych zachowań. Wszytskie usługi i aplikacje aktywne na tym Cloudronie zostaną automatycznie uruchomione ponownie po restarcie.",
"warning": "Restart serwera spowoduje tymczasową niedostepność wszystkich aplikacji zainstalowanych na tym Cloudronie!",
"title": "Na pewno zrestartować serwer?"
},
"clipboard": {
"clickToCopyBackupId": "Kliknij by skopiowac Backup ID",
"clickToCopy": "Kliknij by skopiować",
"copied": "Skopiowano do schowka"
},
"action": {
"logs": "Logi",
"reboot": "Restart"
},
"pagination": {
"perPageSelector": "Pokazuj {{ n }} na stronie",
"prev": "Poprzednia",
"next": "Następna"
},
"table": {
"date": "Data"
},
"actions": "Akcje",
"displayName": "Wyświetlana nazwa",
"username": "Użytkownik",
@@ -86,15 +50,7 @@
"offline": "Cloudron jest niedostępny. Odnawiam połączenie…"
},
"apps": {
"domainsFilterHeader": "Wszytskie domeny",
"tagsFilterHeaderAll": "Wszystkie tagi",
"tagsFilterHeader": "Tagi: {{ tags }}",
"stateFilterHeader": "Wszytskie stany",
"searchPlaceholder": "Szukaj Aplikacji",
"adminPageActionTooltip": "Panel Administratora",
"infoActionTooltip": "Informacje",
"logsActionTooltip": "Logi",
"configActionTooltip": "Konfiguracja",
"noAccess": {
"description": "Po uzyskaniu dostępu będą one widoczne tutaj.",
"title": "Nie masz obecnie dostępu do żadnych aplikacji."
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+2 -32
View File
@@ -2,12 +2,6 @@
"main": {
"logout": "නික්මෙන්න",
"actions": "ක්‍රියාමාර්ග",
"prettyDate": {
"minutesAgo": "විනාඩි {{ m }} ට පෙර",
"hoursAgo": "හෝරා {{ h }} ට පෙර",
"justNow": "මේ දැන්",
"yeserday": "ඊයේ"
},
"dialog": {
"cancel": "අවලංගු",
"save": "සුරකින්න",
@@ -16,13 +10,6 @@
"yes": "ඔව්"
},
"username": "පරිශීලක නාමය",
"table": {
"date": "දිනය"
},
"pagination": {
"prev": "පෙර",
"next": "ඊළඟ"
},
"searchPlaceholder": "සොයන්න",
"multiselect": {
"select": "තෝරන්න"
@@ -30,35 +17,18 @@
},
"appstore": {
"category": {
"chat": "සම්භාෂණය",
"learning": "ඉගෙනීම",
"project": "ව්‍යාපෘති කළමනාකරණය",
"all": "සියල්ල",
"popular": "ජනප්‍රිය",
"newApps": "නව යෙදුම්",
"analytics": "විශ්ලේෂ",
"document": "ලේඛන",
"crm": "පා.ස.ක. (CRM)",
"finance": "මූල්‍ය",
"email": "වි-තැපෑල",
"game": "ක්‍රීඩා",
"media": "මාධ්‍ය",
"notes": "සටහන්"
"newApps": "නව යෙදුම්"
},
"title": "යෙදුම් ගබඩාව",
"installDialog": {
"location": "ස්ථානය",
"groups": "සමූහ"
},
"accountDialog": {
"password": "මුරපදය",
"email": "වි-තැපෑල"
}
},
"apps": {
"title": "මාගේ යෙදුම්",
"infoActionTooltip": "තොරතුරු",
"searchPlaceholder": "යෙදුම් සොයන්න",
"domainsFilterHeader": "සියලුම වසම්"
"searchPlaceholder": "යෙදුම් සොයන්න"
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+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>
+324 -292
View File
@@ -1,24 +1,31 @@
<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, InputDialog, fetcher } from '@cloudron/pankow';
import { setLanguage } from './i18n.js';
import { API_ORIGIN, TOKEN_TYPES } from './constants.js';
import { redirectIfNeeded } from './utils.js';
import { redirectIfNeeded, startAuthFlow } from './utils.js';
import ProfileModel from './models/ProfileModel.js';
import ProvisionModel from './models/ProvisionModel.js';
import NotificationsModel from './models/NotificationsModel.js';
import DashboardModel from './models/DashboardModel.js';
import BrandingModel from './models/BrandingModel.js';
import AppstoreModel from './models/AppstoreModel.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';
import AppstoreView from './views/AppstoreView.vue';
import BackupSitesView from './views/BackupSitesView.vue';
import BackupAppArchiveView from './views/BackupAppArchiveView.vue';
import BackupListView from './views/BackupListView.vue';
import AppArchiveView from './views/AppArchiveView.vue';
import CloudronAccountView from './views/CloudronAccountView.vue';
import DomainsView from './views/DomainsView.vue';
import EmailDomainView from './views/EmailDomainView.vue';
@@ -29,57 +36,222 @@ import EmailSettingsView from './views/EmailSettingsView.vue';
import EmailEventlogView from './views/EmailEventlogView.vue';
import EventlogView from './views/EventlogView.vue';
import NetworkView from './views/NetworkView.vue';
import NotificationsView from './views/NotificationsView.vue';
import ProfileView from './views/ProfileView.vue';
import ServicesView from './views/ServicesView.vue';
import SystemLocaleView from './views/SystemLocaleView.vue';
import SystemSettingsView from './views/SystemSettingsView.vue';
import SystemUpdateView from './views/SystemUpdateView.vue';
import SystemDockerView from './views/SystemDockerView.vue';
import MetricsView from './views/MetricsView.vue';
import DockerView from './views/DockerView.vue';
import ServerView from './views/ServerView.vue';
import UserDirectorySettingsView from './views/UserDirectorySettingsView.vue';
import UserDirectoryLdapServerView from './views/UserDirectoryLdapServerView.vue';
import UserDirectoryOpenIdProviderView from './views/UserDirectoryOpenIdProviderView.vue';
import LdapView from './views/LdapView.vue';
import OpenIdView from './views/OpenIdView.vue';
import UsersView from './views/UsersView.vue';
import GroupsView from './views/GroupsView.vue';
import VolumesView from './views/VolumesView.vue';
const VIEWS = {
APP: 'app',
APPEARANCE: 'appearance',
APPS: 'apps',
APPSTORE: 'appstore',
BACKUP_SITES: 'backup-sites',
BACKUP_LIST: 'backup-list',
BACKUP_APP_ARCHIVE: 'backup-app-archive',
CLOUDRON_ACCOUNT: 'cloudron-account',
DOMAINS: 'domains',
EMAIL_DOMAIN: 'email-domain',
EMAIL_DOMAINS: 'email-domains',
EMAIL_MAILBOXES: 'email-mailboxes',
EMAIL_MAILINGLISTS: 'email-mailinglists',
EMAIL_SETTINGS: 'email-settings',
EMAIL_EVENTLOG: 'email-eventlog',
METRICS: 'metrics',
NETWORK: 'network',
PROFILE: 'profile',
SERVICES: 'services',
SYSTEM_LOCALE: 'system-locale',
SYSTEM_DOCKER: 'system-docker',
SYSTEM_EVENTLOG: 'system-eventlog',
SYSTEM_UPDATE: 'system-update',
USER_DIRECTORY_SETTINGS: 'user-directory-settings',
USER_DIRECTORY_LDAP_SERVER: 'user-directory-ldap-server',
USER_DIRECTORY_OPENID_PROVIDER: 'user-directory-openid-provider',
USERS: 'users',
GROUPS: 'users-groups',
VOLUMES: 'volumes',
};
const VIEWS = Object.freeze({
APP: '#/app', // this is a prefix
APPEARANCE: '#/appearance',
APPS: '#/apps',
APPSTORE: '#/appstore', // this is a prefix
BACKUP_SITES: '#/backup-sites',
APP_ARCHIVE: '#/app-archive',
CLOUDRON_ACCOUNT: '#/cloudron-account',
DOMAINS: '#/domains',
EMAIL_DOMAIN: '#/email-domain',
EMAIL_DOMAINS: '#/email-domains',
MAILBOXES: '#/mailboxes',
MAILINGLISTS: '#/mailinglists',
EMAIL_SETTINGS: '#/email-settings',
EMAIL_EVENTLOG: '#/email-eventlog',
SERVER: '#/server',
NETWORK: '#/network',
NOTIFICATIONS: '#/notifications',
PROFILE: '#/profile',
SERVICES: '#/services',
SYSTEM_SETTINGS: '#/system-settings',
DOCKER: '#/docker',
SYSTEM_EVENTLOG: '#/system-eventlog',
SYSTEM_UPDATE: '#/system-update',
USER_DIRECTORY_SETTINGS: '#/user-directory-settings',
LDAP: '#/ldap',
OPENID: '#/openid',
USERS: '#/users',
GROUPS: '#/groups',
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');
function onOnline() {
ready.value = true;
}
fetcher.globalOptions.errorHook = (error) => {
// network error, request killed by browser
if (error instanceof TypeError) {
@@ -105,12 +277,16 @@ fetcher.globalOptions.errorHook = (error) => {
const dashboardModel = DashboardModel.create();
const profileModel = ProfileModel.create();
const provisionModel = ProvisionModel.create();
const notificationModel = NotificationsModel.create();
const appstoreModel = AppstoreModel.create();
const sidebar = useTemplateRef('sidebar');
const inputDialog = useTemplateRef('inputDialog');
const subscriptionRequiredDialog = useTemplateRef('subscriptionRequiredDialog');
const ready = ref(false);
const view = ref('');
const profile = ref({});
const dashboardDomain = ref('');
const notificationCount = ref(0);
const subscription = ref({
plan: {},
});
@@ -118,32 +294,8 @@ const config = ref({});
const avatarUrl = ref('');
const features = ref({});
function onSidebarClose() {
sidebar.value.close();
}
const activeSidebarItem = ref('');
const activeSidebarGroups = ref({
'backup': false,
'email': false,
'system': false,
'user-directory': false,
'users': false
});
function onToggleGroup(group) {
activeSidebarGroups.value[group] = !activeSidebarGroups.value[group];
}
function onHashChange() {
const v = location.hash.slice(2);
activeSidebarItem.value = v;
if (activeSidebarItem.value.indexOf('backup') === 0) activeSidebarGroups.value['backup'] = true;
else if (activeSidebarItem.value.indexOf('email') === 0) activeSidebarGroups.value['email'] = true;
else if (activeSidebarItem.value.indexOf('system') === 0) activeSidebarGroups.value['system'] = true;
else if (activeSidebarItem.value.indexOf('user-directory') === 0) activeSidebarGroups.value['user-directory'] = true;
else if (activeSidebarItem.value.indexOf('users') === 0) activeSidebarGroups.value['users'] = true;
const v = window.location.hash.split('?')[0];
if (v === VIEWS.APPS) {
view.value = VIEWS.APPS;
@@ -155,10 +307,8 @@ function onHashChange() {
view.value = VIEWS.APPEARANCE;
} else if (v === VIEWS.BACKUP_SITES && profile.value.isAtLeastAdmin) {
view.value = VIEWS.BACKUP_SITES;
} else if (v === VIEWS.BACKUP_LIST && profile.value.isAtLeastAdmin) {
view.value = VIEWS.BACKUP_LIST;
} else if (v === VIEWS.BACKUP_APP_ARCHIVE && profile.value.isAtLeastAdmin) {
view.value = VIEWS.BACKUP_APP_ARCHIVE;
} else if (v === VIEWS.APP_ARCHIVE && profile.value.isAtLeastAdmin) {
view.value = VIEWS.APP_ARCHIVE;
} else if (v === VIEWS.CLOUDRON_ACCOUNT && profile.value.isAtLeastOwner) {
view.value = VIEWS.CLOUDRON_ACCOUNT;
} else if (v === VIEWS.DOMAINS && profile.value.isAtLeastAdmin) {
@@ -167,36 +317,38 @@ function onHashChange() {
view.value = VIEWS.EMAIL_DOMAIN;
} else if (v === VIEWS.EMAIL_DOMAINS && profile.value.isAtLeastMailManager) {
view.value = VIEWS.EMAIL_DOMAINS;
} else if (v === VIEWS.EMAIL_MAILBOXES && profile.value.isAtLeastMailManager) {
view.value = VIEWS.EMAIL_MAILBOXES;
} else if (v === VIEWS.EMAIL_MAILINGLISTS && profile.value.isAtLeastMailManager) {
view.value = VIEWS.EMAIL_MAILINGLISTS;
} else if (v === VIEWS.MAILBOXES && profile.value.isAtLeastMailManager) {
view.value = VIEWS.MAILBOXES;
} else if (v === VIEWS.MAILINGLISTS && profile.value.isAtLeastMailManager) {
view.value = VIEWS.MAILINGLISTS;
} else if (v === VIEWS.EMAIL_SETTINGS && profile.value.isAtLeastMailManager) {
view.value = VIEWS.EMAIL_SETTINGS;
} else if (v === VIEWS.EMAIL_EVENTLOG && profile.value.isAtLeastMailManager) {
view.value = VIEWS.EMAIL_EVENTLOG;
} else if (v === VIEWS.METRICS && profile.value.isAtLeastAdmin) {
view.value = VIEWS.METRICS;
} else if (v === VIEWS.SERVER && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SERVER;
} else if (v === VIEWS.NOTIFICATIONS && profile.value.isAtLeastAdmin) {
view.value = VIEWS.NOTIFICATIONS;
} else if (v === VIEWS.NETWORK && profile.value.isAtLeastAdmin) {
view.value = VIEWS.NETWORK;
} else if (v.indexOf(VIEWS.PROFILE) === 0) {
} else if (v === VIEWS.PROFILE) {
view.value = VIEWS.PROFILE;
} else if (v === VIEWS.SERVICES && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SERVICES;
} else if (v === VIEWS.SYSTEM_LOCALE && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SYSTEM_LOCALE;
} else if (v === VIEWS.SYSTEM_DOCKER && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SYSTEM_DOCKER;
} else if (v.indexOf(VIEWS.SYSTEM_EVENTLOG) === 0 && profile.value.isAtLeastAdmin) {
} else if (v === VIEWS.SYSTEM_SETTINGS && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SYSTEM_SETTINGS;
} else if (v === VIEWS.DOCKER && profile.value.isAtLeastAdmin) {
view.value = VIEWS.DOCKER;
} else if (v === VIEWS.SYSTEM_EVENTLOG && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SYSTEM_EVENTLOG;
} else if (v === VIEWS.SYSTEM_UPDATE && profile.value.isAtLeastAdmin) {
view.value = VIEWS.SYSTEM_UPDATE;
} else if (v === VIEWS.USER_DIRECTORY_SETTINGS && profile.value.isAtLeastAdmin) {
view.value = VIEWS.USER_DIRECTORY_SETTINGS;
} else if (v === VIEWS.USER_DIRECTORY_LDAP_SERVER && profile.value.isAtLeastAdmin) {
view.value = VIEWS.USER_DIRECTORY_LDAP_SERVER;
} else if (v === VIEWS.USER_DIRECTORY_OPENID_PROVIDER && profile.value.isAtLeastAdmin) {
view.value = VIEWS.USER_DIRECTORY_OPENID_PROVIDER;
} else if (v === VIEWS.LDAP && profile.value.isAtLeastAdmin) {
view.value = VIEWS.LDAP;
} else if (v === VIEWS.OPENID && profile.value.isAtLeastAdmin) {
view.value = VIEWS.OPENID;
} else if (v === VIEWS.USERS && profile.value.isAtLeastUserManager) {
view.value = VIEWS.USERS;
} else if (v === VIEWS.GROUPS && profile.value.isAtLeastUserManager) {
@@ -204,7 +356,7 @@ function onHashChange() {
} else if (v === VIEWS.VOLUMES && profile.value.isAtLeastAdmin) {
view.value = VIEWS.VOLUMES;
} else {
window.location.hash = '/' + VIEWS.APPS;
window.location.href = VIEWS.APPS;
}
}
@@ -224,54 +376,111 @@ 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) {
localStorage.setItem('version', result.version);
} else if (result.version !== currentVersion) {
console.log('Dashboard version changed, reloading');
localStorage.setItem('version', result.version);
window.location.reload(true);
// return never ending promise to just wait for the reload
return new Promise(() => {});
}
config.value = result;
features.value = result.features;
dashboardDomain.value = result.adminDomain;
}
async function refreshNotifications() {
const [error, result] = await notificationModel.list(false);
if (error) return console.error(error);
notificationCount.value = result.length;
}
async function refreshSubscription() {
const [error, result] = await appstoreModel.getSubscription();
if (error && error.status === 402) console.error('Not yet registered');
else if (error && error.status === 412) window.location.href = ''
else if (error) console.error(error);
else subscription.value = result;
}
async function onOnline() {
ready.value = true;
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('subscription', subscription);
provide('features', features);
provide('profile', profile);
provide('refreshProfile', refreshProfile);
provide('refreshFeatures', refreshConfigAndFeatures);
provide('refreshNotifications', refreshNotifications);
provide('dashboardDomain', dashboardDomain);
provide('isMobile', isMobile);
provide('inputDialog', inputDialog);
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...
if (!localStorage.token) {
localStorage.setItem('redirectToHash', window.location.hash);
// start oidc flow
window.location.href = `${API_ORIGIN}/openid/auth?client_id=` + (API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN) + '&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
const clientId = API_ORIGIN ? TOKEN_TYPES.ID_DEVELOPMENT : TOKEN_TYPES.ID_WEBADMIN;
window.location.href = await startAuthFlow(clientId, API_ORIGIN);
return;
}
await refreshConfigAndFeatures();
await refreshProfile();
// ensure language from profile if set
if (profile.value.language) await setLanguage(profile.value.language, true);
await refreshConfigAndFeatures();
avatarUrl.value = `https://${config.value.adminFqdn}/api/v1/cloudron/avatar`;
if (document.querySelector('link[rel="icon"]')) document.querySelector('link[rel="icon"]').href = `${API_ORIGIN}/api/v1/cloudron/avatar?ts=${Date.now()}`;
if (config.value.mandatory2FA && !profile.value.twoFactorAuthenticationEnabled) window.location.hash = `/${VIEWS.PROFILE}?setup2fa`;
window.addEventListener('hashchange', onHashChange);
onHashChange();
console.log(`Cloudron dashboard v${config.value.version}`);
if (profile.value.isAtLeastAdmin) {
refreshNotifications();
refreshSubscription();
}
ready.value = true;
// when done, redirect the user to setup 2fa if it is mandatory and neither totp nor passkey is setup
if (config.value.mandatory2FA && !profile.value.totpEnabled && !profile.value.hasPasskey) {
return window.location.href = VIEWS.PROFILE;
}
});
onUnmounted(() => {
window.removeEventListener('resize', checkForMobile);
});
</script>
@@ -281,82 +490,14 @@ onMounted(async () => {
<Notification />
<OfflineOverlay ref="offlineOverlay" @online="onOnline()" :href="'https://docs.cloudron.io/troubleshooting/'" :label="$t('main.offline')" />
<SubscriptionRequiredDialog ref="subscriptionRequiredDialog"/>
<RequestErrorDialog/>
<InputDialog ref="inputDialog"/>
<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: activeSidebarItem === 'apps' }" href="#/apps" @click="onSidebarClose()"><i class="fa fa-grip fa-fw"></i> {{ $t('apps.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'appstore' }" v-show="profile.isAtLeastAdmin" href="#/appstore" @click="onSidebarClose()"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ $t('appstore.title') }}</a>
<hr/>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'appearance' }" v-show="profile.isAtLeastAdmin" href="#/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('backup')"><i class="fa fa-archive fa-fw"></i> {{ $t('backups.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['backup'] }" style="margin-left: 6px;"></i></div>
<Transition name="sidebar-item-group-animation">
<div class="sidebar-item-group" v-if="activeSidebarGroups['backup']">
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'backup-sites' }" href="#/backup-sites" @click="onSidebarClose()"><i class="fa fa-fw fa-hard-drive"></i> {{ $t('backups.sites.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'backup-list' }" href="#/backup-list" @click="onSidebarClose()"><i class="fa fa-fw fa-list-check"></i> {{ $t('backups.listing.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'backup-app-archive' }" href="#/backup-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: activeSidebarItem === 'domains' }" v-show="profile.isAtLeastAdmin" href="#/domains" @click="onSidebarClose()"><i class="fa fa-globe fa-fw"></i> {{ $t('domains.title') }}</a>
<div class="sidebar-item" v-show="profile.isAtLeastMailManager" @click="onToggleGroup('email')"><i class="fa fa-envelope fa-fw"></i> {{ $t('emails.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['email'] }" style="margin-left: 6px;"></i></div>
<Transition name="sidebar-item-group-animation">
<div class="sidebar-item-group" v-if="activeSidebarGroups['email']">
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-domains' }" href="#/email-domains" @click="onSidebarClose()"><i class="fa fa-fw fa-globe"></i> Domains</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-mailboxes' }" href="#/email-mailboxes" @click="onSidebarClose()"><i class="fa fa-fw fa-inbox"></i> {{ $t('email.incoming.mailboxes.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-mailinglists' }" href="#/email-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: activeSidebarItem === 'email-eventlog' }" href="#/email-eventlog" @click="onSidebarClose()"><i class="fa fa-fw fa-list-alt"></i> {{ $t('emails.eventlog.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'email-settings' }" href="#/email-settings" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('emails.settings.title') }}</a>
</div>
</Transition>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'network' }" v-show="profile.isAtLeastAdmin" href="#/network" @click="onSidebarClose()"><i class="fas fa-network-wired fa-fw"></i> {{ $t('network.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'services' }" v-show="profile.isAtLeastAdmin" href="#/services" @click="onSidebarClose()"><i class="fa fa-cogs fa-fw"></i> {{ $t('services.title') }}</a>
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup('system')"><i class="fa fa-wrench fa-fw"></i> {{ $t('settings.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['system'] }" style="margin-left: 6px;"></i></div>
<Transition name="sidebar-item-group-animation">
<div class="sidebar-item-group" v-if="activeSidebarGroups['system']">
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-docker' }" href="#/system-docker" @click="onSidebarClose()"><i class="fa-brands fa-fw fa-docker"></i> {{ $t('dockerRegistries.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-eventlog' }" href="#/system-eventlog" @click="onSidebarClose()"><i class="fa fa-list-alt fa-fw"></i> {{ $t('eventlog.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-locale' }" href="#/system-locale" @click="onSidebarClose()"><i class="fa fa-fw fa-language"></i> {{ $t('system.locale.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'system-update' }" href="#/system-update" @click="onSidebarClose()"><i class="fa fa-fw fa-square-up-right"></i> {{ $t('settings.updates.title') }}</a>
</div>
</Transition>
<div class="sidebar-item" v-show="profile.isAtLeastUserManager" @click="onToggleGroup('users')"><i class="fa fa-users-gear fa-fw"></i> {{ $t('users.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['users'] }" style="margin-left: 6px;"></i></div>
<Transition name="sidebar-item-group-animation">
<div class="sidebar-item-group" v-if="activeSidebarGroups['users']">
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'users' }" v-show="profile.isAtLeastUserManager" href="#/users" @click="onSidebarClose()"><i class="fa fa-user fa-fw"></i> {{ $t('main.navbar.users') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'users-groups' }" v-show="profile.isAtLeastUserManager" href="#/users-groups" @click="onSidebarClose()"><i class="fa fa-users fa-fw"></i> {{ $t('main.navbar.groups') }}</a>
</div>
</Transition>
<div class="sidebar-item" v-show="profile.isAtLeastAdmin" @click="onToggleGroup('user-directory')"><i class="fa fa-address-book fa-fw"></i> {{ $t('userDirectory.title') }} <i class="collapse fa-solid fa-angle-right" :class="{ expanded: activeSidebarGroups['user-directory'] }" style="margin-left: 6px;"></i></div>
<Transition name="sidebar-item-group-animation">
<div class="sidebar-item-group" v-if="activeSidebarGroups['user-directory']">
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'user-directory-ldap-server' }" href="#/user-directory-ldap-server" @click="onSidebarClose()"><i class="fa fa-fw fa-users-rays"></i> {{ $t('users.exposedLdap.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'user-directory-openid-provider' }" href="#/user-directory-openid-provider" @click="onSidebarClose()"><i class="fa fa-fw fa-brands fa-openid"></i> {{ $t('oidc.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'user-directory-settings' }" href="#/user-directory-settings" @click="onSidebarClose()"><i class="fa fa-fw fa-cog"></i> {{ $t('userdirectory.settings.title') }}</a>
</div>
</Transition>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'volumes' }" v-show="profile.isAtLeastAdmin" href="#/volumes" @click="onSidebarClose()"><i class="fa fa-hdd fa-fw"></i> {{ $t('volumes.title') }}</a>
<hr v-show="profile.isAtLeastAdmin"/>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'metrics' }" v-show="profile.isAtLeastAdmin" href="#/metrics" @click="onSidebarClose()"><i class="fa fa-chart-area fa-fw"></i> {{ $t('system.title') }}</a>
<a class="sidebar-item" :class="{ active: activeSidebarItem === 'cloudron-account' }" v-show="profile.isAtLeastOwner" href="#/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"/>
<Headerbar :config="config" :notification-count="notificationCount"/>
<div style="display: flex; justify-content: center; overflow: auto; flex-grow: 1; padding: 0; margin: 0 10px; position: relative;">
<KeepAlive>
@@ -366,27 +507,27 @@ onMounted(async () => {
<AppConfigureView v-if="view === VIEWS.APP" />
<AppearanceView v-else-if="view === VIEWS.APPEARANCE" />
<BackupSitesView v-else-if="view === VIEWS.BACKUP_SITES" />
<BackupListView v-else-if="view === VIEWS.BACKUP_LIST" />
<BackupAppArchiveView v-else-if="view === VIEWS.BACKUP_APP_ARCHIVE" />
<AppArchiveView v-else-if="view === VIEWS.APP_ARCHIVE" />
<CloudronAccountView v-else-if="view === VIEWS.CLOUDRON_ACCOUNT" />
<DomainsView v-else-if="view === VIEWS.DOMAINS" />
<EmailDomainsView v-else-if="view === VIEWS.EMAIL_DOMAINS" />
<EmailDomainView v-else-if="view === VIEWS.EMAIL_DOMAIN" />
<EmailMailboxesView v-else-if="view === VIEWS.EMAIL_MAILBOXES" />
<EmailMailinglistsView v-else-if="view === VIEWS.EMAIL_MAILINGLISTS" />
<EmailMailboxesView v-else-if="view === VIEWS.MAILBOXES" />
<EmailMailinglistsView v-else-if="view === VIEWS.MAILINGLISTS" />
<EmailSettingsView v-else-if="view === VIEWS.EMAIL_SETTINGS" />
<EmailEventlogView v-else-if="view === VIEWS.EMAIL_EVENTLOG" />
<EventlogView v-else-if="view === VIEWS.SYSTEM_EVENTLOG" />
<MetricsView v-else-if="view === VIEWS.METRICS" />
<ServerView v-else-if="view === VIEWS.SERVER" />
<NetworkView v-else-if="view === VIEWS.NETWORK" />
<NotificationsView v-else-if="view === VIEWS.NOTIFICATIONS" />
<ProfileView v-else-if="view === VIEWS.PROFILE" />
<ServicesView v-else-if="view === VIEWS.SERVICES" />
<SystemLocaleView v-else-if="view === VIEWS.SYSTEM_LOCALE" />
<SystemDockerView v-else-if="view === VIEWS.SYSTEM_DOCKER" />
<SystemSettingsView v-else-if="view === VIEWS.SYSTEM_SETTINGS" />
<DockerView v-else-if="view === VIEWS.DOCKER" />
<SystemUpdateView v-else-if="view === VIEWS.SYSTEM_UPDATE" />
<UserDirectorySettingsView v-else-if="view === VIEWS.USER_DIRECTORY_SETTINGS" />
<UserDirectoryLdapServerView v-else-if="view === VIEWS.USER_DIRECTORY_LDAP_SERVER" />
<UserDirectoryOpenIdProviderView v-else-if="view === VIEWS.USER_DIRECTORY_OPENID_PROVIDER" />
<LdapView v-else-if="view === VIEWS.LDAP" />
<OpenIdView v-else-if="view === VIEWS.OPENID" />
<UsersView v-else-if="view === VIEWS.USERS" />
<GroupsView v-else-if="view === VIEWS.GROUPS" />
<VolumesView v-else-if="view === VIEWS.VOLUMES" />
@@ -395,112 +536,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>
+52 -46
View File
@@ -1,69 +1,75 @@
<script setup>
import { ref, onMounted } from 'vue';
import { computed } from 'vue';
import { FormGroup, Radiobutton, MultiSelect } from '@cloudron/pankow';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
import { ACL_OPTIONS } from '../constants.js';
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
const props = defineProps([ 'manifest', 'error', 'hideOptionalSsoOption' ]);
const props = defineProps({
users: {
type: Array,
required: true,
},
groups: {
type: Array,
required: true,
},
manifest: {
type: Object,
required: true,
},
sso: {
type: Boolean,
default: false,
required: false,
},
installation: {
type: Boolean,
required: true,
},
});
const accessRestrictionOption = defineModel('option');
const accessRestriction = defineModel('acl');
const users = ref([]);
const groups = ref([]);
const optionalSso = !!props.manifest.optionalSso;
const cloudronAuth = !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']) && !props.hideOptionalSsoOption;
onMounted(async () => {
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => u.username = u.username || u.email);
users.value = result;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
const optionalSso = computed(() => {
return !!props.manifest.optionalSso && props.installation;
});
const cloudronAuth = computed(() => {
return !(!props.installation && !props.sso) && !!(props.manifest.addons['ldap'] || props.manifest.addons['oidc'] || props.manifest.addons['proxyAuth']);
});
</script>
<template>
<div>
<FormGroup v-show="manifest.addons.email">
<label>{{ $t('appstore.installDialog.userManagement') }}</label>
<div>
{{ $t('appstore.installDialog.userManagementMailbox') }}
<span v-html="$t('appstore.installDialog.configuredForCloudronEmail', { emailDocsLink: 'https://docs.cloudron.io/email/' })"></span>
</div>
<FormGroup>
<label>{{ $t('appstore.installDialog.userManagement') }} <sup v-if="!manifest.addons.email"><a href="https://docs.cloudron.io/apps/#access-control" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
<div v-if="!cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
<div v-if="manifest.addons.email" v-html="$t('appstore.installDialog.userManagementMailbox')"></div>
</FormGroup>
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
<FormGroup>
<label v-show="cloudronAuth && !manifest.addons.email">{{ $t('appstore.installDialog.userManagement') }} <sup><a href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-show="cloudronAuth && !manifest.addons.email">{{ $t('app.accessControl.userManagement.description') }}</div>
<label v-show="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-show="!cloudronAuth || manifest.addons.email">{{ $t('appstore.installDialog.userManagementNone') }}</div>
<div style="padding-top: 10px">
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
</div>
<label v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="!cloudronAuth || manifest.addons.email">{{ $t('app.accessControl.dashboardVisibility.description') }}</div>
</FormGroup>
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED">
<div style="margin-left: 20px; display: flex; gap: 10px;">
<div>
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="username" :search-threshold="20" />
</div>
<div>
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
</div>
<div style="padding-top: 10px" v-if="!cloudronAuth || manifest.addons.email"/>
<div>
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.NOSSO" v-if="cloudronAuth && optionalSso" :label="$t('appstore.installDialog.userManagementLeaveToApp')"/>
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.ANY" :label="cloudronAuth ? $t('appstore.installDialog.userManagementAllUsers') : $t('app.accessControl.userManagement.visibleForAllUsers')"/>
<Radiobutton v-model="accessRestrictionOption" :value="ACL_OPTIONS.RESTRICTED" :label="cloudronAuth ? $t('appstore.installDialog.userManagementSelectUsers') : $t('app.accessControl.userManagement.visibleForSelected')"/>
</div>
<div v-if="accessRestrictionOption === ACL_OPTIONS.RESTRICTED" style="margin-top: 12px; margin-left: 20px; display: flex; gap: 10px;">
<div>
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :search-threshold="20" />
</div>
<div>
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
</div>
</div>
</div>
+113
View File
@@ -0,0 +1,113 @@
<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(() => {
if (window.innerWidth <= 576) return [];
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 && 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>
+26
View File
@@ -0,0 +1,26 @@
<template>
<span class="dots"></span>
</template>
<style scoped>
.dots {
display: inline-block;
width: 1.5em; /* reserve enough space for '...' */
text-align: left;
}
.dots::after {
content: '';
animation: dots 1.5s steps(4, end) infinite;
}
@keyframes dots {
0% { content: ''; }
25% { content: '.'; }
50% { content: '..'; }
75% { content: '...'; }
100% { content: ''; }
}
</style>
+47 -48
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, Dialog, InputDialog, FormGroup, Radiobutton, TableView, TextInput, InputGroup } from '@cloudron/pankow';
import { copyToClipboard, prettyLongDate } from '@cloudron/pankow/utils';
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;
@@ -89,11 +91,6 @@ async function onSubmitAddApiToken(){
await refreshApiTokens();
}
function onCopyToClipboard(apiToken) {
copyToClipboard(apiToken);
window.pankow.notify({ type: 'success', text: 'API Token copied!' });
}
function onReset() {
setTimeout(() => {
addedToken.value = '';
@@ -101,15 +98,18 @@ function onReset() {
tokenScope.value = 'rw';
tokenAllowedIpRanges.value = '';
tokenAllowedIpRangesError.value = '';
setTimeout(checkValidity, 100); // update state of the confirm button
}, 500);
}
async function onRevokeToken(apiToken) {
const yes = await inputDialog.value.confirm({
message: t('profile.removeApiToken.title', { name: apiToken.name }),
title: t('profile.removeApiToken.title'),
message: t('profile.removeApiToken.description', { name: apiToken.name }),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no')
confirmLabel: t('main.action.remove'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -128,14 +128,14 @@ onMounted(async () => {
<template>
<div>
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<Dialog ref="newDialog"
:title="$t('profile.createApiToken.title')"
:confirm-label="addedToken ? '' : $t('profile.createApiToken.generateToken')"
:confirm-label="addedToken ? '' : $t('main.action.add')"
:confirm-active="isFormValid"
confirm-style="primary"
:reject-label="$t('main.dialog.close')"
:reject-label="addedToken ? $t('main.dialog.close') : $t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmitAddApiToken()"
@close="onReset()"
@@ -143,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/>
@@ -159,7 +159,8 @@ onMounted(async () => {
<FormGroup>
<label for="">{{ $t('profile.createApiToken.allowedIpRanges') }}</label>
<div class="has-error" v-show="tokenAllowedIpRangesError">{{ tokenAllowedIpRangesError }}</div>
<TextInput v-model="tokenAllowedIpRanges" :placeholder="$t('profile.apiTokens.allowedIpRangesPlaceholder')" />
<TextInput v-model="tokenAllowedIpRanges" />
<small class="helper-text">{{ $t('profile.apiTokens.allowedIpRangesPlaceholder') }}</small>
</FormGroup>
</form>
</div>
@@ -167,7 +168,7 @@ onMounted(async () => {
<p>{{ $t('profile.createApiToken.description') }}</p>
<InputGroup>
<TextInput v-model="addedToken" readonly style="flex-grow: 1;"/>
<Button tool @click="onCopyToClipboard(addedToken)" icon="fa fa-clipboard" />
<ClipboardButton :value="addedToken" />
</InputGroup>
<p>{{ $t('profile.createApiToken.copyNow') }}</p>
</div>
@@ -184,22 +185,20 @@ onMounted(async () => {
<br/>
<TableView :columns="columns" :model="apiTokens" :placeholder="$t('profile.apiTokens.noTokensPlaceholder')">
<template #lastUsedTime="apiToken">
<template #lastUsedTime="{ item:apiToken }">
<span v-if="apiToken.lastUsedTime">{{ prettyLongDate(apiToken.lastUsedTime) }}</span>
<span v-else>{{ $t('profile.apiTokens.neverUsed') }}</span>
</template>
<template #scope="apiToken">
<template #scope="{ item:apiToken }">
<span v-if="apiToken.scope['*'] === 'rw'">{{ $t('profile.apiTokens.readwrite') }}</span>
<span v-else>{{ $t('profile.apiTokens.readonly') }}</span>
</template>
<template #allowedIpRanges="apiToken">
<span v-if="apiToken.allowedIpRanges !== ''" v-tooltip="apiToken.allowedIpRanges">{{ apiToken.allowedIpRanges }}</span>
<template #allowedIpRanges="{ item:apiToken }">
<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>
<template #actions="{ item:apiToken }">
<ActionBar :actions="createActionMenu(apiToken)" />
</template>
</TableView>
</Section>
+149 -66
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 } from '../utils.js';
import { s3like, mountlike, parseFullBackupPath } from '../utils.js';
import BackupProviderForm from './BackupProviderForm.vue';
import AppsModel from '../models/AppsModel.js';
import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_WASABI } from '../constants.js';
@@ -10,102 +10,132 @@ import { REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LIN
const appsModel = AppsModel.create();
const dialog = useTemplateRef('dialog');
const form = useTemplateRef('form');
const backupConfigInput = useTemplateRef('backupConfigInput');
const appId = ref('');
const busy = ref(false);
const formError = ref({});
const providerConfig = ref({});
const provider = ref('');
const remotePath = ref('');
const fullPath = ref('');
const format = ref('');
const encrypted = ref(false);
const encryptionPasswordHint = ref('');
const encryptionPassword = ref('');
const encryptedFilenames = ref(false);
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
}
async function onSubmit() {
if (!form.value.reportValidity()) return;
formError.value = {};
busy.value = true;
let backupPath = remotePath.value;
const backupConfig = {};
const config = {};
const { prefix, remotePath } = parseFullBackupPath(fullPath.value);
// only set provider specific fields, this will clear them in the db
if (s3like(provider.value)) {
backupConfig.bucket = providerConfig.value.bucket;
backupConfig.prefix = providerConfig.value.prefix;
backupConfig.accessKeyId = providerConfig.value.accessKeyId;
backupConfig.secretAccessKey = providerConfig.value.secretAccessKey;
config.bucket = providerConfig.value.bucket;
config.prefix = providerConfig.value.prefix;
config.accessKeyId = providerConfig.value.accessKeyId;
config.secretAccessKey = providerConfig.value.secretAccessKey;
config.prefix = prefix;
if (providerConfig.value.endpoint) backupConfig.endpoint = providerConfig.value.endpoint;
if (providerConfig.value.endpoint) config.endpoint = providerConfig.value.endpoint;
if (provider.value === 's3') {
if (providerConfig.value.region) backupConfig.region = providerConfig.value.region;
delete backupConfig.endpoint;
if (providerConfig.value.region) config.region = providerConfig.value.region;
delete config.endpoint;
} else if (provider.value === 'minio' || provider.value === 's3-v4-compat') {
backupConfig.region = providerConfig.value.region || 'us-east-1';
backupConfig.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
config.region = providerConfig.value.region || 'us-east-1';
config.acceptSelfSignedCerts = !!providerConfig.value.acceptSelfSignedCerts;
config.s3ForcePathStyle = true; // might want to expose this in the UI
} else if (provider.value === 'exoscale-sos') {
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
config.region = 'us-east-1';
config.signatureVersion = 'v4';
} else if (provider.value === 'wasabi') {
backupConfig.region = REGIONS_WASABI.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_WASABI.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'scaleway-objectstorage') {
backupConfig.region = REGIONS_SCALEWAY.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_SCALEWAY.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'linode-objectstorage') {
backupConfig.region = REGIONS_LINODE.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_LINODE.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'ovh-objectstorage') {
backupConfig.region = REGIONS_OVH.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_OVH.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'ionos-objectstorage') {
backupConfig.region = REGIONS_IONOS.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_IONOS.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'vultr-objectstorage') {
backupConfig.region = REGIONS_VULTR.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
config.region = REGIONS_VULTR.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
} else if (provider.value === 'contabo-objectstorage') {
backupConfig.region = REGIONS_CONTABO.find(function (x) { return x.value === backupConfig.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
config.region = REGIONS_CONTABO.find(function (x) { return x.value === config.endpoint; }).region;
config.signatureVersion = 'v4';
config.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
} else if (provider.value === 'upcloud-objectstorage') {
const m = /^.*\.(.*)\.upcloudobjects.com$/.exec(providerConfig.value.endpoint);
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
backupConfig.signatureVersion = 'v4';
config.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
config.signatureVersion = 'v4';
} else if (provider.value === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
config.region = 'us-east-1';
} else if (provider.value === 'hetzner-objectstorage') {
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
config.region = 'us-east-1';
config.signatureVersion = 'v4';
} else if (provider.value === 'synology-c2-objectstorage') {
config.region = 'us-east-1';
config.signatureVersion = 'v4';
}
} else if (mountlike(provider.value)) {
config.prefix = prefix;
config.noHardlinks = !providerConfig.value.useHardlinks;
config.mountOptions = {};
if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs') {
config.mountOptions.host = providerConfig.value.mountOptionHost;
config.mountOptions.remoteDir = providerConfig.value.mountOptionRemoteDir;
if (provider.value === 'cifs') {
config.mountOptions.username = providerConfig.value.mountOptionUsername;
config.mountOptions.password = providerConfig.value.mountOptionPassword;
config.mountOptions.seal = !!providerConfig.value.mountOptionSeal;
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
} else if (provider.value === 'sshfs') {
config.mountOptions.user = providerConfig.value.mountOptionUser;
config.mountOptions.port = parseInt(providerConfig.value.mountOptionPort);
config.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
config.preserveAttributes = true;
}
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
config.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
config.preserveAttributes = true;
} else if (provider.value === 'mountpoint') {
config.mountPoint = providerConfig.value.mountPoint;
config.chown = !!providerConfig.value.chown;
config.preserveAttributes = !!providerConfig.value.preserveAttributes;
}
} else if (provider.value === 'gcs') {
backupConfig.bucket = providerConfig.value.bucket;
backupConfig.prefix = providerConfig.value.prefix;
backupConfig.projectId = providerConfig.value.projectId;
backupConfig.credentials = providerConfig.value.credentials;
} else if (provider.value === 'sshfs' || provider.value === 'cifs' || provider.value === 'nfs' || provider.value === 'ext4' || provider.value === 'xfs') {
backupConfig.mountOptions = providerConfig.value.mountOptions;
backupConfig.prefix = providerConfig.value.prefix;
} else if (provider.value === 'mountpoint') {
backupConfig.prefix = providerConfig.value.prefix;
backupConfig.mountPoint = providerConfig.value.mountPoint;
} else if (provider.value === 'filesystem') {
const parts = remotePath.value.split('/');
backupPath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
backupConfig.backupDir = parts.join('/'); // this is dirname()
config.backupDir = prefix;
} else if (provider.value === 'gcs') {
config.bucket = providerConfig.value.bucket;
config.projectId = providerConfig.value.projectId;
config.credentials = providerConfig.value.credentials;
config.prefix = prefix;
}
const data = {
format: format.value,
provider: provider.value,
config: backupConfig,
remotePath: backupPath
config,
remotePath
};
if (encrypted.value) {
@@ -166,22 +196,65 @@ function onBackupConfigChanged(event) {
let data;
try {
data = JSON.parse(result.target.result); // 'provider', 'config', 'limits', 'format', 'remotePath', 'encrypted', 'encryptedFilenames'
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
data.remotePath = `${data.config.backupDir}/${data.remotePath}`;
}
} catch (e) {
console.error('Unable to parse backup config', e);
return;
}
provider.value = data.provider;
remotePath.value = data.remotePath;
providerConfig.value = data.config;
if (data.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
fullPath.value = data.config.prefix ? `${data.config.backupDir}/${data.config.prefix}/${data.remotePath}` : `${data.config.backupDir}/${data.remotePath}`;
} else if (data.provider === 'mountpoint') {
fullPath.value = data.config.prefix ? `${data.config.mountPoint}/${data.config.prefix}/${data.remotePath}` : `${data.config.mountPoint}/${data.remotePath}`;
} else {
fullPath.value = data.config.prefix ? `${data.config.prefix}/${data.remotePath}` : data.remotePath;
}
format.value = data.format;
encrypted.value = !!data.encrypted;
encryptionPasswordHint.value = data.encryptionPasswordHint || '';
encryptionPassword.value = '';
encryptedFilenames.value = data.encryptedFilenames;
providerConfig.value = {};
for (const [key, value] of Object.entries(data.config)) {
switch (key) {
case 'noHardlinks':
case 'chown':
case 'preserveAttributes':
// not really used for importing
break;
case 'projectId':
case 'credentials':
// gcs fields which should be set by user by uploading json
break;
case 'mountOptions': // providerConfig uses a flattened format of config.mountOptions
providerConfig.value.mountOptionHost = data.config.mountOptions.host;
providerConfig.value.mountOptionPort = data.config.mountOptions.port;
providerConfig.value.mountOptionRemoteDir = data.config.mountOptions.remoteDir;
providerConfig.value.mountOptionSeal = !!data.config.mountOptions.seal;
providerConfig.value.mountOptionDiskPath = data.config.mountOptions.diskPath;
providerConfig.value.mountOptionUser = data.config.mountOptions.user;
providerConfig.value.mountOptionUsername = data.config.mountOptions.username;
providerConfig.value.mountOptionPassword = data.config.mountOptions.password;
providerConfig.value.mountOptionPrivateKey = '';
break;
case 'accessKeyId': // s3
case 'secretAccessKey': // s3
case 'bucket': // s3, gcs
case 'prefix': // s3, gcs
case 'signatureVersion': // s3
case 'endpoint': // s3
case 'region': // s3
case 'acceptSelfSignedCerts': // s3
case 's3ForcePathStyle': // s3
providerConfig.value[key] = value;
break;
default:
console.log('unhandled key when importing config file:', key);
}
}
setTimeout(checkValidity, 100); // update state of the confirm button
};
reader.readAsText(event.target.files[0]);
@@ -191,6 +264,10 @@ function onUploadBackupConfig() {
backupConfigInput.value.click();
}
watchEffect(() => {
if (providerConfig.value.credentials) setTimeout(checkValidity, 100);
});
defineExpose({
async open(id) {
appId.value = id;
@@ -198,13 +275,15 @@ defineExpose({
formError.value = {};
provider.value = '';
providerConfig.value = {};
remotePath.value = '';
fullPath.value = '';
encrypted.value = false;
encryptionPassword.value = '';
encryptedFilenames.value = false;
encryptionPasswordHint.value = '';
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -216,14 +295,18 @@ 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="busy ? '' : $t('main.dialog.cancel')"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
reject-style="secondary"
@confirm="onSubmit()"
>
<div>
<div>{{ $t('app.importBackupDialog.description') }}</div>
<div class="text-danger">{{ $t('app.importBackupDialog.warning') }}</div>
<!-- ideally, we get can get rid of this and just display this error from the imported config -->
<p class="text-danger">{{ $t('app.importBackupDialog.versionMustMatchInfo') }}</p>
<p>{{ $t('app.importBackupDialog.provideBackupInfo') }}
<input type="file" ref="backupConfigFileInput" @change="onBackupConfigChanged" accept="application/json, text/json" style="display:none"/>
@@ -232,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"
@@ -249,7 +332,7 @@ defineExpose({
:form-error="formError"
:import-only="true" />
<Checkbox style="padding-top: 12px" v-model="encrypted" :label="$t('backups.configureBackupStorage.usesEncryption')"/>
<Checkbox v-model="encrypted" :label="$t('backups.configureBackupStorage.usesEncryption')"/>
<FormGroup v-if="encrypted">
<label for="encryptionPassswordInput">{{ $t('backups.configureBackupStorage.encryptionPassword') }}</label>
<PasswordInput id="encryptionPassswordInput" v-model="encryptionPassword" :placeholder="$t('backups.configureBackupStorage.encryptionPasswordPlaceholder')" required/>
+134 -67
View File
@@ -1,54 +1,73 @@
<script setup>
import { ref, computed, useTemplateRef, onMounted, inject } from 'vue';
import { ref, computed, useTemplateRef, onMounted, inject, watch } from 'vue';
import { marked } from 'marked';
import { Button, Dialog, SingleSelect, FormGroup, TextInput, InputGroup } from '@cloudron/pankow';
import { prettyDate, prettyBinarySize, isValidDomain } from '@cloudron/pankow/utils';
import { Button, Checkbox, Dialog, SingleSelect, FormGroup, TextInput, InputGroup, Spinner } from '@cloudron/pankow';
import { prettyDate, prettyBinarySize } from '@cloudron/pankow/utils';
import AccessControl from './AccessControl.vue';
import PortBindings from './PortBindings.vue';
import AppsModel from '../models/AppsModel.js';
import DashboardModel from '../models/DashboardModel.js';
import { PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
import DomainsModel from '../models/DomainsModel.js';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
import { API_ORIGIN, PROXY_APP_ID, ACL_OPTIONS } from '../constants.js';
const STEP = Object.freeze({
LOADING: Symbol('loading'),
DETAILS: Symbol('details'),
INSTALL: Symbol('install'),
});
const appsModel = AppsModel.create();
const dashboardModel = DashboardModel.create();
const domainsModel = DomainsModel.create();
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
const dashboardDomain = inject('dashboardDomain');
// reactive
const busy = ref(false);
const formError = ref({});
const app = ref({});
// community { iconUrl, versionsUrl, manifest, publishState, creationDate, ts }
// appstore { id, iconUrl, appStoreId, manifest, creationDate, publishState }
const packageData = ref({});
const manifest = ref({});
const step = ref(STEP.DETAILS);
const dialog = useTemplateRef('dialogHandle');
const locationInput = useTemplateRef('locationInput');
const description = computed(() => marked.parse(manifest.value.description || ''));
const domains = ref([]);
const dashboardDomain = ref('');
const formValid = computed(() => {
if (!domain.value) return false;
const form = ref(null); // assigned via "Function Ref" because it is inside v-if
const isFormValid = ref(false);
function resetDnsOverwrite() {
needsOverwriteDns.value = [];
overwriteDns.value = false;
formError.value = {};
}
if (location.value && !isValidDomain(location.value + '.' + domain.value)) return false;
async function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) return false;
if (isFormValid.value) {
if (accessRestrictionOption.value === ACL_OPTIONS.RESTRICTED && (accessRestrictionAcl.value.users.length === 0 && accessRestrictionAcl.value.groups.length === 0)) {
isFormValid.value = true;
}
if (manifest.value.id === PROXY_APP_ID) {
try {
new URL(upstreamUri.value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
return false;
if (manifest.value.id === PROXY_APP_ID) {
try {
new URL(upstreamUri.value);
// eslint-disable-next-line no-unused-vars
} catch (e) {
isFormValid.value = false;
}
}
}
return true;
}
watch(form, () => { // trigger form validation when the ref becomes set
setTimeout(checkValidity, 100);
});
const appMaxCountExceeded = ref(false);
@@ -61,7 +80,9 @@ function setStep(newStep) {
}
step.value = newStep;
if (newStep === STEP.INSTALL) setTimeout(() => locationInput.value.$el.focus(), 500);
if (newStep === STEP.INSTALL) {
setTimeout(() => locationInput.value.$el.focus(), 500);
}
}
// form data
@@ -74,22 +95,56 @@ const tcpPorts = ref({});
const udpPorts = ref({});
const secondaryDomains = ref({});
const upstreamUri = ref('');
const overwriteDns = ref(false);
const needsOverwriteDns = ref([]);
const users = ref([]);
const groups = ref([]);
function onDomainChange() {
const tmp = domains.value.find(d => d.domain === domain.value);
domainProvider.value = tmp ? tmp.provider : '';
}
async function submit() {
async function onSubmit() {
if (!form.value.reportValidity()) return;
formError.value = {};
busy.value = true;
const checkForDomains = [{
domain: domain.value,
subdomain: location.value,
}];
for (const d in secondaryDomains.value) checkForDomains.push({ domain: secondaryDomains.value[d].domain, subdomain: secondaryDomains.value[d].value });
const conflicting = [];
for (const d of checkForDomains) {
const [error, result] = await domainsModel.checkRecords(d.domain, d.subdomain);
if (error) {
formError.value.location = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
if (result.needsOverwrite) conflicting.push((d.subdomain ? d.subdomain + '.' : '') + d.domain);
}
if (conflicting.length > 0 && !overwriteDns.value) {
busy.value = false;
needsOverwriteDns.value = conflicting;
formError.value.generic = `DNS records of ${conflicting.join(', ')} already exist`;
return;
}
const config = {
subdomain: location.value,
domain: domain.value,
accessRestriction: accessRestrictionOption.value === ACL_OPTIONS.ANY ? null : (accessRestrictionOption.value === ACL_OPTIONS.NOSSO ? null : accessRestrictionAcl.value)
};
if (overwriteDns.value) config.overwriteDns = true;
if (manifest.value.optionalSso) config.sso = accessRestrictionOption.value !== ACL_OPTIONS.NOSSO;
const finalPorts = {};
@@ -103,8 +158,7 @@ async function submit() {
}
config.ports = finalPorts;
const finalSecondaryDomains = {};
for (var p in secondaryDomains.value) {
const finalSecondaryDomains = {}; for (const p in secondaryDomains.value) {
finalSecondaryDomains[p] = {
subdomain: secondaryDomains.value[p].value,
domain: secondaryDomains.value[p].domain
@@ -114,12 +168,12 @@ async function submit() {
if (manifest.value.id === PROXY_APP_ID) config.upstreamUri = upstreamUri.value;
const [error, result] = await appsModel.install(manifest.value, config);
const [error, result] = await appsModel.install(packageData.value, config);
if (!error) {
dialog.value.close();
localStorage['confirmPostInstall_' + result.id] = true;
return window.location.href = '#/apps';
if (manifest.value.postInstallMessage) localStorage['confirmPostInstall_' + result.id] = true;
return window.location.href = `/#/app/${result.id}/info`;
}
busy.value = false;
@@ -128,9 +182,8 @@ async function submit() {
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);
}
}
@@ -140,10 +193,14 @@ function onClose() {
}
onMounted(async () => {
const [error, result] = await dashboardModel.config();
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => { u.label = u.displayName || u.username || u.email; });
users.value = result;
dashboardDomain.value = result.adminDomain;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
});
const screenshotsContainer = useTemplateRef('screenshotsContainer');
@@ -165,17 +222,21 @@ function onScreenshotNext() {
}
defineExpose({
open: async function(a, appCountExceeded, domainList) {
open: async function(pd, appCountExceeded, domainList) {
busy.value = false;
step.value = STEP.DETAILS;
app.value = a;
step.value = STEP.LOADING;
formError.value = {};
packageData.value = pd;
appMaxCountExceeded.value = appCountExceeded;
manifest.value = a.manifest;
manifest.value = packageData.value.manifest;
location.value = '';
accessRestrictionOption.value = ACL_OPTIONS.ANY;
accessRestrictionAcl.value = { users: [], groups: [] };
domainProvider.value = '';
upstreamUri.value = '';
overwriteDns.value = false;
needsOverwriteDns.value = [];
domainList.forEach(d => {
d.label = '.' + d.domain;
@@ -184,10 +245,10 @@ defineExpose({
domains.value = domainList;
// preselect with dashboard domain
domain.value = (domains.value.find(d => d.domain === dashboardDomain.value) || domains.value[0]).domain;
domain.value = domains.value.find(d => d.domain === dashboardDomain.value).domain;
tcpPorts.value = a.manifest.tcpPorts;
udpPorts.value = a.manifest.udpPorts;
tcpPorts.value = manifest.value.tcpPorts;
udpPorts.value = manifest.value.udpPorts;
// ensure we have value property
for (const p in tcpPorts.value) {
@@ -199,15 +260,15 @@ defineExpose({
udpPorts.value[p].enabled = udpPorts.value[p].enabledByDefault ?? true;
}
secondaryDomains.value = a.manifest.httpPorts;
secondaryDomains.value = manifest.value.httpPorts;
for (const p in secondaryDomains.value) {
const port = secondaryDomains.value[p];
port.value = port.defaultValue;
port.domain = domains.value[0].domain;
port.domain = dashboardDomain.value;
}
currentScreenshotPos = 0;
step.value = STEP.DETAILS;
dialog.value.open();
},
close() {
@@ -218,18 +279,20 @@ defineExpose({
</script>
<template>
<Dialog ref="dialogHandle" @close="onClose()" 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">{{ manifest.title }}</div>
<div>{{ $t('app.updates.info.packageVersion') }} {{ app.manifest.version }}</div>
<div>{{ manifest.title }} Version {{ app.manifest.upstreamVersion }}</div>
<div><a :href="manifest.website" target="_blank">{{ manifest.website }}</a></div>
<div class="summary" v-if="packageData.manifest">
<div class="title"><img class="icon pankow-no-desktop" style="width: 32px; height: 32px; margin-right: 10px" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>{{ manifest.title }}</div>
<div><a :href="manifest.website" target="_blank">{{ manifest.title }}</a> {{ packageData.manifest.upstreamVersion }} - {{ $t('app.updates.info.packageVersion') }} {{ packageData.manifest.version }}</div>
<div v-if="packageData.versionsUrl"><a :href="packageData.manifest.packagerUrl" target="_blank">{{ packageData.manifest.packagerName }}</a></div>
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(packageData.creationDate) }) }}</div>
<div>{{ $t('appstore.installDialog.memoryRequirement', { size: prettyBinarySize(manifest.memoryLimit || (256 * 1024 * 1024)) }) }}</div>
<div>{{ $t('appstore.installDialog.lastUpdated', { date: prettyDate(app.creationDate) }) }}</div>
</div>
<img class="icon" :src="app.iconUrl" />
<img class="icon pankow-no-mobile" :src="packageData.iconUrl" v-fallback-image="API_ORIGIN + '/img/appicon_fallback.png'"/>
</div>
<Transition name="slide-left" mode="out-in">
<div v-if="step === STEP.DETAILS">
@@ -244,42 +307,44 @@ defineExpose({
<div class="description" v-html="description"></div>
</div>
<div v-else-if="step === STEP.INSTALL">
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
<form @submit.prevent="submit()" autocomplete="off">
<form :ref="(el) => { form = el; }" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
<fieldset :disabled="busy">
<input style="display: none;" type="submit" :disabled="!formValid" />
<input style="display: none;" type="submit" :disabled="busy" />
<FormGroup :class="{ 'has-error': formError.location }">
<label for="location">{{ $t('appstore.installDialog.location') }}</label>
<InputGroup>
<TextInput id="location" ref="locationInput" v-model="location" style="flex-grow: 1"/>
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange()" :search-threshold="10"/>
<TextInput id="location" ref="locationInput" v-model="location" @input="resetDnsOverwrite()" style="flex-grow: 1"/>
<SingleSelect v-model="domain" :options="domains" option-label="label" option-key="domain" @select="onDomainChange(); resetDnsOverwrite()" :search-threshold="10" required/>
</InputGroup>
<div class="text-danger" v-if="formError.location">{{ formError.location }}</div>
<div class="warning-label" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></div>
<div class="error-label" v-if="formError.location">{{ formError.location }}</div>
</FormGroup>
<p class="text-small text-warning" v-show="domainProvider === 'noop' || domainProvider === 'manual'" v-html="$t('appstore.installDialog.manualWarning', { location: ((location ? location + '.' : '') + domain) })"></p>
<FormGroup v-for="(port, key) in secondaryDomains" :key="key">
<label :for="'secondaryDomainInput' + key">{{ port.title }}</label>
<small>{{ port.description }}</small>
<InputGroup>
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" />
<TextInput :id="'secondaryDomainInput' + key" v-model="port.value" @input="resetDnsOverwrite()" :placeholder="$t('appstore.installDialog.locationPlaceholder')" style="flex-grow: 1"/>
<SingleSelect v-model="port.domain" :options="domains" option-label="label" option-key="domain" @select="resetDnsOverwrite()" required/>
</InputGroup>
</FormGroup>
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-show="manifest.id === PROXY_APP_ID">
<FormGroup :class="{ 'has-error': formError.upstreamUri }" v-if="manifest.id === PROXY_APP_ID">
<label for="upstreamUri">Upstream URI</label>
<TextInput id="upstreamUri" v-model="upstreamUri" />
<TextInput id="upstreamUri" v-model="upstreamUri" required/>
</FormGroup>
<PortBindings v-model:tcp="tcpPorts" v-model:udp="udpPorts" :error="formError" :domain-provider="domainProvider"/>
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest"/>
<AccessControl v-model:option="accessRestrictionOption" v-model:acl="accessRestrictionAcl" :manifest="manifest" :users="users" :groups="groups" :installation="true"/>
<br/>
<div class="text-danger" v-if="formError.generic">{{ formError.generic }}</div>
<Checkbox v-if="needsOverwriteDns.length" v-model="overwriteDns" style="margin-top: 10px" :label="$t('app.location.overwriteDns', { domains: needsOverwriteDns.join(', ') })"/>
<div class="bottom-button-bar">
<Button @click="submit" icon="fa-solid fa-circle-down" :disabled="!formValid" :loading="busy">Install {{ manifest.title }}</Button>
<Button @click="onSubmit()" icon="fa-solid fa-circle-down" :disabled="busy || !isFormValid || (needsOverwriteDns.length > 0 && !overwriteDns)" :loading="busy">Install {{ manifest.title }}</Button>
</div>
</fieldset>
</form>
@@ -298,7 +363,7 @@ defineExpose({
}
.step-detail {
width: 960px;
width: 800px;
}
.step-install {
@@ -307,7 +372,6 @@ defineExpose({
.app-install-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
}
@@ -316,6 +380,7 @@ defineExpose({
display: flex;
flex-direction: column;
font-size: 14px;
overflow: hidden;
}
.summary > div {
@@ -325,6 +390,8 @@ defineExpose({
.title {
font-size: 32px;
margin-bottom: 10px;
display: flex;
align-items: center;
}
.icon {
+93 -56
View File
@@ -4,10 +4,10 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import moment from 'moment-timezone';
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, Menu, Dialog, SingleSelect, FormGroup, TextInput, TableView, InputDialog, InputGroup } from '@cloudron/pankow';
import { prettyLongDate, copyToClipboard } from '@cloudron/pankow/utils';
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, ClipboardButton, DateTimeInput, 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,27 +29,32 @@ const columns = {
hideMobile: true,
},
creationTime: {
label: t('main.table.date'),
label: t('main.table.created'),
hideMobile: true,
sort(a, b) {
if (!a) return 1;
if (!b) return -1;
return moment(a).isBefore(b) ? 1 : -1;
return new Date(a) - new Date(b);
}
},
expiresAt: {
label: t('profile.appPasswords.expires'),
hideMobile: true,
sort(a, b) {
if (!a) return 1;
if (!b) return -1;
return new Date(a) - new Date(b);
}
},
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
@@ -57,66 +62,85 @@ const addedPassword = ref('');
const passwordName = ref('');
const identifiers = ref([]);
const identifier = ref('');
const expiresAtDate = ref('');
const minExpiresAt = new Date().toISOString().slice(0, 16);
const addError = ref('');
const busy = ref(false);
const appsById = {};
async function refresh() {
const [error, result] = await appPasswordsModel.list();
if (error) return console.error(error);
// setup label for the table UI
result.forEach(function (password) {
if (password.identifier === 'mail') return password.label = password.identifier;
const app = appsById[password.identifier];
if (!app) return password.label = password.identifier + ' (App not found)';
for (const password of result) {
if (password.identifier === 'mail') {
password.label = password.identifier;
} else {
const app = appsById[password.identifier];
if (!app) return password.label = password.identifier + ' (App not found)';
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
const labelSuffix = ftp ? ' - SFTP' : '';
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
});
const ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
const labelSuffix = ftp ? ' - SFTP' : '';
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
}
password.expired = password.expiresAt && new Date(password.expiresAt) < new Date();
}
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 = '';
expiresAtDate.value = '';
addedPassword.value = '';
addError.value = '';
busy.value = false;
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;
busy.value = true;
addError.value = '';
addedPassword.value = '';
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value);
if (error) return console.error(error);
const expiresAt = expiresAtDate.value ? new Date(expiresAtDate.value).toISOString() : null;
const [error, result] = await appPasswordsModel.add(identifier.value, passwordName.value, expiresAt);
if (error) {
busy.value = false;
addError.value = error.body ? error.body.message : 'Internal error';
return;
}
addedPassword.value = result.password;
passwordName.value = '';
identifier.value = '';
expiresAtDate.value = '';
await refresh();
}
function onCopyToClipboard(password) {
copyToClipboard(password);
window.pankow.notify({ type: 'success', text: 'Password copied!' });
busy.value = false;
}
async function onRemove(appPassword) {
const yes = await inputDialog.value.confirm({
message: t('profile.removeAppPassword.title', { name: appPassword.name }),
title: t('profile.removeAppPassword.title'),
message: t('profile.removeAppPassword.description', { name: appPassword.name }),
confirmLabel: t('main.action.remove'),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no')
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -161,39 +185,48 @@ onMounted(async () => {
<template>
<div>
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<Dialog ref="newDialog"
:title="$t('profile.createAppPassword.title')"
:confirm-label="addedPassword ? '' : $t('profile.createAppPassword.generatePassword')"
:confirm-busy="busy"
:confirm-active="addedPassword || (!busy && isFormValid)"
:confirm-label="addedPassword ? '' : $t('main.action.add')"
confirm-style="primary"
:reject-label="$t('main.dialog.close')"
:reject-label="addedPassword ? $t('main.dialog.close') : $t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
@close="onReset()"
>
<div>
<div class="error-label" v-show="addError">{{ addError }}</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"/>
<FormGroup>
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
<TextInput id="passwordName" v-model="passwordName" required/>
</FormGroup>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<fieldset :disabled="busy">
<input style="display: none" type="submit"/>
<FormGroup>
<label for="passwordName">{{ $t('profile.createAppPassword.name') }}</label>
<TextInput id="passwordName" v-model="passwordName" required/>
</FormGroup>
<FormGroup>
<label>{{ $t('profile.createAppPassword.app') }}</label>
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" />
</FormGroup>
<FormGroup>
<label>{{ $t('profile.createAppPassword.app') }}</label>
<SingleSelect outline v-model="identifier" :options="identifiers" option-label="label" option-key="id" required/>
</FormGroup>
<FormGroup>
<label for="expiresAt">{{ $t('profile.createAppPassword.expiresAt') }} (optional)</label>
<DateTimeInput id="expiresAt" v-model="expiresAtDate" :min="minExpiresAt"/>
</FormGroup>
</fieldset>
</form>
</div>
<div v-else>
<p>{{ $t('profile.createAppPassword.description') }}</p>
<InputGroup>
<TextInput v-model="addedPassword" readonly style="flex-grow: 1;"/>
<Button tool @click="onCopyToClipboard(addedPassword)" icon="fa fa-clipboard" />
<ClipboardButton :value="addedPassword" />
</InputGroup>
<p>{{ $t('profile.createAppPassword.copyNow') }}</p>
</div>
@@ -210,11 +243,15 @@ onMounted(async () => {
<br/>
<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>
<template #name="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.name }}</span></template>
<template #label="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ password.label }}</span></template>
<template #creationTime="{ item:password }"><span :class="{ 'text-muted': password.expired }">{{ prettyLongDate(password.creationTime) }}</span></template>
<template #expiresAt="{ item:password }">
<span :class="{ 'text-muted': password.expired }" v-if="!password.expiresAt">-</span>
<span :class="{ 'text-muted': password.expired }" v-else>{{ prettyLongDate(password.expiresAt) }}</span>
</template>
<template #actions="{ item:password }">
<ActionBar :actions="createActionMenu(password)" />
</template>
</TableView>
</Section>
@@ -2,7 +2,7 @@
// for restore from archive or clone !
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef, computed, inject } from 'vue';
import { InputGroup, FormGroup, TextInput, SingleSelect, Dialog } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import PortBindings from '../components/PortBindings.vue';
@@ -14,6 +14,7 @@ const appsModel = AppsModel.create();
const archivesModel = ArchivesModel.create();
const domainsModel = DomainsModel.create();
const dashboardDomain = inject('dashboardDomain');
const appId = ref(null);
const dialog = useTemplateRef('dialog');
const restoreArchive = ref({});
@@ -119,7 +120,7 @@ defineExpose({
const app = archive.appConfig || {
subdomain: '',
domain: domains.value[0].domain,
domain: dashboardDomain.value,
secondaryDomains: [],
portBindings: {}
}; // pre-8.2 backups do not have appConfig
@@ -129,7 +130,7 @@ defineExpose({
restoreLocation.value = app.subdomain;
const d = domains.value.find(function (d) { return app.domain === d.domain; });
restoreDomain.value = d ? d.domain : domains.value[0].domain; // try to pre-select the app's domain
restoreDomain.value = d ? d.domain : dashboardDomain.value; // try to pre-select the app's domain
restoreSecondaryDomains.value = {};
needsOverwrite.value = false;
restoreArchive.value = archive;
@@ -190,9 +191,10 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="appId ? $t('app.cloneDialog.title', { app: fqdn }) : $t('backups.restoreArchiveDialog.title')"
:title="appId ? $t('app.cloneDialog.title') : $t('backups.restoreArchiveDialog.title')"
reject-style="secondary"
:reject-label="busy ? '' : $t('main.dialog.cancel')"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
:confirm-label="!appId ? $t(needsOverwrite ? 'backups.restoreArchiveDialog.restoreActionOverwrite' : 'backups.restoreArchiveDialog.restoreAction') : $t(needsOverwrite ? 'app.restoreDialog.cloneActionOverwrite' : 'app.restoreDialog.cloneAction')"
:confirm-busy="busy"
:confirm-active="!busy"
@@ -206,6 +208,8 @@ defineExpose({
<div class="error-label" v-show="formError.port">{{ formError.port }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off">
<input type="submit" style="display: none;"/>
<fieldset>
<FormGroup>
<label for="locationInput">{{ $t('app.cloneDialog.location') }}</label>
+39 -30
View File
@@ -4,7 +4,7 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { computed, ref, useTemplateRef } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { Dialog, FormGroup, InputDialog, MultiSelect, Radiobutton, TagInput, TextInput } from '@cloudron/pankow';
import { API_ORIGIN } from '../constants.js';
import { getDataURLFromFile } from '../utils.js';
@@ -38,39 +38,43 @@ 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 = {
label: label.value,
upstreamUri: upstreamUri.value,
tags: tags.value,
};
if (label.value) data.label = label.value;
data.accessRestriction = null;
if (accessRestrictionOption.value === 'groups') {
data.accessRestriction = { users: [], groups: [] };
data.accessRestriction.users = accessRestriction.value.users.map(function (u) { return u.id; });
data.accessRestriction.groups = accessRestriction.value.groups.map(function (g) { return g.id; });
data.accessRestriction.users = accessRestriction.value.users;
data.accessRestriction.groups = accessRestriction.value.groups;
}
if (iconFile === 'fallback') { // user reset the icon
@@ -98,8 +102,9 @@ async function onRemove() {
const yes = await inputDialog.value.confirm({
message: `Really remove applink?`,
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.cancel')
confirmLabel: t('main.action.remove'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -126,6 +131,7 @@ defineExpose({
// fetch users and groups
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => { u.label = u.username || u.email; });
users.value = result;
[error, result] = await groupsModel.list();
@@ -133,6 +139,8 @@ defineExpose({
groups.value = result;
applinkDialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -145,17 +153,17 @@ defineExpose({
alternate-style="danger"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-active="isValid"
:confirm-label="mode === 'edit' ? $t('main.dialog.save') : $t('main.action.add')"
:confirm-active="!busy && isFormValid"
:confirm-busy="busy"
@confirm="onSubmit()"
@alternate="onRemove()"
>
<InputDialog ref="inputDialog" />
<form @submit.prevent="onSubmit()" autocomplete="off">
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<fieldset :disabled="busy">
<input style="display: none;" type="submit" :disabled="!isValid" />
<input style="display: none;" type="submit" />
<p class="has-error" v-show="error.generic">{{ error.generic }}</p>
@@ -171,17 +179,18 @@ defineExpose({
</FormGroup>
<div>
<label for="previewIcon">{{ $t('app.display.icon') }}</label>
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" size="512" display-height="80px" style="width: 80px"/>
<label>{{ $t('app.display.icon') }}</label>
<ImagePicker ref="imagePicker" mode="simple" :src="iconUrl" :fallback-src="`${API_ORIGIN}/img/appicon_fallback.png`" @changed="onIconChanged" :size="512" display-height="80px" style="width: 80px"/>
</div>
<FormGroup>
<label for="applinkTags">{{ $t('app.display.tags') }}</label>
<TagInput id="applinkTags" :placeholder="$t('app.display.tagsPlaceholder')" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
<TagInput id="applinkTags" v-model="tags" v-tooltip="$t('app.display.tagsTooltip')" />
<small class="helper-text">{{ $t('app.display.tagsPlaceholder') }}</small>
</FormGroup>
<FormGroup>
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label>{{ $t('app.accessControl.userManagement.dashboardVisibility') }} <sup><a href="https://docs.cloudron.io/apps/#no-sso" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<Radiobutton v-model="accessRestrictionOption" value="any" :label="$t('app.accessControl.userManagement.visibleForAllUsers')"/>
<Radiobutton v-model="accessRestrictionOption" value="groups" :label="$t('app.accessControl.userManagement.visibleForSelected')"/>
<!-- <span class="label label-danger"v-show="accessRestrictionOption === 'groups' && !isAccessRestrictionValid(applinkDialogData)">{{ $t('appstore.installDialog.errorUserManagementSelectAtLeastOne') }}</span> -->
@@ -190,10 +199,10 @@ defineExpose({
<div v-if="accessRestrictionOption === 'groups'">
<div style="margin-left: 20px; display: flex;">
<div>
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-label="username" :search-threshold="20" />
{{ $t('appstore.installDialog.users') }}: <MultiSelect v-model="accessRestriction.users" :options="users" option-key="id" option-label="label" :search-threshold="20" />
</div>
<div>
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-label="name" :search-threshold="20" />
{{ $t('appstore.installDialog.groups') }}: <MultiSelect v-model="accessRestriction.groups" :options="groups" option-key="id" option-label="name" :search-threshold="20" />
</div>
</div>
</div>
@@ -0,0 +1,203 @@
<script setup>
import { ref, computed, useTemplateRef } from 'vue';
import { ClipboardAction, TableView, Dialog } from '@cloudron/pankow';
import { prettyDuration, prettyLongDate, prettyFileSize } from '@cloudron/pankow/utils';
import AppsModel from '../models/AppsModel.js';
import BackupsModel from '../models/BackupsModel.js';
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
const appsModel = AppsModel.create();
const backupsModel = BackupsModel.create();
const busy = ref(true);
const backupContentTableColumns = computed(() => {
const columns = {
label: {
label: t('backups.listing.contents'),
sort: true,
},
fileCount: {
label: t('backup.target.fileCount'),
sort(a, b, A, B) {
return A.stats?.upload?.fileCount - B.stats?.upload?.fileCount;
},
align: 'right',
},
size: {
label: t('backup.target.size'),
sort(a, b, A , B) {
return A.stats?.upload?.size - B.stats?.upload?.size;
},
align: 'right',
},
};
if (backup.value.lastIntegrityCheckTime || backup.value.integrityCheckTask) {
columns.integrity = {
label: 'Integrity',
sort: false,
width: '100px',
align: 'center',
};
}
return columns;
});
const backup = ref({ contents: [], validStats: false });
const dialog = useTemplateRef('dialog');
defineExpose({
async open(b) {
backup.value = JSON.parse(JSON.stringify(b)); // make a copy
backup.value.contents = [];
backup.value.validStats = false; // old cloudron version had invalid stats
busy.value = true;
dialog.value.open();
if (backup.value.type === 'app') {
backup.value.validStats = backup.value.stats?.upload && backup.value.stats?.copy;
busy.value = false;
return;
}
// amend detailed app info
const appsById = {};
const [appsError, apps] = await appsModel.list();
if (appsError) console.error('Failed to get apps list:', appsError);
(apps || []).forEach(function (app) {
appsById[app.id] = app;
});
for (const contentId of backup.value.dependsOn) {
const match = contentId.match(/(mail|app)_(.*?)_.*/); // *? means non-greedy
if (!match) continue;
const [error, result] = await backupsModel.get(contentId);
if (error) console.error(error);
const content = { id: null, label: null, fqdn: null, stats: null, integrityCheckStatus: null, lastIntegrityCheckTime: null, integrityCheckTask: null };
content.stats = result.stats;
content.integrityCheckStatus = result.integrityCheckStatus;
content.lastIntegrityCheckTime = result.lastIntegrityCheckTime;
content.integrityCheckTask = result.integrityCheckTask;
if (match[1] === 'mail') {
content.id = 'mail';
content.label = 'Mail Server';
} else {
const app = appsById[match[2]];
if (app) {
content.id = app.id;
content.label = app.label;
content.fqdn = app.fqdn;
} else { // uninstalled app
content.id = match[2];
}
}
backup.value.contents.push(content);
}
backup.value.validStats = backup.value.stats?.aggregatedUpload && backup.value.stats?.aggregatedCopy;
busy.value = false;
}
});
</script>
<template>
<Dialog ref="dialog"
:title="$t('backups.backupDetails.title')"
:reject-label="$t('main.dialog.close')"
>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupDetails.id') }}</div>
<div class="info-value">{{ backup.id }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupEdit.label') }}</div>
<div class="info-value">{{ backup.label || 'Not set'}}</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupEdit.remotePath') }}</div>
<div class="info-value">
<div>
{{ backup.remotePath }}
<ClipboardAction plain :value="backup.remotePath"/>
</div>
</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupDetails.date') }}</div>
<div class="info-value">{{ prettyLongDate(backup.creationTime) }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupDetails.version') }}</div>
<div class="info-value">{{ backup.packageVersion }}</div>
</div>
<div class="info-row" v-if="backup.validStats">
<div class="info-label">{{ $t('backups.backupDetails.size') }}</div>
<div v-if="backup.type === 'box'" class="info-value">{{ prettyFileSize(backup.stats.aggregatedUpload.size) }} | {{ backup.stats.aggregatedUpload.fileCount }} file(s) | {{ backup.appCount }} app(s) </div>
<div v-else class="info-value">{{ prettyFileSize(backup.stats.upload.size) }} | {{ backup.stats.upload.fileCount }} file(s)</div>
</div>
<div class="info-row" v-if="backup.validStats">
<div class="info-label">{{ $t('backups.backupDetails.duration') }}</div>
<div v-if="backup.type === 'box'" class="info-value">{{ prettyDuration(backup.stats.aggregatedUpload.duration + backup.stats.aggregatedCopy.duration) }}</div>
<div v-else class="info-value">{{ prettyDuration(backup.stats.upload.duration + backup.stats.copy.duration) }}</div>
</div>
<div class="info-row">
<div class="info-label">{{ $t('backups.backupDetails.lastIntegrityCheck') }}</div>
<div class="info-value">
<a v-if="backup.integrityCheckTask?.active" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">{{ $t('backups.backupDetails.integrityInProgress') }}</a>
<a v-else-if="backup.lastIntegrityCheckTime && backup.integrityCheckTask" :href="`/logs.html?taskId=${backup.integrityCheckTask.id}`" target="_blank">
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
</a>
<span v-else-if="backup.lastIntegrityCheckTime">
<i class="fa-solid" :class="{ 'fa-check-circle text-success': backup.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': backup.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': backup.integrityCheckStatus === 'skipped' }"></i>
{{ prettyLongDate(backup.lastIntegrityCheckTime) }}
</span>
<span v-else>{{ $t('backups.backupDetails.integrityNever') }}</span>
</div>
</div>
<div v-if="(backup.integrityCheckStatus === 'failed' || backup.integrityCheckStatus === 'skipped') && backup.integrityCheckResult?.messages?.length">
<div class="info-label" style="margin-bottom: 5px;">Integrity Issues</div>
<textarea readonly rows="10" style="width: 100%; resize: vertical;" :value="backup.integrityCheckResult.messages.join('\n')"></textarea>
</div>
<hr style="margin: 15px 0" v-if="backup.type === 'box'"/>
<div v-if="backup.type === 'box'">
<TableView :columns="backupContentTableColumns" :model="backup.contents" :busy="busy">
<template #label="{ item:content }">
<a v-if="content.id === 'mail'" href="/#/mailboxes">{{ content.label }}</a>
<a v-else-if="content.fqdn" :href="`/#/app/${content.id}/backups`">{{ content.label || content.fqdn }}</a>
<a v-else :href="`/#/system-eventlog?search=${content.id}`">{{ content.id }}</a>
</template>
<template #fileCount="{ item:content }">
<div v-if="content.stats?.upload" style="text-align: right">{{ content.stats.upload.fileCount }}</div>
<div v-else style="text-align: right">-</div>
</template>
<!-- <td>{{ prettyDuration(content.stats.upload.duration | content.stats.copy.duration) }}</td> -->
<template #size="{ item:content }">
<div v-if="content.stats?.upload" style="text-align: right">{{ prettyFileSize(content.stats.upload.size) }}</div>
<div v-else style="text-align: right">-</div>
</template>
<template #integrity="{ item:content }">
<a v-if="content.lastIntegrityCheckTime && content.integrityCheckTask" :href="`/logs.html?taskId=${content.integrityCheckTask.id}`" target="_blank" style="display: flex; align-items: center; justify-content: center;">
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
</a>
<div v-else-if="content.lastIntegrityCheckTime" style="display: flex; align-items: center; justify-content: center;">
<i class="fa-solid" :class="{ 'fa-check-circle text-success': content.integrityCheckStatus === 'passed', 'fa-times-circle text-danger': content.integrityCheckStatus === 'failed', 'fa-circle-minus text-warning': content.integrityCheckStatus === 'skipped' }" v-tooltip="prettyLongDate(content.lastIntegrityCheckTime)"></i>
</div>
<div v-else style="text-align: center;">-</div>
</template>
</TableView>
</div>
</Dialog>
</template>
+44 -30
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, onMounted, watch } from 'vue';
import { ref, onMounted, watch, watchEffect } from 'vue';
import { Button, InputGroup, SingleSelect, FormGroup, TextInput, Checkbox, PasswordInput, NumberInput } from '@cloudron/pankow';
import { BACKUP_FORMATS, STORAGE_PROVIDERS, REGIONS_CONTABO, REGIONS_VULTR, REGIONS_IONOS, REGIONS_OVH, REGIONS_LINODE, REGIONS_SCALEWAY, REGIONS_EXOSCALE, REGIONS_DIGITALOCEAN, REGIONS_HETZNER, REGIONS_WASABI, REGIONS_S3 } from '../constants.js';
import ProvisionModel from '../models/ProvisionModel.js';
@@ -92,6 +92,10 @@ watch(provider, (newProvider) => {
if (parseInt(providerConfig.value.downloadConcurrency) < 30) providerConfig.value.downloadConcurrency = 30;
if (parseInt(providerConfig.value.syncConcurrency) < 20) providerConfig.value.syncConcurrency = 20;
if (parseInt(providerConfig.value.copyConcurrency) < 500) providerConfig.value.downloadConcurrency = 500;
} else if (newProvider === 'cifs') {
providerConfig.value.mountOptionSeal = true;
} else if (newProvider === 'sshfs') {
providerConfig.value.mountOptionPort = 23;
} else if (newProvider === 'gcs') {
providerConfig.value.credentials = {
client_email: '',
@@ -100,6 +104,17 @@ watch(provider, (newProvider) => {
}
});
watch(format, (newFormat) => {
if (newFormat === 'rsync') {
if (provider.value === 'filesystem' || mountlike(provider.value)) providerConfig.value.useHardlinks = true;
}
});
watchEffect(() => {
if (!providerConfig.value.mountOptionPrivateKey) return;
providerConfig.value.mountOptionPrivateKey = providerConfig.value.mountOptionPrivateKey.replaceAll('\\n', '\n');
});
onMounted(async () => {
await getBlockDevices();
});
@@ -108,44 +123,45 @@ onMounted(async () => {
<template>
<div>
<div class="error-label" v-show="formError.generic">{{ formError.generic }}</div>
<!-- when provisioning, the error has to be shown on top of the backup path in the view itself -->
<div class="error-label" v-if="!provisioning && formError.generic">{{ formError.generic }}</div>
<FormGroup>
<label for="providerInput">{{ $t('backups.configureBackupStorage.provider') }} <sup><a href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" />
<SingleSelect id="providerInput" v-model="provider" :options="storageProviders" option-key="value" option-label="name" required />
</FormGroup>
<!-- mountpoint -->
<FormGroup v-if="provider === 'mountpoint'">
<label for="mountPointInput">{{ $t('backups.configureBackupStorage.mountPoint') }}</label>
<TextInput id="mountPointInput" v-model="providerConfig.mountPoint" placeholder="/mnt/backups" required/>
<div v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></div>
<small class="helper-text" v-html="$t('backups.configureBackupStorage.mountPointDescription', { providerDocsLink: `https://docs.cloudron.io/backups/#${provider}` })"></small>
</FormGroup>
<!-- CIFS/NFS/SSHFS -->
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }} ({{ provider }})</label>
<label for="mountOptionHostInput">{{ $t('backups.configureBackupStorage.server') }}</label>
<TextInput id="mountOptionHostInput" v-model="providerConfig.mountOptionHost" placeholder="Server IP or hostname" required />
</FormGroup>
<!-- CIFS/NFS/SSHFS -->
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }}</label>
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
</FormGroup>
<!-- CIFS -->
<Checkbox v-if="provider === 'cifs'" v-model="providerConfig.mountOptionSeal" :label="$t('backups.configureBackupStorage.cifsSealSupport')" />
<!-- CIFS/NFS/SSHFS -->
<FormGroup v-if="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label for="mountOptionRemoteDirInput">{{ $t('backups.configureBackupStorage.remoteDirectory') }} ({{ provider }})</label>
<TextInput id="mountOptionRemoteDirInput" v-model="providerConfig.mountOptionRemoteDir" placeholder="/share" required />
</FormGroup>
<!-- CIFS -->
<FormGroup v-if="provider === 'cifs'">
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }} ({{ provider }})</label>
<label for="mountOptionUsernameInput">{{ $t('backups.configureBackupStorage.username') }}</label>
<TextInput id="mountOptionUsernameInput" v-model="providerConfig.mountOptionUsername" required />
</FormGroup>
<!-- CIFS -->
<FormGroup v-if="provider === 'cifs'">
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }} ({{ provider }})</label>
<label for="mountOptionPasswordInput">{{ $t('backups.configureBackupStorage.password') }}</label>
<PasswordInput id="mountOptionPasswordInput" v-model="providerConfig.mountOptionPassword" required />
</FormGroup>
@@ -156,12 +172,6 @@ onMounted(async () => {
<SingleSelect id="blockDevicePath" v-if="provider === 'xfs'" v-model="providerConfig.mountOptionDiskPath" :options="xfsBlockDevices" option-label="label" option-key="path"/>
</FormGroup>
<!-- Disk -->
<FormGroup v-if="provider === 'disk'">
<label class="control-label">{{ $t('backups.configureBackupStorage.diskPath') }}</label>
<TextInput id="mountOptionDiskPathInput" v-model="providerConfig.mountOptionDiskPath" placeholder="/dev/disk/by-uuid/uuid" required />
</FormGroup>
<!-- SSHFS -->
<FormGroup v-if="provider === 'sshfs'">
<label for="mountOptionPortInput">{{ $t('backups.configureBackupStorage.port') }}</label>
@@ -177,19 +187,19 @@ onMounted(async () => {
<!-- SSHFS -->
<FormGroup v-if="provider === 'sshfs'">
<label for="mountOptionPrivateKeyInput">{{ $t('backups.configureBackupStorage.privateKey') }}</label>
<textarea id="mountOptionPrivateKeyInput" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
<textarea id="mountOptionPrivateKeyInput" rows="7" style="white-space: nowrap;" v-model="providerConfig.mountOptionPrivateKey" required></textarea>
</FormGroup>
<!-- Filesystem -->
<FormGroup v-if="provider === 'filesystem' && !importOnly">
<label for="backupDirInput">{{ $t('backups.configureBackupStorage.localDirectory') }}</label>
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="Directory for backups" required />
<TextInput id="backupDirInput" v-model="providerConfig.backupDir" placeholder="/opt/backups" required />
</FormGroup>
<!-- S3/Minio/SOS/GCS/UpCloud/B2/R2 -->
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
<!-- Endpoint - S3/Minio/SOS/GCS/UpCloud/B2/R2/C2 -->
<FormGroup v-if="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2' || provider === 'synology-c2-objectstorage'">
<label for="endpointInput">{{ $t('backups.configureBackupStorage.s3Endpoint') }}</label>
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="URL" required />
<TextInput id="endpointInput" v-model="providerConfig.endpoint" placeholder="https://s3endpoint.example.com" required />
</FormGroup>
<Checkbox v-if="provider === 'minio' || provider === 's3-v4-compat'" v-model="providerConfig.acceptSelfSignedCerts" :label="$t('backups.configureBackupStorage.acceptSelfSignedCerts')"/>
@@ -199,12 +209,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' ||
@@ -235,7 +247,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)">
@@ -244,15 +257,16 @@ onMounted(async () => {
</FormGroup>
<FormGroup v-if="s3like(provider)">
<label for="accessKeyInput">{{ $t('backups.configureBackupStorage.s3SecretAccessKey') }}</label>
<TextInput id="accessKeyInput" v-model="providerConfig.secretAccessKey" required />
<label for="secretAccessKeyInput">{{ $t('backups.configureBackupStorage.s3SecretAccessKey') }}</label>
<TextInput id="secretAccessKeyInput" v-model="providerConfig.secretAccessKey" required />
</FormGroup>
<FormGroup v-if="provider === 'gcs'">
<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>
@@ -1,131 +0,0 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { Checkbox, Dialog, FormGroup, MultiSelect } from '@cloudron/pankow';
import BackupSitesModel from '../models/BackupSitesModel.js';
const emit = defineEmits([ 'success' ]);
const backupSitesModel = BackupSitesModel.create();
const id = ref('');
const busy = ref(false);
const formError = ref('');
const dialog = useTemplateRef('dialog');
const scheduleEnabled = ref(false);
const days = ref([]);
const hours = ref([]);
const configureRetention = ref('');
const isConfigureValid = computed(() => {
return !!days.value.length && !!hours.value.length;
});
async function onSubmit() {
if (!isConfigureValid.value) return;
busy.value = true;
let schedule;
if (scheduleEnabled.value) {
let daysPattern;
if (days.value.length === 7) daysPattern = '*';
else daysPattern = days.value;
let hoursPattern;
if (hours.value.length === 24) hoursPattern = '*';
else hoursPattern = hours.value;
schedule = `00 00 ${hoursPattern} * * ${daysPattern}`;
} else {
schedule = 'never';
}
let [error] = await backupSitesModel.setSchedule(id.value, schedule);
if (error) {
busy.value = false;
formError.value = error.body ? error.body.message : 'Internal error';
return console.error(error);
}
[error] = await backupSitesModel.setRetention(id.value, configureRetention.value);
if (error) {
busy.value = false;
formError.value = error.body ? error.body.message : 'Internal error';
return console.error(error);
}
emit('success');
dialog.value.close();
busy.value = false;
}
defineExpose({
async open(site) {
id.value = site.id;
busy.value = false;
formError.value = false;
const currentRetentionString = JSON.stringify(site.retention);
let selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return JSON.stringify(x.id) === currentRetentionString; });
if (!selectedRetention) selectedRetention = BackupSitesModel.backupRetentions[0];
configureRetention.value = selectedRetention.id;
if (site.schedule === 'never') {
scheduleEnabled.value = false;
} else {
scheduleEnabled.value = true;
const tmp = site.schedule.split(' ');
const tmpHours = tmp[2].split(',');
const tmpDays = tmp[5].split(',');
if (tmpDays[0] === '*') days.value = BackupSitesModel.cronDays.map((day) => { return day.id; });
else days.value = tmpDays.map((day) => { return parseInt(day, 10); });
if (tmpHours[0] === '*') hours.value = BackupSitesModel.cronHours.map(h => h.id);
else hours.value = tmpHours.map((hour) => { return parseInt(hour, 10); });
}
dialog.value.open();
}
});
</script>
<template>
<Dialog ref="dialog"
:title="$t('backups.configureBackupSchedule.title')"
reject-style="secondary"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="busy"
:confirm-active="isConfigureValid"
@confirm="onSubmit()"
>
<div class="error-label" v-show="formError">{{ formError }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset>
<FormGroup>
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule') }}</label>
<div v-html="$t('backups.configureBackupSchedule.scheduleDescription')" style="margin-bottom: 10px; "></div>
<Checkbox :label="$t('main.statusEnabled')" v-model="scheduleEnabled" />
<div v-if="scheduleEnabled" style="display: flex; gap: 10px; margin-left: 10px">
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" :disabled="!scheduleEnabled" v-model="days" :options="BackupSitesModel.cronDays" option-key="id" option-label="name"></MultiSelect></div>
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect :disabled="!scheduleEnabled" v-model="hours" :options="BackupSitesModel.cronHours" option-key="id" option-label="name"></MultiSelect></div>
</div>
</FormGroup>
<FormGroup>
<label for="retentionInput">{{ $t('backups.configureBackupSchedule.retentionPolicy') }}</label>
<select id="retentionInput" v-model="configureRetention">
<option v-for="elem in BackupSitesModel.backupRetentions" :key="elem.id" :value="elem.id">{{ elem.name }}</option>
</select>
</FormGroup>
</fieldset>
</form>
</Dialog>
</template>
@@ -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('');
@@ -29,7 +28,7 @@ const formError = ref({});
const busy = ref(false);
const enableForUpdates = ref(false);
const provider = ref('');
const includeExclude = ref('everything'); // or exclude, include
const includeExclude = ref(''); // or exclude, include
const contentOptions = ref([]);
const contentInclude = ref([]);
const contentExclude = ref([]);
@@ -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;
@@ -122,7 +124,7 @@ async function onSubmit() {
data.mountOptions.privateKey = providerConfig.value.mountOptionPrivateKey;
data.preserveAttributes = true;
}
} else if (provider.value === 'ext4' || provider.value === 'xfs' || provider.value === 'disk') {
} else if (provider.value === 'ext4' || provider.value === 'xfs') {
data.mountOptions.diskPath = providerConfig.value.mountOptionDiskPath;
data.preserveAttributes = true;
} else if (provider.value === 'mountpoint') {
@@ -227,6 +229,12 @@ function onCancel() {
dialog.value.close();
}
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
}
defineExpose({
async open() {
step.value = 'storage';
@@ -237,14 +245,17 @@ defineExpose({
enableForUpdates.value = false;
provider.value = '';
format.value = '';
providerConfig.value = {};
providerConfig.value = {
mountOptions: {},
prefix: '',
};
useEncryption.value = false;
encryptionPassword.value = '';
encryptionPasswordRepeat.value = '';
encryptionPasswordHint.value = '';
encryptedFilenames.value = false;
limits.value = {};
includeExclude.value = 'everything';
includeExclude.value = '';
contentInclude.value = [];
contentExclude.value = [];
@@ -279,6 +290,8 @@ defineExpose({
});
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -288,7 +301,7 @@ defineExpose({
<Dialog ref="dialog" :title="$t('backups.site.addDialog.title')">
<div>
<div v-if="step === 'storage'">
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<fieldset :disabled="busy">
<input style="display: none;" type="submit"/>
@@ -301,19 +314,19 @@ defineExpose({
<FormGroup>
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</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')"/>
<div description>{{ $t('backups.configureBackupStorage.backupContents.description') }}</div>
<div>
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="everything" :label="$t('backups.configureBackupStorage.backupContents.everything')" required/>
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="exclude" :label="$t('backups.configureBackupStorage.backupContents.excludeSelected')" required/>
<MultiSelect v-model="contentExclude" v-if="includeExclude === 'exclude'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
<Radiobutton v-model="includeExclude" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')"/>
<Radiobutton v-model="includeExclude" name="contentRadioGroup" value="include" :label="$t('backups.configureBackupStorage.backupContents.includeOnlySelected')" required/>
<MultiSelect v-model="contentInclude" v-if="includeExclude === 'include'" :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
</div>
</FormGroup>
<FormGroup>
<label>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}</label>
<div>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
<div description>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
<Checkbox v-model="enableForUpdates" :label="$t('backups.configureBackupStorage.useForUpdates')" />
</FormGroup>
@@ -363,11 +376,11 @@ defineExpose({
-->
<div style="display: flex; justify-content: space-between; align-items: end; margin-top: 10px">
<Checkbox v-model="useEncryption" label="Encrypt Backups"/>
<Checkbox v-model="useEncryption" :label="$t('backups.configureBackupStorage.useEncryption')"/>
<div style="display: flex; gap: 6px; align-items: end;">
<Button secondary v-if="!busy" :disabled="busy" @click="onCancel()">{{ $t('main.dialog.cancel') }}</Button>
<Button primary :disabled="busy" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
<Button primary :disabled="busy || !isFormValid" :loading="busy" @click="onSubmit()">{{ useEncryption ? $t('main.action.next') : $t('main.dialog.save') }}</Button>
</div>
</div>
</fieldset>
@@ -379,11 +392,11 @@ defineExpose({
<fieldset :disabled="busy">
<input style="display: none;" type="submit"/>
<div class="warning-label">{{ $t('backups.configureBackupStorage.encryptionDescription') }}</div>
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<FormGroup>
<label for="encryptionPassswordInput">{{ $t('backups.configureBackupStorage.encryptionPassword') }} <sup><a href="https://docs.cloudron.io/backups/#encryption" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<PasswordInput id="encryptionPassswordInput" v-model="encryptionPassword" :placeholder="$t('backups.configureBackupStorage.encryptionPasswordPlaceholder')" required />
<div class="warning-label">{{ $t('backups.configureBackupStorage.encryptionDescription') }}</div>
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
</FormGroup>
<FormGroup>
@@ -0,0 +1,289 @@
<script setup>
import { ref, useTemplateRef, watch } from 'vue';
import { MaskedInput, Dialog, FormGroup, TextInput, Checkbox } from '@cloudron/pankow';
import { prettyBinarySize } from '@cloudron/pankow/utils';
import { s3like, mountlike } from '../utils.js';
import BackupSitesModel from '../models/BackupSitesModel.js';
import SystemModel from '../models/SystemModel.js';
const emit = defineEmits([ 'success' ]);
const backupSitesModel = BackupSitesModel.create();
const systemModel = SystemModel.create();
const minMemoryLimit = ref(1024 * 1024 * 1024); // 1 GB
const maxMemoryLimit = ref(minMemoryLimit.value); // set later
const dialog = useTemplateRef('dialog');
const site = ref({});
const provider = ref('');
const formError = ref({});
const busy = ref(false);
const name = ref('');
const enableForUpdates = ref(false);
const memoryLimit = ref(0);
const uploadPartSize = ref(0);
const syncConcurrency = ref(0);
const downloadConcurrency = ref(0);
const copyConcurrency = ref(0);
const accessKeyId = ref('');
const secretAccessKey = ref('');
const mountOptionsUsername = ref('');
const mountOptionsPassword = ref('');
const mountOptionsUser = ref('');
const mountOptionsPrivateKey = ref('');
const useHardlinks = ref(false);
const chown = ref(false);
const preserveAttributes = ref(false);
watch(mountOptionsPrivateKey, () => {
if (!mountOptionsPrivateKey.value) return;
mountOptionsPrivateKey.value = mountOptionsPrivateKey.value.replaceAll('\\n', '\n');
});
async function onSubmit() {
busy.value = true;
if (site.value.name !== name.value) {
const [error] = await backupSitesModel.setName(site.value.id, name.value);
if (error) {
if (error.status === 400) formError.value.name = error.body.message;
else formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
}
const data = site.value.config;
// TODO maybe deal with gcs??
if (s3like(provider.value)) {
data.accessKeyId = accessKeyId.value;
if (secretAccessKey.value) data.secretAccessKey = secretAccessKey.value;
}
if (provider.value === 'cifs') {
data.mountOptions.username = mountOptionsUsername.value;
if (mountOptionsPassword.value) data.mountOptions.password = mountOptionsPassword.value;
}
if (provider.value === 'sshfs') {
data.mountOptions.user = mountOptionsUser.value;
if (mountOptionsPrivateKey.value) data.mountOptions.privateKey = mountOptionsPrivateKey.value;
}
if ((provider.value === 'filesystem' || mountlike(provider.value)) && site.value.format === 'rsync') {
data.noHardlinks = !useHardlinks.value;
}
if ((provider.value === 'mountpoint' || provider.value === 'cifs') && site.value.format === 'rsync') {
data.preserveAttributes = preserveAttributes.value;
}
if (provider.value === 'mountpoint' && site.value.format === 'rsync') {
data.chown = chown.value;
}
// only call if anything has changed
if (Object.keys(data).length) {
const [error] = await backupSitesModel.setConfig(site.value.id, data);
if (error) {
if (error.status === 400) {
// if (error.body.message.indexOf('password') === 0) formError.value.generic = 'Username or password is wrong';
formError.value.generic = error.body ? error.body.message : 'Internal error';
} else {
formError.value.generic = error.body ? error.body.message : 'Internal error';
console.error(error);
}
busy.value = false;
return;
}
}
const limits = {
memoryLimit: parseInt(memoryLimit.value),
uploadPartSize: parseInt(uploadPartSize.value),
syncConcurrency: parseInt(syncConcurrency.value),
downloadConcurrency: parseInt(downloadConcurrency.value),
copyConcurrency: parseInt(copyConcurrency.value),
};
const [error] = await backupSitesModel.setLimits(site.value.id, limits);
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
emit('success');
dialog.value.close();
busy.value = false;
}
async function getMemory() {
const [error, result] = await systemModel.memory();
if (error) return console.error(error);
maxMemoryLimit.value = Math.ceil(result.memory / (1024*1024*1024)) * 1024 * 1024 * 1024;
}
defineExpose({
async open(t) {
t = JSON.parse(JSON.stringify(t)); // make a copy
formError.value = {};
busy.value = false;
site.value = t;
provider.value = t.provider;
name.value = t.name || '';
enableForUpdates.value = !!t.enableForUpdates;
memoryLimit.value = t.limits.memoryLimit || 1024 * 1024 * 1024; // 1 GB
uploadPartSize.value = t.limits.uploadPartSize || 10 * 1024 * 1024;
syncConcurrency.value = t.limits.syncConcurrency || 10;
downloadConcurrency.value = t.limits.downloadConcurrency || 10;
copyConcurrency.value = t.limits.copyConcurrency || 10;
if (s3like(provider.value)) {
accessKeyId.value = t.config.accessKeyId;
secretAccessKey.value = null;
} else if (provider.value === 'cifs') {
mountOptionsUsername.value = t.config.mountOptions.username;
mountOptionsPassword.value = null;
} else if (provider.value === 'sshfs') {
mountOptionsUser.value = t.config.mountOptions.user;
mountOptionsPrivateKey.value = null;
}
if ((provider.value === 'filesystem' || mountlike(provider.value)) && site.value.format === 'rsync') {
useHardlinks.value = !t.config.noHardlinks;
}
if ((provider.value === 'mountpoint' || provider.value === 'cifs') && site.value.format === 'rsync') {
preserveAttributes.value = !!t.config.preserveAttributes;
}
if (provider.value === 'mountpoint' && site.value.format === 'rsync') {
chown.value = !!t.config.chown;
}
await getMemory();
dialog.value.open();
}
});
</script>
<template>
<Dialog ref="dialog"
:title="$t('backups.configureBackupStorage.title')"
:reject-label="$t('main.dialog.close')"
:reject-active="!busy"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="busy"
confirm-style="primary"
@confirm="onSubmit()"
>
<div>
<div>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
<fieldset :disabled="busy">
<input style="display: none;" type="submit"/>
<FormGroup>
<label for="backupSiteNameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
<TextInput id="backupSiteNameInput" v-model="name" required/>
<div class="error-label" v-if="formError.name">{{ formError.name }}</div>
</FormGroup>
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<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>{{ site.locationLabel }}</div>
</FormGroup>
<FormGroup v-if="provider === 'sshfs'">
<label for="mountOptionsUserInput">{{ $t('backups.configureBackupStorage.user') }}</label>
<TextInput id="mountOptionsUserInput" v-model="mountOptionsUser" required />
</FormGroup>
<FormGroup v-if="provider === 'sshfs'">
<label for="mountOptionsPrivateKeyInput">{{ $t('backups.configureBackupStorage.privateKey') }}</label>
<MaskedInput id="mountOptionsPrivateKeyInput" :multiline="true" v-model="mountOptionsPrivateKey" required/>
</FormGroup>
<FormGroup v-if="provider === 'cifs'">
<label for="mountOptionsUsernameInput">{{ $t('backups.configureBackupStorage.username') }} ({{ provider }})</label>
<TextInput id="mountOptionsUsernameInput" v-model="mountOptionsUsername" required />
</FormGroup>
<FormGroup v-if="provider === 'cifs'">
<label for="mountOptionsPasswordInput">{{ $t('backups.configureBackupStorage.password') }} ({{ provider }})</label>
<MaskedInput id="mountOptionsPasswordInput" v-model="mountOptionsPassword" required />
</FormGroup>
<Checkbox v-if="(provider === 'filesystem' || mountlike(provider)) && site.format === 'rsync'" v-model="useHardlinks" :label="$t('backups.configureBackupStorage.hardlinksLabel')"/>
<Checkbox v-if="(provider === 'mountpoint' || provider === 'cifs') && site.format === 'rsync'" v-model="preserveAttributes" :label="$t('backups.configureBackupStorage.preserveAttributesLabel')"/>
<Checkbox v-if="provider === 'mountpoint' && format === 'rsync'" v-model="chown" :label="$t('backups.configureBackupStorage.chown')"/>
<FormGroup v-if="s3like(provider)">
<label for="accessKeyIdInput">{{ $t('backups.configureBackupStorage.s3AccessKeyId') }}</label>
<TextInput id="accessKeyIdInput" v-model="accessKeyId" required />
</FormGroup>
<FormGroup v-if="s3like(provider)">
<label for="secretAccessKeyInput">{{ $t('backups.configureBackupStorage.s3SecretAccessKey') }}</label>
<MaskedInput id="secretAccessKeyInput" v-model="secretAccessKey" required />
</FormGroup>
<FormGroup>
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
<div description class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
<input type="range" id="memoryLimitInput" v-model="memoryLimit" :step="256*1024*1024" :min="minMemoryLimit" :max="maxMemoryLimit" />
</FormGroup>
<FormGroup v-if="s3like(provider)">
<label for="uploadPartSizeInput">{{ $t('backups.configureBackupStorage.uploadPartSize') }}: <b>{{ prettyBinarySize(uploadPartSize, 'Default (50 MiB)') }}</b></label>
<div description class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</div>
<input type="range" id="uploadPartSizeInput" v-model="uploadPartSize" list="uploadPartSizeTicks" :step="1024*1024" :min="10*1024*1024" :max="1024*1024*1024" />
<datalist id="uploadPartSizeTicks">
<option :value="1024*1024*10"></option>
<option :value="1024*1024*64"></option>
<option :value="1024*1024*128"></option>
<option :value="1024*1024*256"></option>
<option :value="1024*1024*512"></option>
<option :value="1024*1024*1024"></option>
</datalist>
</FormGroup>
<FormGroup v-if="site.format === 'rsync'">
<label for="syncConcurrencyInput">{{ $t('backups.configureBackupStorage.uploadConcurrency') }}: <b>{{ syncConcurrency }}</b></label>
<div description class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
<input type="range" id="syncConcurrencyInput" v-model="syncConcurrency" step="10" min="10" max="200" />
</FormGroup>
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
<label for="downloadConcurrencyInput">{{ $t('backups.configureBackupStorage.downloadConcurrency') }}: <b>{{ downloadConcurrency }}</b></label>
<div description class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
<input type="range" id="downloadConcurrencyInput" v-model="downloadConcurrency" step="10" min="10" max="200" />
</FormGroup>
<FormGroup v-if="site.format === 'rsync' && (s3like(provider) || provider === 'gcs')">
<label for="copyConcurrencyInput">{{ $t('backups.configureBackupStorage.copyConcurrency') }}: <b>{{ copyConcurrency }}</b></label>
<div description class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}</div>
<input type="range" id="copyConcurrencyInput" v-model="copyConcurrency" step="10" min="10" max="500" />
</FormGroup>
</fieldset>
</form>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,156 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Checkbox, Radiobutton, MultiSelect, Dialog, FormGroup } from '@cloudron/pankow';
import AppsModel from '../models/AppsModel.js';
import BackupSitesModel from '../models/BackupSitesModel.js';
const emit = defineEmits([ 'success' ]);
const appsModel = AppsModel.create();
const backupSitesModel = BackupSitesModel.create();
const dialog = useTemplateRef('dialog');
const site = ref({});
const provider = ref('');
const formError = ref({});
const busy = ref(false);
const enableForUpdates = ref(false);
const includeExclude = ref('everything'); // or include, exclude
const contentOptions = ref([]);
const contentInclude = ref([]);
const contentExclude = ref([]);
async function onSubmit() {
busy.value = true;
let [error] = await backupSitesModel.setEnableForUpdates(site.value.id, enableForUpdates.value);
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
let contents;
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') {
if (contentInclude.value.length === 0) {
formError.value.includeExclude = 'Include at least one content item';
busy.value = false;
return;
}
contents = { include: contentInclude.value };
}
[error] = await backupSitesModel.setContents(site.value.id, contents);
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
emit('success');
dialog.value.close();
busy.value = false;
}
defineExpose({
async open(t) {
t = JSON.parse(JSON.stringify(t)); // make a copy
formError.value = {};
busy.value = false;
site.value = t;
provider.value = t.provider;
includeExclude.value = 'everything';
contentInclude.value = [];
contentExclude.value = [];
enableForUpdates.value = !!t.enableForUpdates;
const [error, result] = await appsModel.list();
if (error) return console.error(error);
contentOptions.value = [{
id: 'box',
label: 'System & email',
}];
result.forEach(a => {
contentOptions.value.push({
id: a.id,
label: `${a.label || a.fqdn} - ${a.manifest.title}`,
});
});
if (t.contents !== null) {
if (t.contents.exclude) {
includeExclude.value = 'exclude';
contentExclude.value = t.contents.exclude;
} else if (t.contents.include) {
includeExclude.value = 'include';
contentInclude.value = t.contents.include;
}
}
dialog.value.open();
}
});
</script>
<template>
<Dialog ref="dialog"
:title="$t('backups.configureContent.title')"
:reject-label="$t('main.dialog.close')"
:reject-active="!busy"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="busy"
confirm-style="primary"
@confirm="onSubmit()"
>
<div>
<div>
<p>{{ $t('backups.configureBackupStorage.backupContents.context', { name: site.name }) }}</p>
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
<fieldset :disabled="busy">
<input style="display: none;" type="submit"/>
<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'" 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'" required :options="contentOptions" :search-threshold="10" option-key="id" style="margin: 6px 0 6px 25px;"/>
</div>
</FormGroup>
<FormGroup>
<label>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}</label>
<div description>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
<Checkbox v-model="enableForUpdates" :label="$t('backups.configureBackupStorage.useForUpdates')" />
</FormGroup>
</fieldset>
</form>
</div>
</div>
</Dialog>
</template>
@@ -1,229 +0,0 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Checkbox, Radiobutton, MultiSelect, Dialog, FormGroup, TextInput } from '@cloudron/pankow';
import { prettyBinarySize } from '@cloudron/pankow/utils';
import { s3like } from '../utils.js';
import AppsModel from '../models/AppsModel.js';
import BackupSitesModel from '../models/BackupSitesModel.js';
import SystemModel from '../models/SystemModel.js';
const emit = defineEmits([ 'success' ]);
const appsModel = AppsModel.create();
const backupSitesModel = BackupSitesModel.create();
const systemModel = SystemModel.create();
const minMemoryLimit = ref(1024 * 1024 * 1024); // 1 GB
const maxMemoryLimit = ref(minMemoryLimit.value); // set later
const dialog = useTemplateRef('dialog');
const site = ref({});
const formError = ref({});
const busy = ref(false);
const name = ref('');
const enableForUpdates = ref(false);
const memoryLimit = ref(0);
const uploadPartSize = ref(0);
const syncConcurrency = ref(0);
const downloadConcurrency = ref(0);
const copyConcurrency = ref(0);
const includeExclude = ref('everything'); // or include, exclude
const contentOptions = ref([]);
const contentInclude = ref([]);
const contentExclude = ref([]);
async function onSubmit() {
busy.value = true;
let [error] = await backupSitesModel.setName(site.value.id, name.value);
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
[error] = await backupSitesModel.setEnableForUpdates(site.value.id, enableForUpdates.value);
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
let contents;
if (includeExclude.value === 'everything') {
contents = null;
} else if (includeExclude.value === 'exclude') {
contents = { exclude: contentExclude.value };
} else if (includeExclude.value === 'include' && contentInclude.value.length) {
contents = { include: contentInclude.value };
}
[error] = await backupSitesModel.setContents(site.value.id, contents);
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
const limits = {
memoryLimit: parseInt(memoryLimit.value),
uploadPartSize: parseInt(uploadPartSize.value),
syncConcurrency: parseInt(syncConcurrency.value),
downloadConcurrency: parseInt(downloadConcurrency.value),
copyConcurrency: parseInt(copyConcurrency.value),
};
[error] = await backupSitesModel.setLimits(site.value.id, limits);
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
emit('success');
dialog.value.close();
busy.value = false;
}
async function getMemory() {
const [error, result] = await systemModel.memory();
if (error) return console.error(error);
maxMemoryLimit.value = Math.ceil(result.memory / (1024*1024*1024)) * 1024 * 1024 * 1024;
}
defineExpose({
async open(t) {
formError.value = {};
busy.value = false;
site.value = t;
name.value = t.name || '';
enableForUpdates.value = !!t.enableForUpdates;
memoryLimit.value = t.limits.memoryLimit || 1024 * 1024 * 1024; // 1 GB
uploadPartSize.value = t.limits.uploadPartSize || 10 * 1024 * 1024;
syncConcurrency.value = t.limits.syncConcurrency || 10;
downloadConcurrency.value = t.limits.downloadConcurrency || 10;
copyConcurrency.value = t.limits.copyConcurrency || 10;
await getMemory();
const [error, result] = await appsModel.list();
if (error) return console.error(error);
contentOptions.value = [{
id: 'box',
label: 'Platform',
}];
result.forEach(a => {
contentOptions.value.push({
id: a.id,
label: `${a.label || a.fqdn} - ${a.manifest.title}`,
});
});
if (t.contents !== null) {
if (t.contents.exclude) {
includeExclude.value = 'exclude';
contentExclude.value = t.contents.exclude;
} else if (t.contents.include) {
includeExclude.value = 'include';
contentInclude.value = t.contents.include;
}
} else {
includeExclude.value = 'everything';
}
dialog.value.open();
}
});
</script>
<template>
<Dialog ref="dialog"
:title="$t('backups.configureBackupStorage.title')"
:reject-label="$t('main.dialog.close')"
:reject-active="!busy"
reject-style="secondary"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="busy"
confirm-style="primary"
@confirm="onSubmit()"
>
<div>
<div>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form">
<fieldset :disabled="busy">
<input style="display: none;" type="submit"/>
<FormGroup>
<label for="backupSiteNameInput">{{ $t('backups.configureBackupStorage.name') }}</label>
<TextInput id="backupSiteNameInput" v-model="name" required/>
</FormGroup>
<FormGroup>
<label>{{ $t('backups.configureBackupStorage.backupContents.title') }}</label>
<div>{{ $t('backups.configureBackupStorage.backupContents.description') }}</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;"/>
<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;"/>
</div>
</FormGroup>
<FormGroup>
<label>{{ $t('backups.configureBackupStorage.automaticUpdates.title') }}</label>
<div>{{ $t('backups.configureBackupStorage.automaticUpdates.description') }}</div>
<Checkbox v-model="enableForUpdates" :label="$t('backups.configureBackupStorage.useForUpdates')" />
</FormGroup>
<FormGroup>
<label for="memoryLimitInput">{{ $t('backups.configureBackupStorage.memoryLimit') }}: <b>{{ prettyBinarySize(memoryLimit, '1024 MB') }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.memoryLimitDescription') }}</div>
<input type="range" id="memoryLimitInput" v-model="memoryLimit" :step="256*1024*1024" :min="minMemoryLimit" :max="maxMemoryLimit" />
</FormGroup>
<FormGroup v-if="s3like(site.provider)">
<label for="uploadPartSizeInput">{{ $t('backups.configureBackupStorage.uploadPartSize') }}: <b>{{ prettyBinarySize(uploadPartSize, 'Default (50 MiB)') }}</b></label>
<p class="small">{{ $t('backups.configureBackupStorage.uploadPartSizeDescription') }}</p>
<input type="range" id="uploadPartSizeInput" v-model="uploadPartSize" list="uploadPartSizeTicks" :step="1024*1024" :min="10*1024*1024" :max="1024*1024*1024" />
<datalist id="uploadPartSizeTicks">
<option :value="1024*1024*10"></option>
<option :value="1024*1024*64"></option>
<option :value="1024*1024*128"></option>
<option :value="1024*1024*256"></option>
<option :value="1024*1024*512"></option>
<option :value="1024*1024*1024"></option>
</datalist>
</FormGroup>
<FormGroup v-if="site.format === 'rsync'">
<label for="syncConcurrencyInput">{{ $t('backups.configureBackupStorage.uploadConcurrency') }}: <b>{{ syncConcurrency }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.uploadConcurrencyDescription') }}</div>
<input type="range" id="syncConcurrencyInput" v-model="syncConcurrency" step="10" min="10" max="200" />
</FormGroup>
<FormGroup v-if="site.format === 'rsync' && (s3like(site.provider) || site.provider === 'gcs')">
<label for="downloadConcurrencyInput">{{ $t('backups.configureBackupStorage.downloadConcurrency') }}: <b>{{ downloadConcurrency }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.downloadConcurrencyDescription') }}</div>
<input type="range" id="downloadConcurrencyInput" v-model="downloadConcurrency" step="10" min="10" max="200" />
</FormGroup>
<FormGroup v-if="site.format === 'rsync' && (s3like(site.provider) || site.provider === 'gcs')">
<label for="copyConcurrencyInput">{{ $t('backups.configureBackupStorage.copyConcurrency') }}: <b>{{ copyConcurrency }}</b></label>
<div class="small">{{ $t('backups.configureBackupStorage.copyConcurrencyDescription') }}
<span v-show="site.provider === 'digitalocean-spaces'">{{ $t('backups.configureBackupStorage.copyConcurrencyDigitalOceanNote') }}</span>
</div>
<input type="range" id="copyConcurrencyInput" v-model="copyConcurrency" step="10" min="10" max="500" />
</FormGroup>
</fieldset>
</form>
</div>
</div>
</Dialog>
</template>
@@ -0,0 +1,130 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { Radiobutton, Dialog, FormGroup, SingleSelect, MultiSelect } from '@cloudron/pankow';
import BackupSitesModel from '../models/BackupSitesModel.js';
import { cronDays, cronHours, parseSchedule } from '../utils.js';
const emit = defineEmits([ 'success' ]);
const backupSitesModel = BackupSitesModel.create();
const site = ref({});
const busy = ref(false);
const formError = ref('');
const dialog = useTemplateRef('dialog');
const scheduleType = ref('');
const days = ref([]);
const hours = ref([]);
const configureRetention = ref(''); // this is 'name' and not 'id' of backupRetentions because SingleSelect needs strings
const isConfigureValid = computed(() => {
return scheduleType.value === 'never' || (days.value.length > 0 && hours.value.length > 0);
});
async function onSubmit() {
if (!isConfigureValid.value) return;
busy.value = true;
let schedule;
if (scheduleType.value === 'pattern') {
let daysPattern;
if (days.value.length === 7) daysPattern = '*';
else daysPattern = days.value;
let hoursPattern;
if (hours.value.length === 24) hoursPattern = '*';
else hoursPattern = hours.value;
schedule = `00 00 ${hoursPattern} * * ${daysPattern}`;
} else {
schedule = 'never';
}
let [error] = await backupSitesModel.setSchedule(site.value.id, schedule);
if (error) {
busy.value = false;
formError.value = error.body ? error.body.message : 'Internal error';
return console.error(error);
}
const selectedRetention = BackupSitesModel.backupRetentions.find(function (x) { return x.name === configureRetention.value; });
[error] = await backupSitesModel.setRetention(site.value.id, selectedRetention.id);
if (error) {
busy.value = false;
formError.value = error.body ? error.body.message : 'Internal error';
return console.error(error);
}
emit('success');
dialog.value.close();
busy.value = false;
}
defineExpose({
async open(s) {
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; });
configureRetention.value = selectedRetention ? selectedRetention.name : BackupSitesModel.backupRetentions[0].name;
if (site.value.schedule === 'never') {
scheduleType.value = 'never';
} else {
scheduleType.value = 'pattern';
const result = parseSchedule(site.value.schedule);
days.value = result.days; // Array of cronDays.id
hours.value = result.hours; // Array of cronHours.id
}
dialog.value.open();
}
});
</script>
<template>
<Dialog ref="dialog"
:title="$t('backups.configureBackupSchedule.title')"
reject-style="secondary"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="busy"
:confirm-active="isConfigureValid"
@confirm="onSubmit()"
>
<p>{{ $t('backups.configureBackupSchedule.schedule.context', { name: site.name }) }}</p>
<div class="error-label" v-show="formError">{{ formError }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset>
<FormGroup>
<label for="daysInput">{{ $t('backups.configureBackupSchedule.schedule.title') }}</label>
<div description v-html="$t('backups.configureBackupSchedule.schedule.description')"></div>
<Radiobutton v-model="scheduleType" value="never" :label="$t('backups.configureBackupSchedule.disable')"/>
<Radiobutton v-model="scheduleType" value="pattern" :label="$t('backups.configureBackupSchedule.enable')"/>
<div v-if="scheduleType === 'pattern'" style="display: flex; align-items: center; gap: 10px; margin: 10px">
<div>{{ $t('backups.configureBackupSchedule.days') }}: <MultiSelect id="daysInput" v-model="days" :options="cronDays" option-key="id" option-label="name"></MultiSelect></div>
<div>{{ $t('backups.configureBackupSchedule.hours') }}: <MultiSelect v-model="hours" :options="cronHours" option-key="id" option-label="name"></MultiSelect></div>
<div class="text-small text-danger" v-show="scheduleType === 'pattern' && !(hours.length !== 0 && days.length !== 0)">{{ $t('settings.updateScheduleDialog.selectOne') }}</div>
</div>
</FormGroup>
<FormGroup>
<label for="retentionInput">{{ $t('backups.configureBackupSchedule.retentionPolicy') }}</label>
<!-- we do not used id as key because SingleSelect can only handle strings -->
<SingleSelect id="retentionInput" v-model="configureRetention" :options="BackupSitesModel.backupRetentions" option-key="name" option-label="name" />
</FormGroup>
</fieldset>
</form>
</Dialog>
</template>
+97
View File
@@ -0,0 +1,97 @@
<script setup>
import { ref, onMounted, inject } from 'vue';
import { API_ORIGIN } from '../constants.js';
import Section from './Section.vue';
import SettingsItem from './SettingsItem.vue';
import EditableField from './EditableField.vue';
import ImagePicker from './ImagePicker.vue';
import BrandingModel from '../models/BrandingModel.js';
import DashboardModel from '../models/DashboardModel.js';
const brandingModel = BrandingModel.create();
const dashboardModel = DashboardModel.create();
const avatarUrl = ref(`${API_ORIGIN}/api/v1/cloudron/avatar?${String(Math.random()).slice(2)}`);
const backgroundUrl = ref(`${API_ORIGIN}/api/v1/cloudron/background?${String(Math.random()).slice(2)}`);
const features = inject('features');
const name = ref('');
const savingName = ref(false);
async function onNameSave(newName) {
savingName.value = true;
const [error] = await brandingModel.setName(newName);
savingName.value = false;
if (error) return window.cloudron.onError(error);
name.value = newName;
}
async function onAvatarSubmit(file) {
const [error] = await brandingModel.setAvatar(file);
if (error) return console.error(error);
}
async function onBackgroundSubmit(file) {
const [error] = await brandingModel.setBackground(file);
if (error) return console.error(error);
}
async function onBackgroundUnset() {
const [error] = await brandingModel.unsetBackground();
if (error) return console.error(error);
backgroundUrl.value = '';
}
// Footer
const footer = ref('');
const savingFooter = ref(false);
async function onFooterSave(newFooter) {
savingFooter.value = true;
const [error] = await brandingModel.setFooter(newFooter);
savingFooter.value = false;
if (error) return console.error(error);
footer.value = newFooter;
}
onMounted(async () => {
let [error, result] = await dashboardModel.config();
if (error) return console.error(error);
name.value = result.cloudronName;
[error, result] = await brandingModel.getFooter();
if (error) return console.error(error);
footer.value = result;
});
</script>
<template>
<Section :title="$t('branding.title')" :title-badge="!features.branding ? 'Upgrade' : ''">
<div style="display: flex; justify-content: space-around; margin-bottom: 20px;">
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
<label>{{ $t('branding.logo') }}</label>
<ImagePicker mode="editable" :src="avatarUrl" :save-handler="onAvatarSubmit" :size="512" display-height="128px" :disabled="!features.branding" fallback-src="/api/v1/cloudron/avatar"/>
</div>
<div style="display: flex; flex-direction: column; align-content: stretch; align-items: center;">
<label>{{ $t('branding.backgroundImage') }}</label>
<ImagePicker mode="editable" :src="backgroundUrl" :save-handler="onBackgroundSubmit" :unset-handler="onBackgroundUnset" :disabled="!features.branding" fallback-src="/img/background-image-placeholder.svg" display-height="128px" :max-size="1280"/>
</div>
</div>
<SettingsItem>
<EditableField :label="$t('branding.cloudronName')" :saving="savingName" :value="name" :disabled="!features.branding" @save="onNameSave" :maxlength="64"/>
</SettingsItem>
<SettingsItem>
<EditableField :label="$t('branding.footer.title')" helpUrl="https://docs.cloudron.io/branding/#footer" multiline markdown :saving="savingFooter" :value="footer" :disabled="!features.branding" @save="onFooterSave"/>
</SettingsItem>
</Section>
</template>
@@ -0,0 +1,92 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup } from '@cloudron/pankow';
import CommunityModel from '../models/CommunityModel.js';
const communityModel = CommunityModel.create();
const emit = defineEmits([ 'success' ]);
const dialog = useTemplateRef('dialog');
const form = useTemplateRef('form');
const urlInput = useTemplateRef('urlInput');
const formError = ref({});
const versionsUrl = ref('');
const busy = ref(false);
const unstable = ref(false);
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 = {};
const [url, version] = versionsUrl.value.split('@'); // hidden feature that user can input with version
const [error, result] = await communityModel.getApp(url, version || 'latest');
if (error) {
formError.value.generic = error.body ? error.body.message : 'Internal error';
busy.value = false;
return console.error(error);
}
unstable.value = !!result.unstable;
const packageData = {
...result, // { manifest, publishState, creationDate, ts, unstable, versionsUrl }
versionsUrl: result.versionsUrl,
iconUrl: result.manifest.iconUrl // compat with app store format
};
emit('success', packageData);
dialog.value.close();
busy.value = false;
}
defineExpose({
open() {
versionsUrl.value = '';
formError.value = {};
unstable.value = false;
dialog.value.open();
setTimeout(validateForm, 100); // update state of the confirm button
setTimeout(() => urlInput.value.focus(), 500);
}
});
</script>
<template>
<Dialog ref="dialog"
title="Install Community App"
:confirm-label="$t('main.action.add')"
:confirm-busy="busy"
:confirm-active="!busy && isFormValid"
reject-style="secondary"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
@confirm="onSubmit()"
>
<form @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="validateForm()">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" />
<div class="warning-label">{{ $t('communityapp.installwarning') }}</div>
<div class="error-label" v-if="unstable">{{ $t('communityapp.unstablewarning') }}</div>
<FormGroup>
<label for="urlInput">CloudronVersions.json URL</label>
<TextInput id="urlInput" ref="urlInput" v-model="versionsUrl" required placeholder="https://example.com/CloudronVersions.json"/>
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
</FormGroup>
</fieldset>
</form>
</Dialog>
</template>
+39 -6
View File
@@ -1,6 +1,10 @@
<script setup>
import { ref, onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, inject } from 'vue';
import { Button, ProgressBar, SingleSelect, InputGroup } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import { TASK_TYPES } from '../constants.js';
@@ -8,12 +12,15 @@ import Section from '../components/Section.vue';
import SettingsItem from '../components/SettingsItem.vue';
import TasksModel from '../models/TasksModel.js';
import DashboardModel from '../models/DashboardModel.js';
import DomainsModel from '../models/DomainsModel.js';
const taskModel = TasksModel.create();
const dashboardModel = DashboardModel.create();
const domainsModel = DomainsModel.create();
const props = defineProps([ 'domains' ]);
const inputDialog = inject('inputDialog');
const domains = ref([]);
const formError = ref('');
const originalDomain = ref('');
const newDomain = ref('');
@@ -63,6 +70,16 @@ async function refreshTasks() {
}
async function onSubmit() {
const confirm = await inputDialog.value.confirm({
title: t('domains.changeDashboardDomain.confirmTitle'),
message: t('domains.changeDashboardDomain.confirmMessage'),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
if (!confirm) return;
formError.value = '';
lastTask.value.active = true;
@@ -81,16 +98,32 @@ async function onSubmit() {
await refreshTasks();
}
onMounted(async () => {
async function selectCurrentDomain(newDomains) {
domains.value = newDomains;
const [error, result] = await dashboardModel.config();
if (error) return console.error(error);
newDomain.value = (props.domains.find(d => d.domain === result.adminDomain) || props.domains[0]).domain;
originalDomain.value = (props.domains.find(d => d.domain === result.adminDomain) || props.domains[0]).domain;
if (domains.value.length) {
const adminDomain = domains.value.find(d => d.domain === result.adminDomain);
if (adminDomain) {
newDomain.value = adminDomain.domain;
originalDomain.value = adminDomain.domain;
}
}
}
onMounted(async () => {
const [error, result] = await domainsModel.list();
if (error) return console.error(error);
await selectCurrentDomain(result);
await refreshTasks();
});
defineExpose({ updateDomains: selectCurrentDomain });
</script>
<template>
@@ -113,7 +146,7 @@ onMounted(async () => {
<div class="error-label" v-if="formError">{{ formError }}</div>
<div v-if="lastTask.active" style="padding: 0 10px">
<div v-if="lastTask.active">
<ProgressBar :value="lastTask.percent" :busy="true" />
<div>{{ lastTask.message }}</div>
</div>
@@ -1,8 +1,8 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
import ProfileModel from '../../models/ProfileModel.js';
import ProfileModel from '../models/ProfileModel.js';
const emit = defineEmits([ 'success' ]);
@@ -12,23 +12,33 @@ const dialog = useTemplateRef('dialog');
const formError = ref({});
const busy = ref (false);
const password = ref('');
const twoFAMethod = ref('totp'); // 'totp' or 'passkey'
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 = {};
const [error] = await profileModel.disableTwoFA(password.value);
let error;
if (twoFAMethod.value === 'passkey') {
[error] = await profileModel.deletePasskey(password.value);
} else {
[error] = await profileModel.disableTwoFA(password.value);
}
if (error) {
if (error.status === 412) formError.value.password = error.body.message;
else {
if (error.status === 412) {
password.value = '';
formError.value.password = error.body.message;
setTimeout(() => document.getElementById('passwordInput')?.focus(), 0);
} else {
formError.value.generic = error.status ? error.body.message : 'Internal error';
console.error('Failed to disable 2fa', error);
}
@@ -46,11 +56,14 @@ async function onSubmit() {
}
defineExpose({
async open() {
async open(method = 'totp') {
twoFAMethod.value = method;
password.value = '';
busy.value = false;
formError.value = {};
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -58,25 +71,25 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="$t('profile.disable2FA.title')"
:title="twoFAMethod === 'totp' ? $t('profile.disableTotp.title') : $t('profile.disablePasskey.title')"
:confirm-label="$t('profile.disable2FA.disable')"
:confirm-active="!busy && isFormValid"
:confirm-busy="busy"
confirm-style="primary"
confirm-style="danger"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
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 id="passwordInput" />
<div class="error-label" v-if="formError.password">{{ formError.password }}</div>
</FormGroup>
</fieldset>
+1 -17
View File
@@ -22,7 +22,7 @@ onMounted(async () => {
<template>
<Section :title="$t('system.diskUsage.title')">
<div class="filesystems-grid">
<div>
<DiskUsageItem v-for="filesystem in filesystems" :key="filesystem.filesystem" :filesystem="filesystem" />
</div>
</Section>
@@ -30,16 +30,6 @@ onMounted(async () => {
<style scoped>
.filesystems-grid {
display: flex;
height: 100%;
width: 100%;
transition: 300ms;
flex-wrap: wrap;
justify-content: start;
align-content: start;
}
.usage-bar {
display: flex;
flex-wrap: nowrap;
@@ -85,10 +75,4 @@ onMounted(async () => {
margin-left: 4px;
}
.disks-last-updated {
font-size: 12px;
font-weight: bold;
align-self: center;
}
</style>
+61 -75
View File
@@ -1,60 +1,32 @@
<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 AppsModel from '../models/AppsModel.js';
import VolumesModel from '../models/VolumesModel.js';
import { prettyDecimalSize, prettyDate } from '@cloudron/pankow/utils';
import { getColor } from '../utils.js';
import SystemModel from '../models/SystemModel.js';
const appsModel = AppsModel.create();
const volumesModel = VolumesModel.create();
const systemModel = SystemModel.create();
const props = defineProps({
filesystem: Object
});
function hue(numOfSteps, step) {
const deg = 360/numOfSteps;
return `hsl(${deg*step} 70% 50%)`;
}
let colorIndex = 0;
let colors = [];
function resetColors(n) {
colorIndex = 7;
colors = [];
for (let i = 0; i < n; i++) colors.push(hue(n, i));
}
function getNextColor() {
return colors[colorIndex++];
}
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() {
let [error, result] = await appsModel.list();
async function getUsage() {
const [error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
if (error) return console.error(error);
const appsById = {};
result.forEach(a => { appsById[a.id] = a; });
[error, result] = await volumesModel.list();
if (error) return console.error(error);
const volumesById = {};
result.forEach(v => { volumesById[v.id] = v; });
[error, result] = await systemModel.filesystemUsage(props.filesystem.filesystem);
if (error) return console.error(error);
showingCachedValue.value = false;
contents.value = [];
@@ -65,23 +37,16 @@ async function refresh() {
if (payload.type === 'done') {
percent.value = 100;
ts.value = Date.now();
// we first 8 colors are reserved for known system contents
resetColors(contents.value.length + 8);
contents.value.forEach(content => {
// assign fixed colors for known entries
if (content.id === 'platformdata') content.color = colors[0];
else if (content.id === 'boxdata') content.color = colors[1];
else if (content.id === 'maildata') content.color = colors[2];
else if (content.id === 'cloudron-backup-default') content.color = colors[3];
else if (content.id === 'docker') content.color = colors[4];
else if (content.id === 'docker-volumes') content.color = colors[5];
else if (content.id === '/apps.swap') content.color = colors[6];
else if (content.id === 'os') content.color = colors[7];
else content.color = getNextColor();
});
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;
@@ -89,16 +54,8 @@ async function refresh() {
if (payload.speed) {
speed.value = payload.speed;
} else if (payload.content) {
if (payload.content.type === 'app') {
payload.content.app = appsById[payload.content.id];
if (!payload.content.app) payload.content.uninstalled = true;
else payload.content.label = payload.content.app.label || payload.content.app.fqdn;
} else if (payload.content.type === 'volume') {
payload.content.volume = volumesById[payload.content.id];
payload.content.label = payload.content.volume ? `Volume ${payload.content.volume.name}` : 'Removed volume';
} else {
payload.content.label = payload.content.id;
}
// this can happen if more than one backup sites for filesystem share the folder, so avoid negativ values here
if (payload.content.usage < 0) payload.content.usage = 0;
contents.value.push(payload.content);
} else {
console.error('Unkown data', payload);
@@ -117,9 +74,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();
});
@@ -130,32 +111,38 @@ 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">
<ProgressBar v-if="percent < 100" mode="indeterminate" :show-label="false"/>
<div v-else class="disk-size" style="overflow: visible;">
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.id" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
<div class="disk-used" v-for="content in contents" :key="content.id" v-tooltip="content.name" @mouseover="highlight = content.id" :style="{ 'background-color': content.color, width: 100*content.usage/filesystem.size + '%' }" :class="{ highlight: highlight === content.id }"></div>
</div>
<div v-if="percent < 100" style="text-align: center;">Calculating speed and disk usage ... {{ percent }}%</div>
<div v-if="percent < 100" style="text-align: center; margin-top: 10px;">Calculating speed and disk usage ... {{ parseInt(percent) }}%</div>
<div v-else>
<table style="width: 100%">
<table style="width: 100%;table-layout: fixed">
<tr v-for="content in contents" :key="content.id" @mouseover="highlight = content.id" :class="{ highlight: highlight === content.id }">
<td style="width: 20px"><div class="content-color-indicator" :style="{ backgroundColor: content.color }"></div></td>
<td>{{ content.label }}</td>
<td style="text-align: right">{{ prettyDecimalSize(content.usage) }}</td>
<td style="white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
<a v-if="content.type === 'app'" :href="`/#/app/${content.id}/info`">{{ content.name }}</a>
<a v-else-if="content.type === 'volume'" href="/#/volumes">{{ content.name }} (Volume)</a>
<span v-else>{{ content.name }}</span>
</td>
<td style="text-align: right; white-space: nowrap;">{{ prettyDecimalSize(content.usage) }}</td>
</tr>
</table>
</div>
</div>
<div v-else>
<ProgressBar :value="parseInt(filesystem.capacity*100)" :show-label="false"/>
<div style="text-align: center">
<div style="text-align: center; margin-top: 10px;">
<Button plain @click="onExpand()">Details</Button>
</div>
</div>
@@ -169,12 +156,11 @@ onUnmounted(() => {
display: flex;
flex-direction: column;
flex-grow: 1;
margin: 10px;
max-width: 620px;
padding: 10px 16px;
overflow: hidden;
border-radius: 10px;
background-color: var(--card-background);
margin-bottom: 16px;
}
.disk-item:focus,
@@ -191,7 +177,7 @@ onUnmounted(() => {
.disk-item-title {
display: flex;
justify-content: space-between;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
font-size: 18px;
}
@@ -239,7 +225,7 @@ onUnmounted(() => {
}
tr.highlight {
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
}
</style>
+16 -19
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');
@@ -62,11 +61,12 @@ function onEditOrAdd(registry = null) {
async function onRemove(registry) {
const yes = await inputDialog.value.confirm({
title: t('dockerRegistries.removeDialog.title', { serverAddress: registry.serverAddress}),
message: t('dockerRegistres.removeDialog.description'),
title: t('dockerRegistries.removeDialog.title'),
message: t('dockerRegistres.removeDialog.description', { serverAddress: registry.serverAddress }),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.delete'),
rejectLabel: t('main.dialog.cancel')
confirmLabel: t('main.action.remove'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!yes) return;
@@ -93,7 +93,6 @@ onMounted(async () => {
<template>
<Section :title="$t('dockerRegistries.title')" :title-badge="!features.privateDockerRegistry ? 'Upgrade' : ''">
<Menu ref="actionMenuElement" :model="actionMenuModel" />
<InputDialog ref="inputDialog" />
<DockerRegistryDialog ref="dialog" @success="refresh()"/>
@@ -105,10 +104,8 @@ onMounted(async () => {
<br/>
<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>
<template #actions="{ item:registry }">
<ActionBar :actions="createActionMenu(registry)"/>
</template>
</TableView>
</Section>
@@ -4,9 +4,8 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, useTemplateRef, computed } from 'vue';
import { Dialog, TextInput, FormGroup, SingleSelect } from '@cloudron/pankow';
import { isValidDomainOrURL } from '@cloudron/pankow/utils';
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup, SingleSelect, MaskedInput } from '@cloudron/pankow';
import DockerRegistriesModel from '../models/DockerRegistriesModel.js';
const dockerRegistriesModel = DockerRegistriesModel.create();
@@ -19,7 +18,6 @@ const providers = [
{ name: 'Google Cloud', value: 'google-cloud' },
{ name: 'Linode', value: 'linode' },
{ name: 'Quay', value: 'quay' },
{ name: 'Treescale', value: 'treescale' },
{ name: t('settings.registryConfig.providerOther') || 'Other', value: 'other' },
];
@@ -36,17 +34,15 @@ const username = ref('');
const email = ref('');
const password = ref('');
const isValid = computed(() => {
if (!provider.value) return false;
if (!serverAddress.value) return false;
if (!username.value) return false;
if (!password.value) return false;
if (!isValidDomainOrURL(serverAddress.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 (!form.value.reportValidity()) return;
busy.value = true;
formError.value = {};
@@ -66,6 +62,7 @@ async function onSubmit() {
defineExpose({
async open(r = null) {
r = r ? JSON.parse(JSON.stringify(r)) : null; // make a copy
busy.value = false;
formError.value = {};
registry.value = r;
@@ -76,6 +73,8 @@ defineExpose({
password.value = r ? r.password : '';
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -83,23 +82,23 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="$t('dockerRegistries.dialog.title')"
:confirm-label="$t('main.dialog.save')"
:title="registry ? $t('dockerRegistries.dialog.editTitle') : $t('dockerRegistries.dialog.addTitle')"
:confirm-label="registry ? $t('main.dialog.save') : $t('main.action.add')"
:confirm-busy="busy"
:confirm-active="!busy && isValid"
:confirm-active="!busy && isFormValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
>
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
<fieldset :disabled="busy">
<input style="display: none" type="submit" :disabled="!isValid"/>
<input style="display: none" type="submit" :disabled="busy"/>
<div class="error-label" v-if="formError.generic">{{ formError.generic }}</div>
<FormGroup>
<label for="providerInput">{{ $t('settings.registryConfig.provider') }} <sup><a href="https://docs.cloudron.io/settings/#private-docker-registry" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="provider" :options="providers" option-key="value" option-label="name" />
<SingleSelect id="providerInput" v-model="provider" :options="providers" option-key="value" option-label="name" required />
</FormGroup>
<FormGroup>
@@ -113,13 +112,13 @@ defineExpose({
</FormGroup>
<FormGroup>
<label for="emailInput">{{ $t('dockerRegistries.email') }} (Optional)</label>
<label for="emailInput">{{ $t('dockerRegistries.email') }} (optional)</label>
<TextInput id="emailInput" v-model="email" />
</FormGroup>
<FormGroup>
<label for="passwordInput">{{ $t('dockerRegistries.passwordToken') }}</label>
<TextInput id="passwordInput" v-model="password" required />
<MaskedInput id="passwordInput" v-model="password" required />
</FormGroup>
</fieldset>
</form>
+77 -39
View File
@@ -1,7 +1,8 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup, Checkbox } from '@cloudron/pankow';
import { ref, useTemplateRef, watchEffect } from 'vue';
import { Dialog, TextInput, InputGroup, FormGroup, Button } from '@cloudron/pankow';
import { getTextFromFile } from '../utils.js';
import DomainsModel from '../models/DomainsModel.js';
import DomainProviderForm from './DomainProviderForm.vue';
@@ -10,6 +11,8 @@ const emit = defineEmits([ 'success' ]);
const domainsModel = DomainsModel.create();
const dialog = useTemplateRef('dialog');
const keyFileInput = useTemplateRef('keyFileInput');
const certificateFileInput = useTemplateRef('certificateFileInput');
const busy = ref(false);
const errorMessage = ref('');
@@ -20,15 +23,17 @@ const provider = ref('');
const tlsProvider = ref('letsencrypt-prod-wildcard');
const showAdvanced = ref(false);
const customNameservers = ref(false);
const certificateFileName = ref('');
const keyFileName = ref('');
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;
}
const form = useTemplateRef('form');
async function onSubmit() {
if (!form.value.reportValidity()) return;
@@ -49,8 +54,27 @@ async function onSubmit() {
tlsConfig.wildcard = true;
}
let fallbackCertificate = null;
if (tlsConfig.provider === 'fallback') {
const certFile = certificateFileInput.value.files[0] || null;
const keyFile = keyFileInput.value.files[0] || null;
if ((!certFile && keyFile) || (certFile && !keyFile)) {
errorMessage.value = 'Both certificate and key file need to be provided';
busy.value = false;
return;
} else if (certFile && keyFile) {
fallbackCertificate = {
cert: await getTextFromFile(certFile),
key: await getTextFromFile(keyFile),
};
} else {
// unchanged
}
}
const func = editing.value ? domainsModel.update : domainsModel.add;
const [error] = await func(domain.value, zoneName.value, provider.value, config, null, tlsConfig);
const [error] = await func(domain.value, zoneName.value, provider.value, config, fallbackCertificate, tlsConfig);
if (error) {
errorMessage.value = error.body ? error.body.message : 'Internal error';
busy.value = false;
@@ -61,9 +85,27 @@ async function onSubmit() {
dialog.value.close();
}
function onCertificateFileChange() {
const file = certificateFileInput.value.files[0] || '';
if (!file) return;
certificateFileName.value = file ? file.name : '';
}
function onKeyFileChange() {
const file = keyFileInput.value.files[0] || '';
if (!file) return;
keyFileName.value = file ? file.name : '';
}
watchEffect(() => {
if (dnsConfig.value.credentials) setTimeout(checkValidity, 100);
});
defineExpose({
open(d) {
d = d || { config: {}, tlsConfig: {}};
d = d ? JSON.parse(JSON.stringify(d)) : { config: {}, tlsConfig: { provider: 'letsencrypt-prod', wildcard: true } }; // make a copy
provider.value = d.provider || '';
dnsConfig.value = d.config;
@@ -79,13 +121,13 @@ defineExpose({
editing.value = !!d.domain;
domain.value = d.domain || '';
zoneName.value = d.zoneName || '';
customNameservers.value = d.config.customNameservers;
certificateFileName.value = d.tlsConfig?.provider === 'fallback' ? String.fromCharCode(0x25CF).repeat(8) : '';
keyFileName.value = d.tlsConfig?.provider === 'fallback' ? String.fromCharCode(0x25CF).repeat(8) : '';
dialog.value.open();
// ensure we trigger this once
setTimeout(checkValidity, 100);
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -93,11 +135,12 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="editing ? $t('domains.domainDialog.editTitle', { domain: domain }) : $t('domains.domainDialog.addTitle')"
:title="editing ? $t('domains.domainDialog.editTitle') : $t('domains.domainDialog.addTitle')"
:confirm-busy="busy"
:confirm-active="!busy && isFormValid"
:confirm-label="$t('main.dialog.save')"
:reject-label="busy ? null : $t('main.dialog.cancel')"
:confirm-label="editing ? $t('main.dialog.save') : $t('main.action.add')"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
reject-style="secondary"
@confirm="onSubmit()"
>
@@ -109,42 +152,37 @@ 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" :domain="domain" :show-advanced="showAdvanced" />
<p style="margin-top: 15px" v-show="!showAdvanced" @click="showAdvanced = true" class="actionable">{{ $t('domains.domainDialog.advancedAction') }}</p>
<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" />
<div v-show="showAdvanced">
<FormGroup>
<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" />
</FormGroup>
<Checkbox v-model="customNameservers" :label="$t('domains.domainDialog.customNameservers')" />
<!-- custom certificate -->
<div v-if="tlsProvider === 'fallback'">
<label >{{ $t('domains.domainDialog.fallbackCertCustomCert') }}</label>
<p v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></p>
<label>{{ $t('domains.domainDialog.fallbackCertCustomCert') }}</label>
<div description v-html="$t('domains.domainDialog.fallbackCertCustomCertInfo', { customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' })"></div>
</div>
<FormGroup v-if="tlsProvider === 'fallback'">
<div class="input-group">
<input type="file" id="fallbackCertFileInput" style="display:none"/>
<input type="text" class="form-control" :placeholder="$t('domains.domainDialog.fallbackCertCertificatePlaceholder')" ng-model="domainConfigure.fallbackCert.certificateFileName" name="cert" onclick="getElementById('fallbackCertFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon"><i class="fa fa-upload" onclick="getElementById('fallbackCertFileInput').click();"></i></span>
<div v-if="tlsProvider === 'fallback'">
<input type="file" ref="certificateFileInput" style="display: none" @change="onCertificateFileChange"/>
<input type="file" ref="keyFileInput" style="display: none" @change="onKeyFileChange"/>
<div style="display: grid; grid-template-columns: auto 1fr; gap: 5px 10px; margin-top: 15px">
<label>{{ $t('domains.domainDialog.fallbackCertCertificatePlaceholder') }}</label>
<InputGroup>
<TextInput v-model="certificateFileName" @click="certificateFileInput.click()" style="cursor: pointer; flex-grow: 1;" :disabled="busy" />
<Button tool secondary icon="fa-solid fa-upload" @click="certificateFileInput.click()"></Button>
</InputGroup>
<label>{{ $t('domains.domainDialog.fallbackCertKeyPlaceholder') }}</label>
<InputGroup>
<TextInput v-model="keyFileName" @click="keyFileInput.click()" style="cursor: pointer; flex-grow: 1;" :disabled="busy" />
<Button tool secondary icon="fa-solid fa-upload" @click="keyFileInput.click()"></Button>
</InputGroup>
</div>
</FormGroup>
<FormGroup v-if="tlsProvider === 'fallback'">
<div class="input-group">
<input type="file" id="fallbackKeyFileInput" style="display:none"/>
<input type="text" class="form-control" :placeholder="$t('domains.domainDialog.fallbackCertKeyPlaceholder')" ng-model="domainConfigure.fallbackCert.keyFileName" id="fallbackKeyInput" name="key" onclick="getElementById('fallbackKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon"><i class="fa fa-upload" onclick="getElementById('fallbackKeyFileInput').click();"></i></span>
</div>
</FormGroup>
</div>
</div>
<div v-if="!showAdvanced" style="margin-top: 15px" class="actionable" @click="showAdvanced = true">{{ $t('domains.domainDialog.advancedAction') }}</div>
</fieldset>
</form>
</Dialog>
+51 -42
View File
@@ -5,7 +5,7 @@ const i18n = useI18n();
const t = i18n.t;
import { ref } from 'vue';
import { TextInput, InputGroup, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
import { TextInput, InputGroup, MaskedInput, Button, FormGroup, Checkbox, SingleSelect } from '@cloudron/pankow';
import { ENDPOINTS_OVH } from '../constants.js';
import DomainsModel from '../models/DomainsModel.js';
@@ -27,6 +27,8 @@ defineProps({
const provider = defineModel('provider');
const dnsConfig = defineModel('dnsConfig');
const tlsProvider = defineModel('tlsProvider');
const zoneName = defineModel('zoneName');
const customNameservers = defineModel('customNameservers');
const tlsProviders = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
@@ -51,21 +53,12 @@ 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 = '';
dnsConfig.value.accessToken = '';
dnsConfig.value.apiKey = '';
dnsConfig.value.apikey = '';
dnsConfig.value.appKey = '';
dnsConfig.value.appSecret = '';
dnsConfig.value.apiPassword = '';
dnsConfig.value.apiSecret = '';
@@ -85,8 +78,14 @@ function resetFields() {
}
function onProviderChange(p) {
setDefaultTlsProvider(p);
resetFields(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';
}
}
const gcdnsFileParseError = ref('');
@@ -128,9 +127,13 @@ function onGcdnsFileInputChange(event) {
<div>
<FormGroup>
<label for="providerInput">{{ $t('domains.domainDialog.provider') }} <sup><a href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect v-model="provider" @select="onProviderChange" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" />
<SingleSelect v-model="provider" :disabled="disabled" :options="DomainsModel.providers" option-key="value" option-label="name" required @select="onProviderChange"/>
</FormGroup>
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
<!-- Route53 -->
<FormGroup v-if="provider === 'route53'">
<label for="accessKeyIdInput">{{ $t('domains.domainDialog.route53AccessKeyId') }}</label>
@@ -138,7 +141,7 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'route53'">
<label for="secretAccessKeyInput">{{ $t('domains.domainDialog.route53SecretAccessKey') }}</label>
<TextInput id="secretAccessKeyInput" v-model="dnsConfig.secretAccessKey" required />
<MaskedInput id="secretAccessKeyInput" v-model="dnsConfig.secretAccessKey" required />
</FormGroup>
<!-- Google Cloud DNS -->
@@ -146,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>
@@ -155,7 +159,7 @@ function onGcdnsFileInputChange(event) {
<!-- DigitalOcean -->
<FormGroup v-if="provider === 'digitalocean'">
<label for="digitalOceanTokenInput">{{ $t('domains.domainDialog.digitalOceanToken') }}</label>
<TextInput id="digitalOceanTokenInput" v-model="dnsConfig.token" required />
<MaskedInput id="digitalOceanTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
<!-- Gandi -->
@@ -165,7 +169,7 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'gandi'">
<label for="gandiApiKeyInput">{{ $t('domains.domainDialog.gandiApiKey') }}</label>
<TextInput id="gandiApiKeyInput" v-model="dnsConfig.token" required />
<MaskedInput id="gandiApiKeyInput" v-model="dnsConfig.token" required />
</FormGroup>
<!-- GoDaddy -->
@@ -175,7 +179,7 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'godaddy'">
<label for="godaddyApiSecretInput">{{ $t('domains.domainDialog.goDaddyApiSecret') }}</label>
<TextInput for="godaddyApiSecretInput" v-model="dnsConfig.apiSecret" required />
<MaskedInput for="godaddyApiSecretInput" v-model="dnsConfig.apiSecret" required />
</FormGroup>
<!-- Netcup -->
@@ -189,7 +193,7 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'netcup'">
<label for="netcupApiPasswordInput">{{ $t('domains.domainDialog.netcupApiPassword') }}</label>
<TextInput id="netcupApiPasswordInput" v-model="dnsConfig.apiPassword" required />
<MaskedInput id="netcupApiPasswordInput" v-model="dnsConfig.apiPassword" required />
</FormGroup>
<!-- OVH -->
@@ -207,7 +211,7 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'ovh'">
<label for="ovhAppSecretInput">{{ $t('domains.domainDialog.ovhAppSecret') }}</label>
<TextInput id="ovhAppSecretInput" v-model="dnsConfig.appSecret" required />
<MaskedInput id="ovhAppSecretInput" v-model="dnsConfig.appSecret" required />
</FormGroup>
<!-- Porkbun -->
@@ -217,61 +221,61 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'porkbun'">
<label for="porkbunSecretapikeyInput">{{ $t('domains.domainDialog.porkbunSecretapikey') }}</label>
<TextInput id="porkbunSecretapikeyInput" v-model="dnsConfig.secretapikey" required />
<MaskedInput id="porkbunSecretapikeyInput" v-model="dnsConfig.secretapikey" required />
</FormGroup>
<!-- Cloudflare -->
<FormGroup v-if="provider === 'cloudflare'">
<label for="cloudflareTokenTypeInput">{{ $t('domains.domainDialog.cloudflareTokenType') }}</label>
<SingleSelect v-model="dnsConfig.tokenType" :options="cloudflareTokenTypes" option-key="value" option-label="name" />
<SingleSelect v-model="dnsConfig.tokenType" :options="cloudflareTokenTypes" option-key="value" option-label="name" required/>
</FormGroup>
<FormGroup v-if="provider === 'cloudflare' && dnsConfig.tokenType === 'GlobalApiKey' || dnsConfig.tokenType === 'ApiToken'">
<label for="cloudflareTokenInput" v-show="dnsConfig.tokenType === 'GlobalApiKey'">{{ $t('domains.domainDialog.cloudflareTokenTypeGlobalApiKey') }}</label>
<label for="cloudflareTokenInput" v-show="dnsConfig.tokenType === 'ApiToken'">{{ $t('domains.domainDialog.cloudflareTokenTypeApiToken') }}</label>
<TextInput id="cloudflareTokenInput" v-model="dnsConfig.token" required />
<MaskedInput id="cloudflareTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
<FormGroup v-if="provider === 'cloudflare' && dnsConfig.tokenType === 'GlobalApiKey'">
<label for="cloudflareEmailInput">{{ $t('domains.domainDialog.cloudflareEmail') }}</label>
<TextInput id="cloudflareEmailInput" type="email" v-model="dnsConfig.email" :required="dnsConfig.tokenType === 'GlobalApiKey'" />
<TextInput id="cloudflareEmailInput" type="email" v-model="dnsConfig.email" required />
</FormGroup>
<div v-if="provider === 'cloudflare'">
<Checkbox v-model="dnsConfig.defaultProxyStatus" :label="$t('domains.domainDialog.cloudflareDefaultProxyStatus')" help-url="https://docs.cloudron.io/domains/#cloudflare-dns" style="margin-top: 10px" />
<Checkbox v-model="dnsConfig.defaultProxyStatus" :label="$t('domains.domainDialog.cloudflareDefaultProxyStatus')" help-url="https://docs.cloudron.io/domains/#cloudflare-dns" />
</div>
<!-- Linode -->
<FormGroup v-if="provider === 'linode'">
<label for="linodeTokenInput">{{ $t('domains.domainDialog.linodeToken') }}</label>
<TextInput id="linodeTokenInput" v-model="dnsConfig.token" required />
<MaskedInput id="linodeTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
<!-- Bunny -->
<FormGroup v-if="provider === 'bunny'">
<label for="bunnyAccessKeyInput">{{ $t('domains.domainDialog.bunnyAccessKey') }}</label>
<TextInput id="bunnyAccessKeyInput" v-model="dnsConfig.accessKey" required />
<MaskedInput id="bunnyAccessKeyInput" v-model="dnsConfig.accessKey" required />
</FormGroup>
<!-- dnsimple -->
<FormGroup v-if="provider === 'dnsimple'">
<label for="dnsimpleAccessTokenInput">{{ $t('domains.domainDialog.dnsimpleAccessToken') }}</label>
<TextInput id="dnsimpleAccessTokenInput" v-model="dnsConfig.accessToken" required />
<MaskedInput id="dnsimpleAccessTokenInput" v-model="dnsConfig.accessToken" required />
</FormGroup>
<!-- Hetzner -->
<FormGroup v-if="provider === 'hetzner'">
<FormGroup v-if="provider === 'hetzner' || provider === 'hetznercloud'">
<label for="hetznerTokenInput">{{ $t('domains.domainDialog.hetznerToken') }}</label>
<TextInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
<MaskedInput id="hetznerTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
<!-- Vultr -->
<FormGroup v-if="provider === 'vultr'">
<label for="vultrTokenInput">{{ $t('domains.domainDialog.vultrToken') }}</label>
<TextInput id="vultrTokenInput" v-model="dnsConfig.token" required />
<MaskedInput id="vultrTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
<!-- deSEC -->
<FormGroup v-if="provider === 'desec'">
<label for="deSecTokenInput">{{ $t('domains.domainDialog.deSecToken') }}</label>
<TextInput id="deSecTokenInput" v-model="dnsConfig.token" required />
<MaskedInput id="deSecTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
<!-- Name.com -->
@@ -281,7 +285,7 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'namecom'">
<label for="nameComTokenInput">{{ $t('domains.domainDialog.nameComApiToken') }}</label>
<TextInput id="nameComTokenInput" v-model="dnsConfig.token" required />
<MaskedInput id="nameComTokenInput" v-model="dnsConfig.token" required />
</FormGroup>
<!-- Namecheap -->
@@ -291,9 +295,9 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'namecheap'">
<label for="namecheapApiKeyInput">{{ $t('domains.domainDialog.namecheapApiKey') }}</label>
<TextInput id="namecheapApiKeyInput" v-model="dnsConfig.token" required />
<MaskedInput id="namecheapApiKeyInput" v-model="dnsConfig.token" required />
<div class="warning-label" v-if="provider === 'namecheap'" v-html="$t('domains.domainDialog.namecheapInfo')"></div>
</FormGroup>
<div class="warning-label" v-if="provider === 'namecheap'" v-html="$t('domains.domainDialog.namecheapInfo')"></div>
<!-- INWX -->
<FormGroup v-if="provider === 'inwx'">
@@ -302,17 +306,22 @@ function onGcdnsFileInputChange(event) {
</FormGroup>
<FormGroup v-if="provider === 'inwx'">
<label for="inwxPasswordInput">{{ $t('domains.domainDialog.inwxPassword') }}</label>
<TextInput id="inwxPasswordInput" v-model="dnsConfig.password" required />
<MaskedInput id="inwxPasswordInput" v-model="dnsConfig.password" required />
</FormGroup>
<FormGroup v-if="showAdvanced">
<label>Certificate Provider <sup><a href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name"/>
<label 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>
<div class="warning-label" v-show="provider === 'wildcard'" v-html="$t('domains.domainDialog.wildcardInfo', { domain: domain })"></div>
<div class="warning-label" v-show="provider === 'manual'" v-html="$t('domains.domainDialog.manualInfo')"></div>
<div class="warning-label" v-show="needsPort80(provider, tlsProvider)" v-html="$t('domains.domainDialog.letsEncryptInfo')"></div>
<Checkbox v-if="showAdvanced" v-model="customNameservers" :label="$t('domains.domainDialog.customNameservers')" />
<FormGroup v-if="showAdvanced">
<label>Certificate provider <sup><a href="https://docs.cloudron.io/domains#certificates" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect v-model="tlsProvider" :options="tlsProviders" option-key="value" option-label="name" required/>
</FormGroup>
</div>
</template>
+7 -4
View File
@@ -9,10 +9,12 @@ const props = defineProps({
helpUrl: { type: String, required: false },
value: { type: String, required: true },
disabled: { type: Boolean, default: false },
required: { type: Boolean, default: false },
saving: { type: Boolean, default: false },
multiline: { type: Boolean, default: false },
markdown: { type: Boolean, default: false },
rows: { type: Number, default: 2 },
maxlength: { type: Number, default: -1 },
});
const emit = defineEmits(['save']);
@@ -41,6 +43,7 @@ function startEdit() {
}
function save() {
if (props.required && !draftValue.value) return;
emit('save', draftValue.value);
}
@@ -54,13 +57,13 @@ function cancel() {
<FormGroup>
<label>{{ label }} <sup v-if="helpUrl"><a :href="helpUrl" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-if="editing" style="display: flex; align-items: center; gap: 6px">
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving"/>
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving"></textarea>
<Button tool @click="save" :disabled="saving">{{ $t('main.dialog.save') }}</Button>
<TextInput v-if="!multiline" ref="textInput" v-model="draftValue" @keydown.enter="save()" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"/>
<textarea v-else ref="textInput" :rows="rows" cols="80" v-model="draftValue" :disabled="saving" :required="required ? true : null" :maxlength="maxlength === -1 ? null : maxlength"></textarea>
<Button tool @click="save" :disabled="saving || (required && !draftValue)">{{ $t('main.dialog.save') }}</Button>
<Button tool plain secondary @click="cancel" :disabled="saving">{{ $t('main.dialog.cancel') }}</Button>
</div>
<div v-else>
<div v-if="markdown" v-html="marked.parse(value)"></div>
<div v-if="markdown" v-html="marked.parseInline(value)"></div>
<div v-else>{{ value }}</div>
</div>
</FormGroup>
@@ -0,0 +1,140 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Button, ClipboardAction, Dialog, TextInput, InputGroup, FormGroup } from '@cloudron/pankow';
import { startRegistration } from '@simplewebauthn/browser';
import ProfileModel from '../models/ProfileModel.js';
const props = defineProps({
mandatory2FA: { type: Boolean, default: false },
has2FA: { type: Boolean, default: false }
});
const emit = defineEmits([ 'success' ]);
const profileModel = ProfileModel.create();
const dialog = useTemplateRef('dialog');
const setupMode = ref('');
const totpSecret = ref('');
const totpToken = ref('');
const totpQRCode = ref('');
const totpEnableError = ref('');
const passkeyRegisterError = ref('');
const passkeyRegisterBusy = ref(false);
async function onTotpEnable() {
const [error] = await profileModel.enableTotp(totpToken.value);
if (error) {
totpToken.value = '';
return totpEnableError.value = error.body ? error.body.message : 'Internal error';
}
emit('success');
dialog.value.close();
}
async function onRegisterPasskey() {
passkeyRegisterBusy.value = true;
passkeyRegisterError.value = '';
try {
const [optionsError, options] = await profileModel.getPasskeyRegistrationOptions();
if (optionsError) {
passkeyRegisterError.value = optionsError.body?.message || 'Failed to get registration options';
passkeyRegisterBusy.value = false;
return;
}
const credential = await startRegistration({ optionsJSON: options });
const [registerError] = await profileModel.registerPasskey(credential, 'Cloudron');
if (registerError) {
passkeyRegisterError.value = registerError.body?.message || 'Failed to register passkey';
passkeyRegisterBusy.value = false;
return;
}
emit('success');
dialog.value.close();
} catch (error) {
passkeyRegisterError.value = error.message || 'Passkey registration failed';
}
passkeyRegisterBusy.value = false;
}
async function loadTotpSecret() {
const [error, result] = await profileModel.setTotpSecret();
if (error) return console.error(error);
totpSecret.value = result.secret;
totpQRCode.value = result.qrcode;
}
defineExpose({
async open(method) {
setupMode.value = method || 'passkey';
totpEnableError.value = '';
totpToken.value = '';
passkeyRegisterError.value = '';
dialog.value.open();
if (setupMode.value === 'totp') await loadTotpSecret();
},
close() {
dialog.value.close();
}
});
async function switchMode(mode) {
setupMode.value = mode;
if (mode === 'totp' && !totpSecret.value) await loadTotpSecret();
}
</script>
<template>
<Dialog ref="dialog" :title="setupMode === 'totp' ? $t('profile.enableTotp.title') : $t('profile.enablePasskey.title')" :dismissable="!props.mandatory2FA || props.has2FA">
<div>
<p class="text-warning" v-if="props.mandatory2FA && !props.has2FA">{{ $t('profile.enable2FA.mandatorySetup') }}</p>
<!-- Passkey Setup -->
<div v-if="setupMode === 'passkey'">
<p v-html="$t('profile.enable2FA.passkeyDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
<div style="text-align: center;">
<Button @click="onRegisterPasskey()" :loading="passkeyRegisterBusy" :disabled="passkeyRegisterBusy">{{ $t('profile.enable2FA.registerPasskey') }}</Button>
<div class="error-label" v-if="passkeyRegisterError">{{ passkeyRegisterError }}</div>
</div>
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
<a href="#" @click.prevent="switchMode('totp')">{{ $t('profile.enable2FA.switchToTotp') }}</a>
</p>
</div>
<!-- TOTP Setup -->
<div v-if="setupMode === 'totp'">
<p v-html="$t('profile.enable2FA.authenticatorAppDescription', { googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395'})"></p>
<div style="text-align: center;">
<img :src="totpQRCode" style="border-radius: 10px; margin-bottom: 10px"/>
<small>{{ totpSecret }} <ClipboardAction plain :value="totpSecret"/></small>
</div>
<br/>
<form @submit.prevent="onTotpEnable()">
<input type="submit" style="display: none;" :disabled="!totpToken"/>
<FormGroup style="text-align: left;">
<label for="totpTokenInput">{{ $t('profile.enable2FA.token') }}</label>
<InputGroup>
<TextInput v-model="totpToken" id="totpTokenInput" style="flex-grow: 1;"/>
<Button @click="onTotpEnable()" :disabled="!totpToken">{{ $t('profile.enable2FA.enable') }}</Button>
</InputGroup>
<div class="error-label" v-if="totpEnableError">{{ totpEnableError }}</div>
</FormGroup>
</form>
<p v-if="props.mandatory2FA && !props.has2FA" style="text-align: center; margin-top: 15px;">
<a href="#" @click.prevent="switchMode('passkey')">{{ $t('profile.enable2FA.switchToPasskey') }}</a>
</p>
</div>
</div>
</Dialog>
</template>
+280
View File
@@ -0,0 +1,280 @@
<script setup>
import { ref, computed, onMounted, watch, nextTick, useTemplateRef } from 'vue';
import { Button, TextInput, MultiSelect, Popover, FormGroup, DateTimeInput } from '@cloudron/pankow';
import { useDebouncedRef, prettyLongDate } from '@cloudron/pankow/utils';
import AppsModel from '../models/AppsModel.js';
import { eventlogDetails, eventlogSource } from '../utils.js';
const props = defineProps({
fetchPage: { type: Function, required: true },
availableActions: { type: Array, default: () => [] },
app: { type: Object, default: null },
showToolbar: { type: Boolean, default: true },
});
const appsModel = AppsModel.create();
const apps = ref([]);
const eventlogs = ref([]);
const refreshBusy = ref(false);
const page = ref(1);
const perPage = ref(100);
const eventlogContainer = useTemplateRef('eventlogContainer');
const actions = ref([]);
const highlight = useDebouncedRef('', 300);
const currentMatchPosition = ref(-1);
const searching = ref(false);
const SEARCH_LOOKAHEAD_PAGES = 5;
const filterFrom = ref('');
const filterTo = ref('');
const dateFilterPopover = useTemplateRef('dateFilterPopover');
const dateFilterButton = useTemplateRef('dateFilterButton');
function getApp(id) {
return apps.value.find(a => a.id === id);
}
function processEvent(e) {
const app = props.app || (e.data?.appId ? getApp(e.data.appId) : null);
return {
id: Symbol(),
raw: e,
details: eventlogDetails(e, app, props.app?.id || ''),
source: eventlogSource(e, app),
};
}
function isMatch(eventlog, term) {
if (!term) return false;
const t = term.toLowerCase();
if (eventlog.source.toLowerCase().includes(t)) return true;
if (eventlog.details.replace(/<[^>]+>/g, '').toLowerCase().includes(t)) return true;
if (JSON.stringify(eventlog.raw.data).toLowerCase().includes(t)) return true;
if (eventlog.raw.action.toLowerCase().includes(t)) return true;
return false;
}
const matchIndices = computed(() => {
if (!highlight.value) return [];
return eventlogs.value.reduce((acc, e, i) => {
if (isMatch(e, highlight.value)) acc.push(i);
return acc;
}, []);
});
function scrollToIndex(idx) {
const el = eventlogContainer.value?.querySelector(`[data-index="${idx}"]`);
if (el) el.scrollIntoView({ behavior: 'instant', block: 'center' });
}
function goToPrevMatch() {
if (currentMatchPosition.value > 0) {
currentMatchPosition.value--;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
}
}
async function goToNextMatch() {
if (!highlight.value || searching.value) return;
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
currentMatchPosition.value++;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
return;
}
searching.value = true;
let endOfLog = false;
for (let i = 0; i < SEARCH_LOOKAHEAD_PAGES; i++) {
const prevLength = eventlogs.value.length;
await fetchMore();
if (eventlogs.value.length === prevLength) { endOfLog = true; break; }
if (currentMatchPosition.value + 1 < matchIndices.value.length) {
currentMatchPosition.value++;
scrollToIndex(matchIndices.value[currentMatchPosition.value]);
searching.value = false;
return;
}
}
searching.value = false;
if (endOfLog) window.pankow.notify({ text: `No more matches for "${highlight.value}".`, timeout: 3000 });
else window.pankow.notify({ text: `No match found for "${highlight.value}" in ${eventlogs.value.length} entries. Click next to keep searching.`, timeout: 3000 });
}
function buildFilter() {
const filter = {};
if (actions.value.length) filter.actions = actions.value.join(',');
if (filterFrom.value) filter.from = new Date(filterFrom.value + 'T00:00:00').toISOString();
if (filterTo.value) filter.to = new Date(filterTo.value + 'T23:59:59.999').toISOString();
return filter;
}
async function onRefresh() {
highlight.value = '';
refreshBusy.value = true;
page.value = 1;
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
if (error) return console.error(error);
eventlogs.value = result.map(processEvent);
refreshBusy.value = false;
}
async function fetchMore() {
page.value++;
const [error, result] = await props.fetchPage(buildFilter(), page.value, perPage.value);
if (error) return console.error(error);
eventlogs.value = eventlogs.value.concat(result.map(processEvent));
}
async function onScroll(event) {
if (event.target.scrollTop + event.target.clientHeight >= event.target.scrollHeight) await fetchMore();
}
function onOpenDateFilter(event) {
dateFilterPopover.value.open(event, dateFilterButton.value.$el || dateFilterButton.value);
}
watch(actions.value, onRefresh);
watch(filterFrom, onRefresh);
watch(filterTo, onRefresh);
watch(highlight, async () => {
if (matchIndices.value.length > 0) {
currentMatchPosition.value = 0;
await nextTick();
scrollToIndex(matchIndices.value[0]);
} else {
currentMatchPosition.value = -1;
if (highlight.value) goToNextMatch();
}
});
onMounted(async () => {
if (!props.app) {
const [error, result] = await appsModel.list();
if (error) console.error(error);
else apps.value = result;
}
onRefresh();
while (eventlogContainer.value && eventlogContainer.value.scrollHeight <= eventlogContainer.value.offsetHeight) {
await fetchMore();
}
});
function setHighlight(value) { highlight.value = value; }
defineExpose({ refresh: onRefresh, setHighlight });
</script>
<template>
<div style="overflow: hidden; display: flex; flex-direction: column; height: 100%;">
<div v-if="showToolbar" style="display: flex; align-items: center; gap: 5px; flex-wrap: wrap; padding-bottom: 10px; justify-content: flex-end;">
<TextInput placeholder="Highlight..." v-model="highlight" @keydown.enter="goToNextMatch()"/>
<Button tool plain :disabled="!highlight || currentMatchPosition <= 0 || searching" @click="goToPrevMatch()" icon="fa-solid fa-chevron-up" />
<Button tool plain :disabled="!highlight || searching" :loading="searching" @click="goToNextMatch()" icon="fa-solid fa-chevron-down" />
<Button tool secondary ref="dateFilterButton" @click="onOpenDateFilter($event)" :icon="(filterFrom || filterTo) ? 'fa-solid fa-calendar-check' : 'fa-solid fa-calendar'" />
<MultiSelect v-if="availableActions.length" :search-threshold="10" v-model="actions" :options="availableActions" option-label="label" option-key="id" :selected-label="actions.length ? $t('main.multiselect.selected', { n: actions.length }) : $t('eventlog.filterAllEvents')"/>
<Button tool secondary @click="onRefresh()" :loading="refreshBusy" icon="fa-solid fa-sync-alt" />
</div>
<Popover ref="dateFilterPopover" width="300px">
<div style="padding: 15px; display: flex; flex-direction: column; gap: 10px;">
<FormGroup>
<label>From</label>
<DateTimeInput date-only v-model="filterFrom" :max="filterTo || undefined" />
</FormGroup>
<FormGroup>
<label>To</label>
<DateTimeInput date-only v-model="filterTo" :min="filterFrom || undefined" />
</FormGroup>
</div>
</Popover>
<div ref="eventlogContainer" class="section-body" style="overflow-y: auto; overflow-x: hidden; flex: 1;" @scroll="onScroll">
<table class="eventlog-table">
<thead>
<tr>
<th style="width: 190px;">{{ $t('eventlog.time') }}</th>
<th style="width: 100px;">{{ $t('eventlog.source') }}</th>
<th>{{ $t('eventlog.details') }}</th>
<th style="width: 40px;"></th>
</tr>
</thead>
<tbody>
<template v-for="(eventlog, index) in eventlogs" :key="eventlog.id">
<tr :data-index="index" :class="{ 'active': eventlog.isOpen, 'eventlog-match': highlight && isMatch(eventlog, highlight), 'eventlog-match-current': matchIndices[currentMatchPosition] === index }" @click="eventlog.isOpen = !eventlog.isOpen">
<td>{{ prettyLongDate(eventlog.raw.creationTime) }}</td>
<td class="eventlog-source">{{ eventlog.source }}</td>
<td v-html="eventlog.details"></td>
<td><Button v-if="eventlog.raw.data.taskId" @click.stop plain small tool :href="app ? `/logs.html?appId=${app.id}&taskId=${eventlog.raw.data.taskId}` : `/logs.html?taskId=${eventlog.raw.data.taskId}`" target="_blank">Logs</Button></td>
</tr>
<tr v-show="eventlog.isOpen">
<td colspan="4" class="eventlog-details" @click.stop>
<pre>{{ JSON.stringify(eventlog.raw.data, null, 4) }}</pre>
</td>
</tr>
</template>
</tbody>
</table>
</div>
</div>
</template>
<style scoped>
.eventlog-table {
width: 100%;
border-spacing: 0;
table-layout: fixed;
}
.eventlog-table th {
text-align: left;
}
.eventlog-table th,
.eventlog-table td {
padding: 6px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.eventlog-table tbody tr.active,
.eventlog-table tbody tr:hover {
background-color: var(--pankow-color-background-hover);
}
.eventlog-source {
font-weight: var(--pankow-font-weight-bold);
}
.eventlog-details {
background-color: color-mix(in oklab, var(--pankow-color-background-hover), black 5%);
position: relative;
}
.eventlog-details pre {
white-space: pre-wrap;
font-size: 13px;
padding-left: 10px;
margin: 0;
}
.eventlog-table tbody tr.eventlog-match {
background-color: rgba(255, 193, 7, 0.15);
}
.eventlog-table tbody tr.eventlog-match-current {
background-color: rgba(255, 193, 7, 0.35);
}
</style>
+74 -64
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, useTemplateRef, onMounted, computed, inject } from 'vue';
import { Dialog, Button, Icon, FormGroup, SingleSelect, Checkbox, TextInput, ProgressBar, PasswordInput } from '@cloudron/pankow';
import { ref, useTemplateRef, onMounted, inject } from 'vue';
import { Dialog, Button, Icon, FormGroup, MaskedInput, SingleSelect, Checkbox, TextInput, ProgressBar } from '@cloudron/pankow';
import { prettyLongDate } from '@cloudron/pankow/utils';
import Section from './Section.vue';
import UserDirectoryModel from '../models/UserDirectoryModel.js';
@@ -46,15 +46,26 @@ const bindDn = ref('');
const bindPassword = ref('');
const autoCreate = ref(false);
const isValid = computed(() => {
if (!provider.value) return false;
if (!url.value) return false;
if (!baseDn.value) return false;
if (!filter.value) return false;
if (bindDn.value && !bindPassword.value) return false;
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
}
return true;
});
function onProviderChange() {
url.value = '';
acceptSelfSignedCerts.value = false;
baseDn.value = '';
filter.value = '';
usernameField.value = '';
syncGroups.value = false;
groupBaseDn.value = '';
groupFilter.value = '';
groupnameField.value = '';
bindDn.value = '';
bindPassword.value = '';
autoCreate.value = false;
}
async function onConfigure() {
if (!features.value.externalLdap) return subscriptionRequiredDialog.value.open();
@@ -76,10 +87,12 @@ async function onConfigure() {
autoCreate.value = config.value.autoCreate;
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 = {};
@@ -91,7 +104,7 @@ async function onSubmit() {
config.acceptSelfSignedCerts = acceptSelfSignedCerts.value;
config.autoCreate = autoCreate.value;
config.syncGroups = syncGroups.value;
config.bindPassword = bindPassword.value;
if (bindPassword.value) config.bindPassword = bindPassword.value;
// those values are known and thus overwritten
config.baseDn = 'ou=users,dc=cloudron';
@@ -115,7 +128,7 @@ async function onSubmit() {
if (bindDn.value) {
config.bindDn = bindDn.value;
config.bindPassword = bindPassword.value;
if (bindPassword.value) config.bindPassword = bindPassword.value;
}
}
@@ -230,7 +243,7 @@ onMounted(async () => {
:title="$t('users.externalLdapDialog.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editBusy"
:confirm-active="!editBusy && isValid"
:confirm-active="!editBusy && isFormValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
@@ -239,70 +252,67 @@ onMounted(async () => {
<FormGroup>
<label for="ldapProvider">{{ $t('users.externalLdap.provider') }} <sup><a href="https://docs.cloudron.io/user-directory/#external-directory" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="ldapProvider" v-model="provider" :options="availableProviders" option-key="value" option-label="name" />
<SingleSelect id="ldapProvider" @select="onProviderChange" v-model="provider" :options="availableProviders" option-key="value" option-label="name"/>
<div class="warning-label" v-show="provider === 'noop' && config.provider !== 'noop'">{{ $t('users.externalLdap.disableWarning') }}</div>
</FormGroup>
<p class="text-small text-warning" v-show="provider === 'noop' && config.provider !== 'noop'">{{ $t('users.externalLdap.disableWarning') }}</p>
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
<fieldset :disabled="editBusy" v-if="provider !== 'noop'">
<input style="display: none" type="submit" />
<div v-show="provider !== 'noop'">
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="editBusy">
<input style="display: none" type="submit" :disabled="editBusy || !isValid" />
<FormGroup :class="{ 'has-error': editError.url }">
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
<TextInput id="configUrlInput" v-model="url" placeholder="ldaps://example.com:636" required/>
</FormGroup>
<FormGroup :class="{ 'has-error': editError.url }">
<label class="control-label" for="configUrlInput">{{ $t('users.externalLdap.server') }}</label>
<TextInput id="configUrlInput" v-model="url" placeholder="ldaps://example.com:636" />
</FormGroup>
<Checkbox v-model="acceptSelfSignedCerts" :label="$t('users.externalLdap.acceptSelfSignedCert')" />
<p class="has-error" v-show="editError.acceptSelfSignedCerts">{{ $t('users.externalLdap.errorSelfSignedCert') }}</p>
<Checkbox v-model="acceptSelfSignedCerts" :label="$t('users.externalLdap.acceptSelfSignedCert')" />
<p class="has-error" v-show="editError.acceptSelfSignedCerts">{{ $t('users.externalLdap.errorSelfSignedCert') }}</p>
<FormGroup :class="{ 'has-error': editError.baseDn }" v-if="provider !== 'cloudron'">
<label for="baseDnInput">{{ $t('users.externalLdap.baseDn') }}</label>
<TextInput v-model="baseDn" id="baseDnInput" placeholder="ou=users,dc=example,dc=com" required />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.baseDn }" v-show="provider !== 'cloudron'">
<label for="baseDnInput">{{ $t('users.externalLdap.baseDn') }}</label>
<TextInput v-model="baseDn" id="baseDnInput" placeholder="ou=users,dc=example,dc=com" :required="provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.filter }" v-if="provider !== 'cloudron'">
<label for="filterInput">{{ $t('users.externalLdap.filter') }}</label>
<TextInput v-model="filter" id="filterInput" placeholder="(objectClass=inetOrgPerson)" required />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.filter }" v-show="provider !== 'cloudron'">
<label for="filterInput">{{ $t('users.externalLdap.filter') }}</label>
<TextInput v-model="filter" id="filterInput" placeholder="(objectClass=inetOrgPerson)" :required="provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.usernameField }" v-if="provider !== 'cloudron'">
<label for="usernameFieldInput">{{ $t('users.externalLdap.usernameField') }}</label>
<TextInput v-model="usernameField" id="usernameFieldInput" placeholder="uid or sAMAcountName" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.usernameField }" v-show="provider !== 'cloudron'">
<label for="usernameFieldInput">{{ $t('users.externalLdap.usernameField') }}</label>
<TextInput v-model="usernameField" id="usernameFieldInput" placeholder="uid or sAMAcountName" />
</FormGroup>
<Checkbox v-model="syncGroups" :label="$t('users.externalLdap.syncGroups')" />
<Checkbox v-model="syncGroups" :label="$t('users.externalLdap.syncGroups')" />
<FormGroup :class="{ 'has-error': editError.groupBaseDn }" v-if="syncGroups && provider !== 'cloudron'">
<label for="groupBaseDnInput">{{ $t('users.externalLdap.groupBaseDn') }}</label>
<TextInput v-model="groupBaseDn" id="groupBaseDnInput" placeholder="ou=groups,dc=example,dc=com" required />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.groupBaseDn }" v-show="syncGroups && provider !== 'cloudron'">
<label for="groupBaseDnInput">{{ $t('users.externalLdap.groupBaseDn') }}</label>
<TextInput v-model="groupBaseDn" id="groupBaseDnInput" placeholder="ou=groups,dc=example,dc=com" :required="syncGroups && provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.groupFilter }" v-if="syncGroups && provider !== 'cloudron'">
<label for="groupFilterInput">{{ $t('users.externalLdap.groupFilter') }}</label>
<TextInput v-model="groupFilter" id="groupFilterInput" placeholder="(objectClass=groupOfNames)" required />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.groupFilter }" v-show="syncGroups && provider !== 'cloudron'">
<label for="groupFilterInput">{{ $t('users.externalLdap.groupFilter') }}</label>
<TextInput v-model="groupFilter" id="groupFilterInput" placeholder="(objectClass=groupOfNames)" :required="syncGroups && provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.groupnameField }" v-if="syncGroups && provider !== 'cloudron'">
<label for="groupnameFieldInput">{{ $t('users.externalLdap.groupnameField') }}</label>
<TextInput v-model="groupnameField" id="groupnameFieldInput" placeholder="cn" required />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.groupnameField }" v-show="syncGroups && provider !== 'cloudron'">
<label for="groupnameFieldInput">{{ $t('users.externalLdap.groupnameField') }}</label>
<TextInput v-model="groupnameField" id="groupnameFieldInput" placeholder="cn" :required="syncGroups && provider !== 'cloudron'" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.credentials }" v-if="provider !== 'cloudron'">
<label for="bindDnInput">{{ $t('users.externalLdap.bindUsername') }}</label>
<TextInput v-model="bindDn" id="bindDnInput" placeholder="uid=admin,ou=Users,dc=example,dc=com" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.credentials }" v-show="provider !== 'cloudron'">
<label for="bindDnInput">{{ $t('users.externalLdap.bindUsername') }}</label>
<TextInput v-model="bindDn" id="bindDnInput" placeholder="uid=admin,ou=Users,dc=example,dc=com" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.credentials }">
<label for="bindPasswordInput">{{ $t('users.externalLdap.bindPassword') }}</label>
<MaskedInput v-model="bindPassword" id="bindPasswordInput" />
</FormGroup>
<FormGroup :class="{ 'has-error': editError.credentials }">
<label for="bindPasswordInput">{{ $t('users.externalLdap.bindPassword') }}</label>
<PasswordInput v-model="bindPassword" id="bindPasswordInput" />
</FormGroup>
<Checkbox v-model="autoCreate" :label="$t('users.externalLdap.autocreateUsersOnLogin')" />
</fieldset>
</form>
</div>
<Checkbox v-model="autoCreate" :label="$t('users.externalLdap.autocreateUsersOnLogin')" />
</fieldset>
</form>
</Dialog>
<Section :title="$t('users.externalLdap.title')" :title-badge="!features.externalLdap ? 'Upgrade' : ''">
@@ -1,9 +1,9 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
import { isValidEmail } from '@cloudron/pankow/utils';
import ProfileModel from '../../models/ProfileModel.js';
import ProfileModel from '../models/ProfileModel.js';
const emit = defineEmits([ 'success' ]);
@@ -15,15 +15,18 @@ const busy = ref (false);
const email = ref('');
const password = ref('');
const isFormValid = computed(() => {
if (email.value && !isValidEmail(email.value)) return false;
if (!password.value) return false;
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
return true;
});
if (isFormValid.value) {
if (!isValidEmail(email.value)) isFormValid.value = false;
}
}
async function onSubmit() {
if (!isFormValid.value) return;
if (!form.value.reportValidity()) return;
busy.value = true;
formError.value = {};
@@ -56,6 +59,8 @@ defineExpose({
busy.value = false;
formError.value = {};
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -73,21 +78,21 @@ defineExpose({
reject-style="secondary"
@confirm="onSubmit"
>
<form @submit.prevent="onSubmit">
<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>
+23 -1
View File
@@ -1,6 +1,7 @@
<script setup>
import { ref, useTemplateRef, onMounted } from 'vue';
import { RouterView } from 'vue-router';
import { fetcher } from '@cloudron/pankow';
import OfflineOverlay from '../components/OfflineOverlay.vue';
import ProfileModel from '../models/ProfileModel.js';
@@ -9,6 +10,7 @@ const profileModel = ProfileModel.create();
const offlineOverlay = useTemplateRef('offlineOverlay');
const ready = ref(false);
const serviceDown = ref(false);
function onOnline() {
ready.value = true;
@@ -24,6 +26,9 @@ fetcher.globalOptions.errorHook = (error) => {
// re-login will make the code get a new token
if (error.status === 401) return profileModel.logout();
// if sftp addon is down, tell the user
if (error.status === 424) return serviceDown.value = true;
if (error.status === 500 || error.status === 501) {
// actual internal server error, most likely a bug or timeout log to console only to not alert the user
if (!ready.value) {
@@ -48,6 +53,23 @@ onMounted(() => {
<template>
<div style="height: 100%;">
<OfflineOverlay ref="offlineOverlay" @online="onOnline()"/>
<router-view v-if="ready"></router-view>
<div v-if="ready && serviceDown" class="service-down">
<div>
Unable to connect to filemanager service. Check the status and logs in <a href="/#/services">Services view</a>.
</div>
</div>
<router-view v-if="ready" v-show="!serviceDown"></router-view>
</div>
</template>
<style scoped>
.service-down {
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
}
</style>
+8 -6
View File
@@ -113,15 +113,16 @@ onMounted(async () => {
@confirm="onBlocklistSubmit()"
>
<div>
<p class="small">{{ $t('network.firewall.configure.description') }}</p>
<div class="small">{{ $t('network.firewall.configure.description') }}</div>
<br/>
<form novalidate @submit.prevent="onBlocklistSubmit()" autocomplete="off">
<fieldset :disabled="editBlocklistBusy">
<input style="display: none" type="submit" :disabled="editBlocklistBusy || !isBlocklistValid"/>
<FormGroup>
<label for="blocklistInput">{{ $t('network.firewall.blockedIpRanges') }}</label>
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
<div class="has-error" v-show="editBlocklistError">{{ editBlocklistError }}</div>
<textarea id="blocklistInput" v-model="editBlocklist" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
<textarea id="blocklistInput" v-model="editBlocklist" rows="4"></textarea>
</FormGroup>
</fieldset>
</form>
@@ -138,15 +139,16 @@ onMounted(async () => {
@confirm="onTrustedIpsSubmit()"
>
<div>
<p class="small">{{ $t('network.trustedIps.description') }}</p>
<div class="small">{{ $t('network.trustedIps.description') }}</div>
<br/>
<form novalidate @submit.prevent="onTrustedIpsSubmit()" autocomplete="off">
<fieldset :disabled="editTrustedIpsBusy">
<input style="display: none;" type="submit" :disabled="editTrustedIpsBusy || !isTrustedIpsValid"/>
<FormGroup>
<label for="">{{ $t('network.trustedIpRanges') }}</label>
<div description>{{ $t('network.firewall.configure.blocklistPlaceholder') }}</div>
<div class="has-error" v-show="editTrustedIpsError">{{ editTrustedIpsError }}</div>
<textarea v-model="editTrustedIps" :placeholder="$t('network.firewall.configure.blocklistPlaceholder')" rows="4"></textarea>
<textarea v-model="editTrustedIps" rows="4"></textarea>
</FormGroup>
</fieldset>
</form>
+109 -68
View File
@@ -4,9 +4,9 @@ import { useI18n } from 'vue-i18n';
const i18n = useI18n();
const t = i18n.t;
import { ref, onMounted, computed, useTemplateRef } from 'vue';
import { ref, onMounted, useTemplateRef } from 'vue';
import { useRouter, useRoute, onBeforeRouteUpdate } from 'vue-router';
import { fetcher, Dialog, DirectoryView, TopBar, Breadcrumb, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
import { fetcher, Dialog, DirectoryView, TreeView, TopBar, Button, InputDialog, MainLayout, ButtonGroup, Notification, FileUploader, Spinner } from '@cloudron/pankow';
import { sanitize, sleep } from '@cloudron/pankow/utils';
import { API_ORIGIN, BASE_URL, ISTATES } from '../constants.js';
import PreviewPanel from '../components/PreviewPanel.vue';
@@ -33,7 +33,6 @@ const extractInProgressDialog = useTemplateRef('extractInProgressDialog');
const busy = ref(true);
const fallbackIcon = ref(`${BASE_URL}mime-types/none.svg`);
const cwd = ref('/');
const busyRefresh = ref(false);
const busyRestart = ref(false);
const fatalError = ref(false);
const activeItem = ref(null);
@@ -64,36 +63,10 @@ const uploadMenuModel = [{
action: onUploadFile,
}, {
icon: 'fa-regular fa-folder-open',
label: t('filemanager.toolbar.newFolder'),
label: t('filemanager.toolbar.uploadFolder'),
action: onUploadFolder,
}];
const breadcrumbHomeItem = {
label: '/app/data/',
action: () => onActivateBreadcrumb('/'),
};
const breadcrumbItems = computed(() => {
if (!cwd.value) return [];
const parts = cwd.value.split('/').filter((p) => !!p.trim());
const crumbs = [];
parts.forEach((p, i) => {
crumbs.push({
label: p,
action: () => onActivateBreadcrumb('/' + parts.slice(0, i+1).join('/')),
});
});
return crumbs;
});
// watch(() => {
// if (resourceType.value && resourceId.value) router.push(`/home/${resourceType.value}/${resourceId.value}${cwd.value}`);
// loadCwd();
// });
function onFatalError(errorMessage) {
fatalError.value = errorMessage;
fatalErrorDialog.value.open();
@@ -109,9 +82,10 @@ async function onNewFile() {
message: t('filemanager.newFileDialog.title'),
value: '',
required: true,
confirmStyle: 'success',
confirmStyle: 'primary',
confirmLabel: t('filemanager.newFileDialog.create'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!newFileName) return;
@@ -125,9 +99,10 @@ async function onNewFolder() {
message: t('filemanager.newDirectoryDialog.title'),
value: '',
required: true,
confirmStyle: 'success',
confirmStyle: 'primary',
confirmLabel: t('filemanager.newFileDialog.create'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!newFolderName) return;
@@ -153,14 +128,39 @@ function onSelectionChanged(items) {
selectedItems.value = items;
}
function onActivateBreadcrumb(path) {
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(path)}`);
function onTreeNavigate(event) {
router.push(`/home/${resourceType.value}/${resourceId.value}${sanitize(event.path)}`);
}
async function onTreeDrop(targetPath, event) {
// check if this is an internal pankow drag (files from DirectoryView)
if (event.dataTransfer.getData('application/x-pankow') === 'selected') {
const files = selectedItems.value;
if (!files || !files.length) return;
window.addEventListener('beforeunload', beforeUnloadListener, { capture: true });
pasteInProgressDialog.value.open();
try {
await directoryModel.paste(targetPath, 'cut', files);
} catch (e) {
window.pankow.notify({ type: 'danger', text: e, persistent: true });
}
await loadCwd();
window.removeEventListener('beforeunload', beforeUnloadListener, { capture: true });
pasteInProgressDialog.value.close();
}
}
function treeListFiles(path) {
if (!directoryModel) return [];
return directoryModel.listFiles(path);
}
async function onRefresh() {
busyRefresh.value = true;
await loadCwd();
setTimeout(() => { busyRefresh.value = false; }, 500);
}
// either dataTransfer (external drop) or files (internal drag)
@@ -175,37 +175,66 @@ async function onDrop(targetFolder, dataTransfer, files) {
});
}
async function readEntries(dirReader) {
return new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
function setRelativePath(file, entry) {
const relativePath = (entry.fullPath || entry.name || '').replace(/^\//, '');
if (relativePath) {
// trying with defineProperty() to better mimic native behavior adding a non-enumeratible property
try {
Object.defineProperty(file, 'webkitRelativePath', { value: relativePath });
} catch {
file.webkitRelativePath = relativePath;
}
}
}
// wrapper as chrome only returns files in batches of 100 entries
async function readAllEntries(dirReader) {
const all = [];
let batch;
do {
batch = await new Promise((resolve, reject) => {
dirReader.readEntries(resolve, reject);
});
all.push(...batch);
} while (batch.length > 0);
return all;
}
const fileList = [];
async function traverseFileTree(item) {
if (item.isFile) {
fileList.push(await getFile(item));
const file = await getFile(item);
setRelativePath(file, item);
fileList.push(file);
} else if (item.isDirectory) {
// Get folder contents
const dirReader = item.createReader();
const entries = await readEntries(dirReader);
const entries = await readAllEntries(dirReader);
for (const i in entries) {
await traverseFileTree(entries[i], item.name);
await traverseFileTree(entries[i]);
}
} else {
console.log('Skipping uknown file type', item);
console.log('Skipping unknown file type', item);
}
}
// collect all files to upload
for (const item of dataTransfer.items) {
const entry = item.webkitGetAsEntry();
const entry = item.webkitGetAsEntry ? item.webkitGetAsEntry() : null;
if (!entry) {
const file = item.getAsFile ? item.getAsFile() : null;
if (file) fileList.push(file);
continue;
}
if (entry.isFile) {
fileList.push(await getFile(entry));
const file = await getFile(entry);
setRelativePath(file, entry);
fileList.push(file);
} else if (entry.isDirectory) {
await traverseFileTree(entry, sanitize(`${cwd.value}/${targetFolder}`));
await traverseFileTree(entry);
}
}
@@ -239,8 +268,9 @@ async function deleteHandler(files) {
const confirmed = await inputDialog.value.confirm({
message: t('filemanager.removeDialog.reallyDelete'),
confirmStyle: 'danger',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no'),
confirmLabel: t('main.dialog.delete'),
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary'
});
if (!confirmed) return;
@@ -369,9 +399,9 @@ async function onRestartApp() {
const confirmed = await inputDialog.value.confirm({
message: t('filemanager.toolbar.restartApp') + '?',
confirmStyle: 'primary',
confirmLabel: t('main.dialog.yes'),
rejectLabel: t('main.dialog.no'),
confirmLabel: t('main.action.restart'),
confirmStyle: 'danger',
rejectLabel: t('main.dialog.cancel'),
rejectStyle: 'secondary',
});
@@ -443,7 +473,7 @@ onMounted(async () => {
}
appLink.value = `https://${result.body.fqdn}`;
title.value = `${result.body.label || result.body.fqdn} (${result.body.manifest.title})`;
title.value = `${result.body.label || result.body.fqdn} ` + (result.body.manifest ? `(${result.body.manifest.title})` : '');
} else if (type === 'volume') {
let error, result;
try {
@@ -506,8 +536,8 @@ onMounted(async () => {
<template #header>
<TopBar class="navbar">
<template #left>
<Button icon="fa-solid fa-arrow-rotate-right" :loading="busyRefresh" @click="onRefresh()" secondary plain tool style="margin-right: 10px"/>
<Breadcrumb :home="breadcrumbHomeItem" :items="breadcrumbItems" :activate-handler="onActivateBreadcrumb"/>
<a v-if="appLink" class="title" :href="appLink" target="_blank">{{ title }}</a>
<span v-else class="title">{{ title }}</span>
</template>
<template #right>
<ButtonGroup>
@@ -518,7 +548,7 @@ onMounted(async () => {
<Button style="margin: 0 20px;" v-tooltip="$t('filemanager.toolbar.restartApp')" secondary tool :loading="busyRestart" icon="fa-solid fa-arrows-rotate" @click="onRestartApp" v-show="resourceType === 'app'"/>
<ButtonGroup>
<Button :href="'/terminal.html?id=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
<Button :href="'/terminal.html?id=' + resourceId + '&cwd=/app/data' + cwd" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-terminal" v-tooltip="$t('terminal.title')" />
<Button :href="'/logs.html?appId=' + resourceId" target="_blank" v-show="resourceType === 'app'" secondary tool icon="fa-solid fa-align-left" v-tooltip="$t('logs.title')" />
</ButtonGroup>
</template>
@@ -526,6 +556,17 @@ onMounted(async () => {
</template>
<template #body>
<div class="main-view">
<div class="main-view-col tree-view-col">
<TreeView
v-if="!busy"
:list-files="treeListFiles"
:active-path="cwd"
:fallback-icon="fallbackIcon"
root-label="/app/data"
:drop-handler="onTreeDrop"
@navigate="onTreeNavigate"
/>
</div>
<div class="main-view-col">
<DirectoryView
class="directory-view"
@@ -545,6 +586,7 @@ onMounted(async () => {
:new-folder-handler="onNewFolder"
:upload-file-handler="onUploadFile"
:upload-folder-handler="onUploadFolder"
:refresh-handler="onRefresh"
:drop-handler="onDrop"
:items="items"
:owners-model="ownersModel"
@@ -553,10 +595,6 @@ onMounted(async () => {
/>
</div>
<div class="main-view-col" style="max-width: 300px;">
<div class="side-bar-title">
<a v-show="appLink" :href="appLink" target="_blank" class="no-highlight">{{ title }}</a>
<span v-show="!appLink">{{ title }}</span>
</div>
<PreviewPanel :item="activeItem || activeDirectoryItem" :fallback-icon="fallbackIcon"/>
</div>
</div>
@@ -583,17 +621,20 @@ onMounted(async () => {
padding: 0 10px;
}
.side-bar-title {
text-align: center;
font-size: 20px;
margin-bottom: 20px;
}
.main-view-col {
flex-grow: 1;
}
.no-highlight {
.tree-view-col {
flex-grow: 0;
flex-shrink: 0;
width: 250px;
border-right: 1px solid var(--pankow-input-border-color);
overflow: auto;
}
.title {
font-size: 20px;
color: var(--pankow-color-text);
}
+118 -60
View File
@@ -1,10 +1,9 @@
<script setup>
import moment from 'moment-timezone';
import { onMounted, onUnmounted, useTemplateRef, watch } from 'vue';
import Chart from 'chart.js/auto';
import { prettyDecimalSize, prettyLongDate, prettyShortDate } from '@cloudron/pankow/utils';
import { prettyDecimalSize, formatDate } from '@cloudron/pankow/utils';
import annotationPlugin from 'chartjs-plugin-annotation';
Chart.register(annotationPlugin);
@@ -21,13 +20,70 @@ let yscaleUnit = null;
const props = defineProps({
title: String,
subtext: String,
footer: String,
period: Object, // { hours, format, tooltpFormat }
yscale: String, // cpu, memory
memory: Number,
cpuCount: Number,
highMark: Number,
highMarkLabel: String,
});
function renderTooltip(context) {
// console.log(context); { chart, tooltip } tooltip has { title, body }
if (!tooltipElem.value) return;
const { /*chart, */ tooltip } = context;
if (tooltip.opacity === 0) {
tooltipElem.value.style.opacity = 0;
return;
}
// 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 => { return { label: item.lines }; });
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</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;
}
tooltipElem.value.style.opacity = 1;
tooltipElem.value.style.position = 'absolute';
tooltipElem.value.classList.remove('graphs-tooltip-caret-left', 'graphs-tooltip-caret-right');
if (tooltip.chart.width/2 < tooltip.caretX) {
tooltipElem.value.style.right = (tooltip.chart.width - tooltip.caretX) + 'px';
tooltipElem.value.style.left = 'unset';
tooltipElem.value.classList.add('graphs-tooltip-caret-right');
} else {
tooltipElem.value.style.right = 'unset';
tooltipElem.value.style.left = tooltip.caretX + 'px';
tooltipElem.value.classList.add('graphs-tooltip-caret-left');
}
tooltipElem.value.style.top = '0px';
tooltipElem.value.style.height = '100%';
}
function createGraphOptions({ yscale, period, highMark }) {
let startTime, endTime, stepSize, count; // x axis configuration values
@@ -52,56 +108,14 @@ function createGraphOptions({ yscale, period, highMark }) {
},
tooltip: {
enabled: false,
external: function(context) {
if (!tooltipElem.value) return;
const tooltipModel = context.tooltip;
if (tooltipModel.opacity === 0) {
tooltipElem.value.style.opacity = 0;
return;
}
// Set Text
if (tooltipModel.body) {
const titleLines = tooltipModel.title || [];
const bodyLines = tooltipModel.body.map(item => item.lines);
let innerHtml = `<div class="graphs-tooltip-title">${titleLines[0]}</div>`;
bodyLines.forEach(function(body, i) {
const colors = tooltipModel.labelColors[i];
innerHtml += `<div style="color: ${colors.borderColor}" class="graphs-tooltip-item">${body}</div>`;
});
tooltipElem.value.innerHTML = innerHtml;
}
tooltipElem.value.style.opacity = 1;
tooltipElem.value.style.position = 'absolute';
tooltipElem.value.classList.remove('graphs-tooltip-caret-left', 'graphs-tooltip-caret-right');
if (tooltipModel.chart.width/2 < tooltipModel.caretX) {
tooltipElem.value.style.right = (tooltipModel.chart.width - tooltipModel.caretX) + 'px';
tooltipElem.value.style.left = 'unset';
tooltipElem.value.classList.add('graphs-tooltip-caret-right');
} else {
tooltipElem.value.style.right = 'unset';
tooltipElem.value.style.left = tooltipModel.caretX + 'px';
tooltipElem.value.classList.add('graphs-tooltip-caret-left');
}
tooltipElem.value.style.top = 0 + 'px';
tooltipElem.value.style.height = '100%';
},
callbacks: {
title: (tooltipItem) => period.tooltipFormat === 'long' ? prettyLongDate(tooltipItem[0].raw.x) : prettyShortDate(tooltipItem[0].raw.x),
callbacks: { // passed as title,body to the tooltip renderer
title: (tooltipItem) => formatDate(period.tooltipFormat, tooltipItem[0].raw.x),
label: (tooltipItem) => {
const datasetLabel = tooltipItem.chart.data.datasets[tooltipItem.datasetIndex].label;
return `<span style="font-family: fixed">${yscale.ticks.callback(tooltipItem.raw.y)}</span>: ${datasetLabel}`;
return `${datasetLabel}: <span>${yscale.ticks.callback(tooltipItem.raw.y)}</span>`;
}
}
},
external: renderTooltip,
},
annotation: {
annotations: {
@@ -111,7 +125,18 @@ function createGraphOptions({ yscale, period, highMark }) {
yMin: highMark,
yMax: highMark,
borderColor: 'rgb(139, 0, 0)',
borderWidth: 0.5
borderWidth: 0.5,
label: {
display: true,
content: `Max ${props.title}`,
position: 'end', // 'start', 'center', or 'end'
backgroundColor: 'transparent',
color: 'rgb(139, 0, 0)',
yAdjust: -6,
font: {
size: 10
}
}
}
}
},
@@ -128,7 +153,7 @@ function createGraphOptions({ yscale, period, highMark }) {
maxRotation: 0, // don't rotate the labels
callback: function (value) {
if (period.hours === 0) return `${5-(value-this.min)/60000}min`;
return moment(value).format(period.tickFormat);
return formatDate(period.tickFormat, value);
},
count, // ignored when stepSize is set
stepSize // for realtime graph, generate steps of 1min and appropriate tick text
@@ -185,8 +210,10 @@ function pruneGraphData(dataset, options) {
}
function advance() {
graph.options.scales.x.min += LIVE_REFRESH_INTERVAL_MSECS;
graph.options.scales.x.max += LIVE_REFRESH_INTERVAL_MSECS;
// advance is called in a timer and when the browser tab is in the background , it is unreliable. Use absolute time to set the scale
const now = Date.now();
graph.options.scales.x.min = now - 5*60*1000;
graph.options.scales.x.max = now;
graph.update('none');
}
@@ -195,7 +222,6 @@ function pushData(datasetIndex, ...data) {
graph.data.datasets[datasetIndex+index].data.push(transformData(item));
pruneGraphData(graph.data.datasets[datasetIndex+index], graph.options);
}
graph.update('none');
}
function onPeriodChanged() {
@@ -301,17 +327,22 @@ defineExpose({
</script>
<template>
<div>
<div class="graph-container">
<label>{{ title }} <span class="pull-right text-small">{{ subtext }}</span></label>
<div class="graph">
<canvas ref="graphNode"></canvas>
<div ref="tooltipElem" class="graphs-tooltip"></div>
</div>
<div class="footer">{{ footer }}</div>
</div>
</template>
<style scoped>
.graph-container {
margin-bottom: 30px;
}
.graphs label {
margin: 16px 0;
}
@@ -319,7 +350,14 @@ defineExpose({
.graph {
position: relative;
width: 100%;
height: 160px;
height: 200px;
}
.footer {
margin-top: 10px;
text-align: center;
font-weight: var(--pankow-font-weight-bold);
font-size: 12px;
}
</style>
@@ -341,13 +379,33 @@ defineExpose({
border-right: 1px var(--pankow-color-primary) solid;
}
.graphs-tooltip-item,
.graphs-tooltip-title {
font-weight: bold;
padding: 2px;
padding-left: 10px;
padding-right: 10px;
background: rgba(255,255,255,0.8);
}
.graphs-tooltip-item {
padding: 2px 0px;
-webkit-text-stroke: 0.2px gray;
@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>
+13 -11
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';
@@ -53,6 +53,7 @@ async function onSubmit() {
defineExpose({
async open(g = null) {
g = g ? JSON.parse(JSON.stringify(g)) : null; // make a copy
group.value = g;
name.value = g ? g.name : '';
formError.value = {};
@@ -62,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();
}
@@ -78,7 +79,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="group ? $t('users.editGroupDialog.title', { name: group.name }) : $t('users.addGroupDialog.title')"
:title="group ? $t('users.editGroupDialog.title') : $t('users.addGroupDialog.title')"
:confirm-label="group ? $t('main.dialog.save') : $t('users.group.addGroupAction')"
:confirm-busy="busy"
:confirm-active="!busy && name !== ''"
@@ -102,13 +103,14 @@ defineExpose({
<FormGroup>
<label for="usersInput">{{ $t('users.group.users') }}</label>
<div v-if="group?.source"><span ng-repeat="user in groupEdit.selectedUsers"> {{ (user.username || user.email) }}</span></div>
<MultiSelect v-else v-model="users" :options="allUsers" option-key="id" :search-threshold="20"/>
<!-- membership of external groups cannot be edited -->
<div v-if="group?.source"><span v-for="userId of userIds" :key="userId" style="padding-right: 5px">{{ allUsers.find(u => u.id === userId)?.username || allUsers.find(u => u.id === userId)[userId]?.email }}</span></div>
<MultiSelect v-else v-model="userIds" :options="allUsers" option-key="id" :search-threshold="20"/>
</FormGroup>
<FormGroup>
<label for="appsInput">Access to Apps</label>
<MultiSelect v-model="apps" :options="allApps" option-key="id" :search-threshold="20"/>
<label for="appsInput">{{ $t('users.group.allowedApps') }}</label>
<MultiSelect v-model="appIds" :options="allApps" option-key="id" :search-threshold="20"/>
</FormGroup>
</fieldset>
</form>
+53 -120
View File
@@ -6,15 +6,14 @@ 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 { prettyDate, prettyLongDate } from '@cloudron/pankow/utils';
import NotificationsModel from '../models/NotificationsModel.js';
import { Menu, Popover, Icon, InputDialog, Spinner } from '@cloudron/pankow';
import ServicesModel from '../models/ServicesModel.js';
import ProfileModel from '../models/ProfileModel.js';
const props = defineProps(['config', 'subscription']);
defineProps(['config', 'notificationCount']);
const profile = inject('profile');
const subscription = inject('subscription');
const helpButton = useTemplateRef('helpButton');
const helpPopover = useTemplateRef('helpPopover');
@@ -24,50 +23,7 @@ function onOpenHelp(popover, event, elem) {
}
const servicesModel = ServicesModel.create();
const notificationModel = NotificationsModel.create();
const notificationButton = useTemplateRef('notificationButton');
const notificationPopover = useTemplateRef('notificationPopover');
const notifications = ref([]);
const notificationsAllBusy = ref(false);
function onOpenNotifications(popover, event, elem) {
popover.open(event, elem);
}
async function onMarkNotificationRead(notification) {
notification.busy = true;
const [error] = await notificationModel.update(notification.id, true);
if (error) return console.error(error);
await refresh();
}
async function onMarkAllNotificationRead() {
notificationsAllBusy.value = true;
await eachLimit(notifications.value, 2, async (notification) => {
notification.busy = true;
const [error] = await notificationModel.update(notification.id, true);
if (error) return console.error(error);
});
await refresh();
notificationsAllBusy.value = false;
}
async function refresh() {
const [error, result] = await notificationModel.list();
if (error) return console.error(error);
result.forEach(n => {
n.isCollapsed = true;
n.busy = false;
});
notifications.value = result;
}
const profileModel = ProfileModel.create();
const subscriptionRequiredDialog = inject('subscriptionRequiredDialog');
@@ -77,7 +33,7 @@ function onSubscriptionRequired() {
const platformStatus = ref({
message: '',
isReady: true,
state: '',
});
let platformTimeoutId = 0;
@@ -87,7 +43,16 @@ async function trackPlatformStatus() {
platformStatus.value = result;
if (!result.isReady) platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
if (result.state === 'starting') platformTimeoutId = setTimeout(trackPlatformStatus, 5000);
}
const inputDialog = useTemplateRef('inputDialog');
function onShowPlatformError() {
inputDialog.value.info({
confirmLabel: t('main.dialog.close'),
title: t('main.platform.startupFailed'),
message: platformStatus.value.message,
});
}
const description = marked.parse(t('support.help.description', {
@@ -97,9 +62,24 @@ const description = marked.parse(t('support.help.description', {
apiLink: 'https://docs.cloudron.io/api.html'
}));
onMounted(async () => {
if (profile.value.isAtLeastAdmin) await refresh();
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 () => {
await trackPlatformStatus();
});
@@ -111,29 +91,8 @@ onUnmounted(() => {
<template>
<div class="headerbar">
<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">
<div class="notification-item" v-for="notification in notifications" :key="notification.id">
<div class="notification-item-title" @click="notification.isCollapsed = !notification.isCollapsed">
<div>
{{ notification.title }}<br/>
<span class="notification-item-date" v-tooltip="prettyLongDate(notification.creationTime)">{{ prettyDate(notification.creationTime) }}</span>
</div>
<Button plain small tool :loading="notification.busy" :disabled="notification.busy" class="notification-read-button" @click.stop="onMarkNotificationRead(notification)">{{ $t('notifications.dismissTooltip') }}</Button>
</div>
<div class="notification-item-body" v-if="!notification.isCollapsed">
<pre v-if="notification.messageJson" style="cursor: auto">{{ JSON.stringify(notification.messageJson, null, 4) }}</pre>
<div v-else style="cursor: auto; overflow: auto;" v-html="marked.parse(notification.message)"></div>
</div>
</div>
</div>
<Button v-if="notifications.length" @click="onMarkAllNotificationRead()" :loading="notificationsAllBusy" :disabled="notificationsAllBusy">{{ $t('notifications.markAllAsRead') }}</Button>
<div v-if="notifications.length === 0" class="notification-item-empty-placeholder">
{{ $t('notifications.allCaughtUp') }}
</div>
</div>
</Popover>
<InputDialog ref="inputDialog"/>
<Menu ref="avatarMenu" :model="avatarActions" />
<Popover ref="helpPopover" :width="'min(80%, 400px)'" :height="'min(80%, 600px)'">
<div style="padding: 10px; display: flex; flex-direction: column; overflow: hidden; height: 100%;">
@@ -150,17 +109,21 @@ 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>
<!-- Warnings if subscription is expired, unpaid or canceled -->
<a v-if="profile.isAtLeastOwner && subscription.plan.id === 'expired'" class="headerbar-action subscription-expired" href="/#/cloudron-account">Subscription Expired</a>
<a v-else-if="profile.isAtLeastOwner && (subscription.cancel_at || subscription.status === 'canceled')" class="headerbar-action subscription-canceled" href="/#/cloudron-account">Subscription Canceled</a>
<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>
<a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="/#/notifications"><Icon :icon="notificationCount > 0 ? 'fas fa-bell' : 'far fa-bell'"/> {{ notificationCount > 99 ? '99+' : notificationCount }}</a>
<div class="headerbar-action pankow-no-mobile" v-if="profile.isAtLeastAdmin" ref="helpButton" @click="onOpenHelp(helpPopover, $event, helpButton)"><Icon icon="fa fa-question"/></div>
<!-- <a class="headerbar-action" v-if="profile.isAtLeastAdmin" href="#/support"><Icon icon="fa fa-question"/></a> -->
<a class="headerbar-action" href="#/profile"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
<a class="headerbar-action" @click.capture="onAvatarClick($event)"><img :src="profile.avatarUrl" @error="event => event.target.src = '/img/avatar-default-symbolic.svg'"/> {{ profile.username }}</a>
</div>
</template>
@@ -175,13 +138,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);
@@ -206,47 +170,16 @@ onUnmounted(() => {
border-bottom: 1px solid var(--pankow-input-border-color);
}
.notification-item {
margin-bottom: 10px;
padding-bottom: 10px;
cursor: pointer;
border-bottom: 1px solid var(--pankow-input-border-color);
position: relative;
}
.notification-item-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 6px;
}
.notification-item-date {
font-size: 10px;
}
.notification-read-button {
visibility: hidden;
margin-right: 10px;
}
.notification-item:hover .notification-read-button {
visibility: visible;
}
@media (hover: none) {
.notification-item .notification-read-button {
visibility: visible;
}
}
.subscription-expired {
.subscription-expired,
.subscription-canceled {
background-color: var(--pankow-color-danger);
color: white;
border-radius: 20px;
font-size: 11px;
}
.subscription-expired:hover {
.subscription-expired:hover,
.subscription-canceled:hover {
color: white;
background-color: var(--pankow-color-danger-hover);
}
+9 -14
View File
@@ -10,8 +10,8 @@ const props = defineProps({
mode: { type: String, default: 'editable', required: true },
src: { type: String, required: true },
fallbackSrc: { type: String, required: true },
size: { type: String, required: true },
maxSize: { type: String, required: false },
size: { type: Number, required: false, default: 512 },
maxSize: { type: Number, required: false, default: 0 },
displayHeight: { type: String, required: false },
displayWidth: { type: String, required: false },
disabled: { type: Boolean, required: false },
@@ -109,22 +109,19 @@ function onChanged(event) {
fr.onload = function () {
const image = new Image();
image.onload = function () {
const size = props.size ? parseInt(props.size) : 512;
const maxSize = props.maxSize ? parseInt(props.maxSize) : 0;
const canvas = document.createElement('canvas');
if (maxSize) {
if (image.naturalWidth > maxSize) {
canvas.width = maxSize;
canvas.height = (image.naturalHeight / image.naturalWidth) * maxSize;
if (props.maxSize) {
if (image.naturalWidth > props.maxSize) {
canvas.width = props.maxSize;
canvas.height = (image.naturalHeight / image.naturalWidth) * props.maxSize;
} else {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
}
} else {
canvas.width = size;
canvas.height = size;
canvas.width = props.size;
canvas.height = props.size;
}
const imageDimensionRatio = image.width / image.height;
@@ -155,8 +152,7 @@ function onChanged(event) {
internalSrc.value = canvas.toDataURL('image/png');
isChanged.value = true;
console.log('internalSrc is now some data url');
emit('changed', file);
emit('changed', dataURLtoFile(internalSrc.value, 'image.png'));
};
image.src = fr.result;
@@ -177,7 +173,6 @@ function onError() {
<div ref="image" :disabled="disabled || null" class="image-picker" @click="!disabled && onEdit()">
<img :src="internalSrc || src" @error="onError" class="image-picker-image" :style="{ height: displayHeight || null, width: displayWidth || null }">
<!-- Editable mode -->
<template v-if="mode === 'editable'">
<div v-if="isChanged" class="image-picker-actions" style="visibility: visible;">
+6 -10
View File
@@ -1,8 +1,7 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup, Button, InputGroup } from '@cloudron/pankow';
import { copyToClipboard } from '@cloudron/pankow/utils';
import { Dialog, TextInput, ClipboardButton, FormGroup, Button, InputGroup } from '@cloudron/pankow';
import UsersModel from '../models/UsersModel.js';
const usersModel = UsersModel.create();
@@ -27,11 +26,6 @@ function onGeneratePassword() {
password.value = tmp;
}
function onCopyPassword() {
copyToClipboard(password.value);
window.pankow.notify({ type: 'success', text: 'Copied to clipboard!' });
}
async function onSubmit() {
busy.value = true;
@@ -48,6 +42,7 @@ async function onSubmit() {
defineExpose({
async open(u) {
u = JSON.parse(JSON.stringify(u)); // make a copy
user.value = u;
success.value = false;
password.value = '';
@@ -61,22 +56,23 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="$t('users.setGhostDialog.title', { username: user.username })"
:title="$t('users.setGhostDialog.title')"
:reject-label="success ? $t('main.dialog.close') : $t('main.dialog.cancel')"
reject-style="secondary"
:confirm-label="success ? '' : $t('users.setGhostDialog.setPassword')"
:confirm-busy="busy"
@confirm="onSubmit()"
>
<p>{{ $t('users.setGhostDialog.context', { username: user.username }) }}</p>
<p>{{ $t('users.setGhostDialog.description') }}</p>
<p class="text-danger" v-show="formError">{{ formError }}</p>
<form @submit.prevent="onSubmit()" autocomplete="none">
<form @submit.prevent="onSubmit()" autocomplete="off">
<fieldset :disabled="busy">
<FormGroup>
<label for="passwordInput">{{ $t('users.setGhostDialog.password') }}</label>
<InputGroup>
<TextInput id="passwordInput" v-model="password" style="flex-grow: 1;"/>
<Button tool v-if="success" @click="onCopyPassword()" icon="fa fa-clipboard" />
<ClipboardButton v-if="success" :value="password" />
<Button tool v-else @click="onGeneratePassword()" v-tooltip="$t('users.setGhostDialog.generatePassword')" icon="fa fa-key" />
</InputGroup>
</FormGroup>
+7 -10
View File
@@ -5,8 +5,7 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, EmailInput, FormGroup, Button, InputGroup, ProgressBar } from '@cloudron/pankow';
import { copyToClipboard } from '@cloudron/pankow/utils';
import { Dialog, TextInput, ClipboardButton, EmailInput, FormGroup, Button, InputGroup, ProgressBar } from '@cloudron/pankow';
import UsersModel from '../models/UsersModel.js';
const emit = defineEmits([ 'refreshRequired' ]);
@@ -21,11 +20,6 @@ const email = ref('');
const success = ref(false);
const busy = ref(false);
function onCopyToClipboard() {
copyToClipboard(inviteLink.value);
window.pankow.notify({ type: 'success', text: 'Copied to clipboard!' });
}
async function onSendInvite() {
const [error] = await usersModel.sendInviteEmail(user.value.id, email.value);
if (error) return console.error(error);
@@ -37,6 +31,7 @@ async function onSendInvite() {
defineExpose({
async open(u) {
u = JSON.parse(JSON.stringify(u)); // make a copy
user.value = u;
success.value = false;
email.value = u.email || '';
@@ -64,7 +59,7 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="$t('users.invitationDialog.title', { username: user? (user.username || user.email) : '' })"
:title="$t('users.invitationDialog.title')"
:reject-label="$t('main.dialog.close')"
reject-style="secondary"
>
@@ -73,11 +68,13 @@ defineExpose({
<ProgressBar mode="indeterminate" :show-label="false" :slim="true"/>
</div>
<div v-else>
<p>{{ $t('users.invitationDialog.context', { username: user? (user.username || user.email) : '' }) }}</p>
<FormGroup>
<label>{{ $t('users.invitationDialog.descriptionLink') }}</label>
<InputGroup>
<TextInput style="flex-grow: 1;" v-model="inviteLink" readonly/>
<Button tool @click="onCopyToClipboard()" icon="fa fa-clipboard"/>
<ClipboardButton :value="inviteLink" />
</InputGroup>
</FormGroup>
@@ -85,7 +82,7 @@ defineExpose({
<label>{{ $t('users.invitationDialog.descriptionEmail') }}</label>
<InputGroup>
<EmailInput style="flex-grow: 1;" v-model="email"/>
<Button @click="onSendInvite()" ng-disabled="invitation.busy" :loading="busy">{{ $t('users.invitationDialog.sendAction') }}</Button>
<Button @click="onSendInvite()" :loading="busy">{{ $t('users.invitationDialog.sendAction') }}</Button>
</InputGroup>
</FormGroup>
</div>
+30 -27
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
import Section from '../components/Section.vue';
import NetworkModel from '../models/NetworkModel.js';
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
const providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
{ name: 'Static IP address', value: 'fixed' },
{ name: 'Network interface', value: 'network-interface' }
];
function prettyIpProviderName(provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
case 'fixed': return 'Static IP address';
case 'network-interface': return 'Network interface';
default: return 'Unknown';
}
}
@@ -36,12 +36,16 @@ const editProvider = ref('');
const editAddress = ref('');
const editInterfaceName = ref('');
const isValid = computed(() => {
if (editProvider.value === 'fixed' && !editAddress.value) return false;
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
return true;
});
if (isFormValid.value) {
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
}
}
async function refresh() {
let [error, result] = await networkModel.getIpv4Config();
@@ -65,10 +69,11 @@ function onConfigure() {
editInterfaceName.value = interfaceName.value || '';
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
async function onSubmit() {
if (!isValid.value) return;
if (!form.value.reportValidity()) return;
editBusy.value = true;
editError.value = {};
@@ -100,39 +105,39 @@ onMounted(async () => {
:title="$t('network.configureIp.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editBusy"
:confirm-active="isValid"
:confirm-active="!editBusy && isFormValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
>
<div>
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<fieldset :disabled="editBusy">
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
<input style="display: none" type="submit"/>
<FormGroup>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
<p class="has-error" v-show="editError.generic">{{ editError.generic }}</p>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#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" required/>
<div class="has-error" v-show="editError.generic">{{ editError.generic }}</div>
</FormGroup>
<p v-show="editProvider === 'generic'">
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</p>
</div>
<!-- Fixed -->
<FormGroup v-show="editProvider === 'fixed'">
<label for="addressInput">{{ $t('network.ipv4.address') }}</label>
<TextInput id="addressInput" v-model="editAddress" :required="editProvider === 'fixed'" />
<p class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</p>
<div class="has-error" v-show="editError.ipv4">{{ editError.ipv4 }}</div>
</FormGroup>
<!-- Network Interface -->
<FormGroup v-show="editProvider === 'network-interface'">
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet -br addr</code></p>
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet -br addr <ClipboardAction plain value="ip -f inet -br addr" /></div>
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
<p class="has-error" v-show="editError.ifname">{{ editError.ifname }}</p>
<div class="has-error" v-show="editError.ifname">{{ editError.ifname }}</div>
</FormGroup>
</fieldset>
</form>
@@ -140,10 +145,6 @@ onMounted(async () => {
</Dialog>
<Section :title="$t('network.ip.title')">
<template #header-buttons>
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
</template>
<div>{{ $t('network.ip.description') }}</div>
<br/>
@@ -159,6 +160,8 @@ onMounted(async () => {
<div class="info-label">{{ $t('network.ip.interface') }}</div>
<div class="info-value">{{ interfaceName }}</div>
</div>
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
</Section>
</div>
</template>
+26 -23
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted, useTemplateRef, computed } from 'vue';
import { Button, Dialog, SingleSelect, FormGroup, TextInput } from '@cloudron/pankow';
import { ref, onMounted, useTemplateRef } from 'vue';
import { Button, Dialog, SingleSelect, FormGroup, TextInput, ClipboardAction } from '@cloudron/pankow';
import Section from '../components/Section.vue';
import NetworkModel from '../models/NetworkModel.js';
@@ -11,16 +11,16 @@ const networkModel = NetworkModel.create();
const providers = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
{ name: 'Static IP address', value: 'fixed' },
{ name: 'Network interface', value: 'network-interface' }
];
function prettyIpProviderName(provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
case 'fixed': return 'Static IP address';
case 'network-interface': return 'Network interface';
default: return 'Unknown';
}
}
@@ -36,12 +36,16 @@ const editProvider = ref('');
const editAddress = ref('');
const editInterfaceName = ref('');
const isValid = computed(() => {
if (editProvider.value === 'fixed' && !editAddress.value) return false;
if (editProvider.value === 'network-interface' && !editInterfaceName.value) return false;
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
return true;
});
if (isFormValid.value) {
if (editProvider.value === 'fixed' && !editAddress.value) isFormValid.value = false;
if (editProvider.value === 'network-interface' && !editInterfaceName.value) isFormValid.value = false;
}
}
async function refresh() {
let [error, result] = await networkModel.getIpv6Config();
@@ -65,10 +69,11 @@ function onConfigure() {
editInterfaceName.value = interfaceName.value || '';
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
async function onSubmit() {
if (!isValid.value) return;
if (!form.value.reportValidity()) return;
editBusy.value = true;
editError.value = {};
@@ -100,23 +105,23 @@ onMounted(async () => {
:title="$t('network.configureIpv6.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="editBusy"
:confirm-active="isValid"
:confirm-active="!editBusy && isFormValid"
:reject-label="$t('main.dialog.cancel')"
reject-style="secondary"
@confirm="onSubmit()"
>
<div>
<form novalidate @submit.prevent="onSubmit()" autocomplete="off">
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<fieldset :disabled="editBusy">
<input style="display: none" type="submit" :disabled="editBusy || !isValid"/>
<input style="display: none" type="submit" />
<FormGroup>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<SingleSelect id="providerInput" v-model="editProvider" :options="providers" option-key="value" option-label="name"/>
<label for="providerInput">{{ $t('network.ip.provider') }} <sup><a href="https://docs.cloudron.io/network/#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" required/>
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
</FormGroup>
<div v-show="editProvider === 'generic'">
<div v-show="editProvider === 'generic'" style="margin-top: 10px">
{{ $t('network.configureIp.providerGenericDescription') }} <sup><a href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</div>
@@ -130,9 +135,9 @@ onMounted(async () => {
<!-- Network Interface -->
<FormGroup v-show="editProvider === 'network-interface'">
<label for="interfaceNameInput">{{ $t('network.ip.interface') }}</label>
<div description>{{ $t('network.ip.interfaceDescription') }} ip -f inet6 -br addr <ClipboardAction plain value="ip -f inet6 -br addr" /></div>
<TextInput id="interfaceNameInput" v-model="editInterfaceName" :required="editProvider === 'network-interface'" />
<div class="error-label" v-show="editError.ifname">{{ editError.ifname }}</div>
<p>{{ $t('network.ip.interfaceDescription') }} <code>ip -f inet6 -br addr</code></p>
</FormGroup>
</fieldset>
</form>
@@ -140,10 +145,6 @@ onMounted(async () => {
</Dialog>
<Section :title="$t('network.ipv6.title')">
<template #header-buttons>
<Button @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
</template>
<div>{{ $t('network.ipv6.description') }}</div>
<br/>
@@ -159,6 +160,8 @@ onMounted(async () => {
<div class="info-label">{{ $t('network.ip.interface') }}</div>
<div class="info-value">{{ interfaceName }}</div>
</div>
<Button style="margin-top: 10px" @click="onConfigure()">{{ $t('network.ip.configure') }}</Button>
</Section>
</div>
</template>
+112
View File
@@ -0,0 +1,112 @@
<script setup>
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';
import DashboardModel from '../models/DashboardModel.js';
import UserDirectoryModel from '../models/UserDirectoryModel.js';
const domainsModel = DomainsModel.create();
const dashboardModel = DashboardModel.create();
const userDirectoryModel = UserDirectoryModel.create();
const adminDomain = ref({});
const editError = ref({});
const busy = ref(false);
const enabled = ref(false);
const ldapUrl = ref('');
const secret = ref('');
const allowlist = ref('');
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;
busy.value = true;
editError.value = {};
const [error] = await userDirectoryModel.setExposedLdapConfig({ enabled: enabled.value, allowlist: allowlist.value, secret: secret.value });
busy.value = false;
if (error) {
if (error.status === 400) {
if (error.body.message.indexOf('secret') !== -1) editError.value.secret = error.body.message;
else editError.value.allowlist = error.body.message;
} else {
editError.value.generic = error.body ? error.body.message : 'Internal error';
}
}
}
onMounted(async () => {
let [error, result] = await domainsModel.list();
if (error) return console.error(error);
const domains = result;
[error, result] = await dashboardModel.config();
if (error) return console.error(error);
ldapUrl.value = `ldaps://${result.adminFqdn}:636`;
adminDomain.value = domains.find(d => d.domain === result.adminDomain);
[error, result] = await userDirectoryModel.getExposedLdapConfig();
if (error) return console.error(error);
enabled.value = result.enabled;
secret.value = result.secret;
allowlist.value = result.allowlist;
setTimeout(checkValidity, 100); // update state of the confirm button
});
</script>
<template>
<Section :title="$t('users.exposedLdap.title')">
<div>{{ $t('users.exposedLdap.description') }}</div>
<form novalidate @submit.prevent="onSubmit()" autocomplete="off" ref="form" @input="checkValidity()">
<fieldset :disabled="busy">
<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"/>
<FormGroup>
<label for="ldapUrlInput">{{ $t('users.exposedLdap.secret.url') }}</label>
<InputGroup>
<TextInput id="ldapUrlInput" v-model="ldapUrl" readonly style="flex-grow: 1;"/>
<ClipboardButton :value="ldapUrl" />
</InputGroup>
<div class="warning-label" v-show="adminDomain.provider === 'cloudflare'">{{ $t('users.exposedLdap.cloudflarePortWarning') }}</div>
</FormGroup>
<FormGroup>
<label for="secretInput">{{ $t('users.exposedLdap.secret.label') }}</label>
<div description v-html="$t('users.exposedLdap.secret.description', { userDN: 'cn=admin,ou=system,dc=cloudron' })"></div>
<PasswordInput id="secretInput" v-model="secret" required :disabled="!enabled" />
<div class="has-error" v-show="editError.secret">{{ editError.secret }}</div>
</FormGroup>
<FormGroup>
<label for="allowlistInput">{{ $t('users.exposedLdap.ipRestriction.label') }}</label>
<div description v-html="$t('users.exposedLdap.ipRestriction.description')"></div>
<textarea id="allowlistInput" v-model="allowlist" rows="4" required :disabled="!enabled"></textarea>
<small style="helper-text">{{ $t('users.exposedLdap.ipRestriction.placeholder') }}</small>
<div class="has-error" v-show="editError.allowlist">{{ editError.allowlist }}</div>
</FormGroup>
</fieldset>
</form>
<div class="error-label" v-show="editError.generic">{{ editError.generic }}</div>
<br/>
<Button :loading="busy" :disabled="!isFormValid || busy" @click="onSubmit()">{{ $t('users.settings.saveAction') }}</Button>
</Section>
</template>
+129 -131
View File
@@ -1,150 +1,148 @@
<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 && taskId) {
type.value = 'task';
id.value = taskId;
name.value = 'Task ' + taskId;
} else 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, { appId });
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>
+54 -24
View File
@@ -1,7 +1,7 @@
<script setup>
import { ref, onMounted } from 'vue';
import { Button } from '@cloudron/pankow';
import { Button, ClipboardAction } from '@cloudron/pankow';
import Section from './Section.vue';
import MailModel from '../models/MailModel.js';
@@ -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,17 +96,18 @@ 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>
<div v-if="key === 'mx' && domain.provider === 'namecheap'">{{ $t('email.dnsStatus.namecheapInfo') }} <sup><a href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
<div v-if="key === 'ptr4' || key === 'ptr6'">{{ $t('email.dnsStatus.ptrInfo') }} <sup><a href="https://docs.cloudron.io/email/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></div>
<div v-if="domainStatus[key].status === 'skipped'">{{ domainStatus[key].message }}</div>
<div v-else>
<table class="domain-status">
<div v-else style="overflow: hidden;">
<table class="domain-status" style="width: 100%; table-layout: fixed;">
<tbody>
<tr>
<td>{{ $t('email.dnsStatus.hostname') }}:</td>
<td style="width: 160px">{{ $t('email.dnsStatus.hostname') }}:</td>
<td>{{ domainStatus[key].name }}</td>
</tr>
<tr>
@@ -117,12 +119,17 @@ onMounted(async () => {
<td>{{ domainStatus[key].type }}</td>
</tr>
<tr>
<td>{{ $t('email.dnsStatus.expected') }}:</td>
<td>{{ domainStatus[key].expected }}</td>
<td class="domain-status-expected-label">{{ $t('email.dnsStatus.expected') }}:</td>
<td class="domain-status-expected-value">
<div class="domain-status-expected">{{ domainStatus[key].expected }}</div>
<ClipboardAction :value="domainStatus[key].expected"/>
</td>
</tr>
<tr>
<td>{{ $t('email.dnsStatus.current') }}:</td>
<td>{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</td>
<td>
<div style="overflow: hidden; text-overflow: ellipsis; white-space: nowrap;">{{ domainStatus[key].value ? domainStatus[key].value : ('['+$t('email.dnsStatus.recordNotSet')+']') }} {{ domainStatus[key].message }}</div>
</td>
</tr>
</tbody>
</table>
@@ -131,8 +138,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 +149,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 +194,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;
}
@@ -209,7 +224,7 @@ onMounted(async () => {
overflow: scroll;
white-space: nowrap;
text-overflow: auto;
font-weight: bold;
font-weight: var(--pankow-font-weight-bold);
}
.domain-status > tbody > tr > td:first-of-type {
@@ -217,4 +232,19 @@ onMounted(async () => {
padding-right: 20px;
}
.domain-status-expected-label {
vertical-align: top;
}
.domain-status-expected-value {
display: flex;
gap: 6px;
align-items: center;
}
.domain-status-expected {
overflow-wrap: break-word;
word-break: break-all;
}
</style>
@@ -1,33 +1,21 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { Button, Dialog, FormGroup, SingleSelect, TextInput, NumberInput, Checkbox, PasswordInput } from '@cloudron/pankow';
import { ref, useTemplateRef, computed, onMounted } from 'vue';
import { Button, Dialog, FormGroup, SingleSelect, TextInput, NumberInput, Checkbox, MaskedInput } from '@cloudron/pankow';
import SettingsItem from './SettingsItem.vue';
import MailModel from '../models/MailModel.js';
import { RELAY_PROVIDERS } from '../constants.js';
import { prettyRelayProviderName } from '../utils';
const props = defineProps(['domain']);
const props = defineProps({
domain: { type: String, required: true },
adminDomain: { type: String, required: true }
});
const mailModel = MailModel.create();
const providers = [
{ provider: 'cloudron-smtp', name: 'Built-in SMTP server' },
{ provider: 'external-smtp', name: 'External SMTP server', host: '', port: 587 },
{ provider: 'external-smtp-noauth', name: 'External SMTP server (No Authentication)', host: '', port: 587 },
{ provider: 'ses-smtp', name: 'Amazon SES', host: 'email-smtp.us-east-1.amazonaws.com', port: 587, spfDoc: 'https://docs.aws.amazon.com/ses/latest/DeveloperGuide/spf.html' },
{ provider: 'brevo-smtp', name: 'Brevo', host: 'smtp-relay.brevo.com', port: 587, spfDoc: 'https://help.brevo.com/hc/en-us/articles/12163873383186-Authenticate-your-domain-with-Brevo-Brevo-code-DKIM-DMARC' },
{ provider: 'elasticemail-smtp', name: 'Elastic Email', host: 'smtp.elasticemail.com', port: 587, spfDoc: 'https://elasticemail.com/blog/marketing_tips/common-spf-errors' },
{ provider: 'google-smtp', name: 'Google', host: 'smtp.gmail.com', port: 587, spfDoc: 'https://support.google.com/a/answer/33786?hl=en' },
{ provider: 'mailgun-smtp', name: 'Mailgun', host: 'smtp.mailgun.org', port: 587, spfDoc: 'https://www.mailgun.com/blog/white-labeling-dns-records-your-customers-tips-tricks' },
{ provider: 'mailjet-smtp', name: 'Mailjet', host: 'in-v3.mailjet.com', port: 587, spfDoc: 'https://app.mailjet.com/docs/spf-dkim-guide' },
{ provider: 'office365-legacy-smtp', name: 'Office 365', host: 'smtp-legacy.office365.com', port: 587 }, // uses "login" AUTH instead of "plain"
{ provider: 'postmark-smtp', name: 'Postmark', host: 'smtp.postmarkapp.com', port: 587, spfDoc: 'https://postmarkapp.com/support/article/1092-how-do-i-set-up-spf-for-postmark' },
{ provider: 'sendgrid-smtp', name: 'SendGrid', host: 'smtp.sendgrid.net', port: 587, username: 'apikey', spfDoc: 'https://sendgrid.com/docs/ui/account-and-settings/spf-records/' },
{ provider: 'sparkpost-smtp', name: 'SparkPost', host: 'smtp.sparkpostmail.com', port: 587, username: 'SMTP_Injection', spfDoc: 'https://www.sparkpost.com/resources/email-explained/spf-sender-policy-framework/' },
{ provider: 'noop', name: 'Disable' },
];
const providerSpfDoc = computed(() => {
const tmp = providers.find(p => p.provider === provider.value);
const tmp = RELAY_PROVIDERS.find(p => p.provider === provider.value);
return (tmp ? tmp.spfDoc : '') || '';
});
@@ -35,7 +23,7 @@ const mailConfig = ref({});
const dialog = useTemplateRef('dialog');
const busy = ref(false);
const formError = ref('');
const adminDomain = ref('');
const currentProvider = ref('cloudron-smtp');
const provider = ref('cloudron-smtp');
const host = ref('');
const port = ref(1);
@@ -63,6 +51,12 @@ function usesPasswordAuth(provider) {
|| provider === 'mailjet-smtp';
}
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
}
function onProviderChange() {
// reset the form
host.value = '';
@@ -73,7 +67,7 @@ function onProviderChange() {
password.value = '';
// prefill from preset
const tmp = providers.find(p => p.provider === provider.value);
const tmp = RELAY_PROVIDERS.find(p => p.provider === provider.value);
if (!tmp) return;
if (tmp.host) host.value = tmp.host;
@@ -98,9 +92,13 @@ async function onShowDialog() {
serverApiToken.value = result.relay.serverApiToken;
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
async function onSubmit() {
if (!form.value.reportValidity()) return;
busy.value = true;
formError.value = '';
@@ -110,7 +108,7 @@ async function onSubmit() {
if (usesExternalServer(data.provider)) {
data.username = username.value;
data.password = password.value;
if (password.value) data.password = password.value;
data.host = host.value;
data.port = parseInt(port.value);
data.acceptSelfSignedCerts = acceptSelfSignedCerts.value;
@@ -119,15 +117,15 @@ async function onSubmit() {
// fill in provider specific username/password usage
if (data.provider === 'postmark-smtp') {
data.username = serverApiToken.value;
data.password = serverApiToken.value;
if (serverApiToken.value) data.username = serverApiToken.value;
if (serverApiToken.value) data.password = serverApiToken.value;
data.forceFromAddress = true; // postmark requires the "From:" in mail to be a Sender Signature
} else if (data.provider === 'sendgrid-smtp') {
data.username = 'apikey';
data.password = serverApiToken.value;
if (serverApiToken.value) data.password = serverApiToken.value;
} else if (data.provider === 'sparkpost-smtp') {
data.username = 'SMTP_Injection';
data.password = serverApiToken.value;
if (serverApiToken.value) data.password = serverApiToken.value;
}
const [error] = await mailModel.setMailRelay(props.domain, data);
@@ -137,11 +135,21 @@ async function onSubmit() {
return console.error(error);
}
currentProvider.value = provider.value;
dialog.value.close();
busy.value = false;
}
onMounted(async () => {
const [error, result] = await mailModel.config(props.domain);
if (error) return console.error(error);
provider.value = result.relay.provider;
currentProvider.value = result.relay.provider;
});
</script>
<template>
@@ -150,23 +158,24 @@ async function onSubmit() {
:title="$t('email.outbound.title')"
:confirm-label="$t('main.dialog.save')"
:confirm-busy="busy"
:reject-label="busy ? '' : $t('main.dialog.cancel')"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy && isFormValid"
reject-style="secondary"
@confirm="onSubmit()"
>
<div>
<SingleSelect v-model="provider" :options="providers" option-key="provider" option-label="name" @select="onProviderChange()" style="display: flex;"/>
<SingleSelect v-model="provider" :options="RELAY_PROVIDERS" option-key="provider" option-label="name" @select="onProviderChange()" style="display: flex;"/>
<!-- set max-width here until Dialog supports that -->
<div v-if="providerSpfDoc" class="text-warning" style="margin: 10px 0; max-width: 600px" v-html="$t('email.outbound.mailRelay.spfDocInfo', { name: provider, spfDocsLink: providerSpfDoc })"></div>
<div class="text-danger" style="margin: 10px 0;" v-if="provider === 'noop'">{{ $t(domain === adminDomain ? 'email.outbound.noopAdminDomainWarning' : 'email.outbound.noopNonAdminDomainWarning') }}</div>
<div class="error-label" style="margin: 10px 0;" v-if="provider === 'noop'">{{ $t(domain === adminDomain ? 'email.outbound.noopAdminDomainWarning' : 'email.outbound.noopNonAdminDomainWarning') }}</div>
<div class="text-danger" v-if="formError">{{ formError }}</div>
<div class="error-label" v-if="formError">{{ formError }}</div>
<form @submit.prevent="onSubmit()" autocomplete="off" v-if="usesExternalServer(provider)">
<fieldset :disabled="busy">
<input type="submit" style="display: none"/>
<form ref="form" @submit.prevent="onSubmit()" autocomplete="off" @input="checkValidity()">
<fieldset :disabled="busy" v-if="usesExternalServer(provider)">
<input type="submit" style="display: none" />
<FormGroup>
<label for="hostInput">{{ $t('email.outbound.mailRelay.host') }}</label>
@@ -183,7 +192,7 @@ async function onSubmit() {
<!-- Postmark, Sendgrid, SparkPost -->
<FormGroup v-if="usesTokenAuth(provider)">
<label for="serverApiTokenInput">{{ $t('email.outbound.mailRelay.apiTokenOrKey') }}</label>
<TextInput id="serverApiTokenInput" v-model="serverApiToken" required />
<MaskedInput id="serverApiTokenInput" v-model="serverApiToken" required />
</FormGroup>
<!-- Other -->
@@ -194,7 +203,7 @@ async function onSubmit() {
<FormGroup v-if="usesPasswordAuth(provider)">
<label for="passwordInput">{{ $t('email.outbound.mailRelay.password') }}</label>
<TextInput id="passwordInput" v-model="password" required />
<MaskedInput id="passwordInput" v-model="password" required />
</FormGroup>
</fieldset>
</form>
@@ -205,9 +214,11 @@ async function onSubmit() {
<SettingsItem>
<FormGroup>
<label>{{ $t('email.outbound.title') }} <sup><a href="https://docs.cloudron.io/email/#relay-outbound-mails" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div v-html="$t('email.outbound.description')"></div>
<div>
<span>{{ prettyRelayProviderName(currentProvider) }}</span> / <span v-html="$t('email.outbound.description')"></span>
</div>
</FormGroup>
<div style="display: flex; align-items: center">
<div style="display: flex; align-items: center;">
<Button tool plain @click="onShowDialog()">{{ $t('main.dialog.edit') }}</Button>
</div>
</SettingsItem>
@@ -109,20 +109,18 @@ onMounted(async () => {
</template>
<SettingsItem wrap>
<div style="display: flex; align-items: center">
<div style="display: flex; align-items: center; width: 100%">
<div v-html="$t('emails.changeDomainDialog.description')"></div>
</div>
<div style="display: flex; gap: 6px; align-items: center; flex-wrap: wrap;">
<form @submit.prevent="onSubmit()">
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
<form @submit.prevent="onSubmit()" style="display: flex; align-items: center; width: 100%; justify-content: end;" autocomplete="off">
<input type="submit" style="display: none" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)"/>
<InputGroup>
<TextInput v-model="subdomain" :disabled="busy"/>
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('main.dialog.save') }}</Button>
</InputGroup>
</form>
</div>
<InputGroup style="overflow: hidden;">
<TextInput v-model="subdomain" :disabled="busy" style="width: 120px"/>
<SingleSelect v-model="domain" :options="domains" option-key="domain" option-label="domain" :disabled="busy"/>
<Button tool @click="onSubmit()" :disabled="busy || (originalSubdomain === subdomain && originalDomain === domain)">{{ $t('emails.changeDomainDialog.setAction') }}</Button>
</InputGroup>
</form>
</SettingsItem>
<div class="error-label" v-if="formError">{{ formError }}</div>
+43 -23
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef, computed, inject } from 'vue';
import { Dialog, Button, TextInput, FormGroup, Checkbox, InputGroup, SingleSelect } from '@cloudron/pankow';
import { prettyDecimalSize } from '@cloudron/pankow/utils';
import MailboxesModel from '../models/MailboxesModel.js';
@@ -10,6 +10,7 @@ const props = defineProps([ 'apps', 'users', 'groups', 'domains' ]);
const mailboxesModel = MailboxesModel.create();
const dashboardDomain = inject('dashboardDomain');
const dialog = useTemplateRef('dialog');
const busy = ref(false);
const formError = ref('');
@@ -18,7 +19,7 @@ const domain = ref('');
const mailbox = ref(null);
const aliases = ref([]);
const ownerId = ref('');
const usersAndGroups = ref([]);
const usersAndGroupsAndApps = ref([]);
const storageQuotaEnabled = ref(false);
const storageQuotaTicks = [ 500*1000*1000, 5*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
const storageQuota = ref(5*1000*1000*1000);
@@ -34,7 +35,8 @@ const domainHasIncomingEnabled = computed(() => {
function onAddAlias() {
aliases.value.push({
name: '',
domain: '@' + props.domains[0].domain,
domain: domain.value,
label: '@' + domain.value,
});
}
@@ -42,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 = '';
@@ -78,32 +88,42 @@ async function onSubmit() {
}
}
emit('success');
emit('success', { domain: domain.value, name: name.value, fullName: name.value + '@' + domain.value });
dialog.value.close();
busy.value = false;
}
defineExpose({
async open(m = null) {
m = m ? JSON.parse(JSON.stringify(m)) : null; // make a copy
busy.value = false;
formError.value = '';
mailbox.value = m;
name.value = m ? m.name : '';
domain.value = m ? m.domain : props.domains[0].domain;
domain.value = m ? m.domain : dashboardDomain.value;
ownerId.value = m ? m.ownerId : '';
aliases.value = m ? JSON.parse(JSON.stringify(m.aliases)) : [];
aliases.value = m ? m.aliases : [];
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;
usersAndGroups.value = props.users.concat(props.groups).concat(props.apps);
storageQuota.value = m && m.storageQuota ? m.storageQuota : 5*1000*1000*1000;
usersAndGroupsAndApps.value = [];
// unify on .name for multiselect
usersAndGroups.value.forEach(u => {
u.icon = u.name ? 'fa-solid fa-users' : (u.username ? 'fa-solid fa-user' : 'fa-solid fa-cube') ;
u.name = u.name || u.username || u.label || u.fqdn;
});
if (props.users.length) usersAndGroupsAndApps.value.push({ separator: true, label: '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.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.map(a => {
return { ...a, icon: 'fa-solid fa-cube', name: a.label || a.fqdn };
}));
domainList.value = props.domains.map(d => {
return {
@@ -114,6 +134,8 @@ defineExpose({
});
dialog.value.open();
setTimeout(validateForm, 100); // update state of the confirm button
}
});
@@ -121,26 +143,25 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="mailbox ? $t('email.editMailboxDialog.title', { name: mailbox.name, domain: mailbox.domain }) : $t('email.addMailboxDialog.title')"
:title="mailbox ? $t('email.editMailboxDialog.title') : $t('email.addMailboxDialog.title')"
:confirm-label="$t(mailbox ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
:confirm-busy="busy"
:confirm-active="!busy && name !== '' && domain !== ''"
:confirm-active="!busy && isFormValid"
reject-style="secondary"
:reject-label="$t('main.dialog.cancel')"
:reject-active="!busy"
@confirm="onSubmit()"
>
<div>
<form @submit.prevent="onSubmit()" novalidate autocomplete="off">
<form @submit.prevent="onSubmit()" novalidate autocomplete="off" ref="form" @input="validateForm()">
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
<FormGroup v-if="!mailbox">
<FormGroup>
<label for="nameInput">{{ $t('email.addMailboxDialog.name') }}</label>
<InputGroup>
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="!!mailbox" :required="!mailbox"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailbox" :required="!mailbox"/>
</InputGroup>
<div class="warning-label" v-if="!domainHasIncomingEnabled">{{ $t('email.addMailboxDialog.incomingDisabledWarning') }}</div>
<div class="error-label" v-if="formError">{{ formError }}</div>
@@ -148,7 +169,7 @@ defineExpose({
<FormGroup>
<label>{{ $t('email.editMailboxDialog.owner') }}</label>
<SingleSelect v-model="ownerId" :options="usersAndGroups" :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')"/>
@@ -173,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>
+14 -7
View File
@@ -1,6 +1,6 @@
<script setup>
import { ref, useTemplateRef } 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';
@@ -19,6 +19,11 @@ const membersText = ref('');
const membersOnly = ref(false);
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;
@@ -57,12 +62,13 @@ async function onSubmit() {
defineExpose({
async open(m = null) {
m = m ? JSON.parse(JSON.stringify(m)) : null; // make a copy
busy.value = false;
formError.value = {};
mailinglist.value = m;
name.value = m ? m.name : '';
domain.value = m ? m.domain : props.domains[0].domain;
domain.value = m ? m.domain : dashboardDomain.value;
membersText.value = m ? m.members.join('\n') : '';
membersOnly.value = m ? m.membersOnly : false;
active.value = m ? m.active : true;
@@ -82,7 +88,8 @@ defineExpose({
<template>
<Dialog ref="dialog"
:title="mailinglist ? $t('email.editMailinglistDialog.title', { name: mailinglist.name, domain: mailinglist.domain }) : $t('email.addMailinglistDialog.title')"
:title="mailinglist ? $t('email.editMailinglistDialog.title') : $t('email.addMailinglistDialog.title')"
:style="{ 'min-width': '700px' }"
:confirm-label="$t(mailinglist ? 'main.dialog.save' : 'email.incoming.mailboxes.addAction')"
:confirm-busy="busy"
:confirm-active="!busy && name !== '' && domain !== '' && membersText !== ''"
@@ -98,17 +105,17 @@ defineExpose({
<fieldset :disabled="busy">
<input type="submit" style="display: none;" :disabled="!name || !domain"/>
<FormGroup v-if="!mailinglist">
<FormGroup>
<label for="nameInput">{{ $t('email.addMailinglistDialog.name') }}</label>
<InputGroup>
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" />
<TextInput id="nameInput" style="flex-grow: 1;" v-model="name" :readonly="!!mailinglist"/>
<SingleSelect v-model="domain" :options="domainList" option-key="domain" option-label="label" :disabled="!!mailinglist"/>
</InputGroup>
<div class="error-label" v-if="formError.name">{{ formError.name }}</div>
</FormGroup>
<FormGroup>
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }}</label>
<label for="membersInput">{{ $t('email.addMailinglistDialog.members') }} ({{ memberCount }})</label>
<textarea id="membersInput" v-model="membersText" rows="5"></textarea>
<div class="error-label" v-if="formError.members">{{ formError.members }}</div>
</FormGroup>
@@ -1,21 +1,23 @@
<script setup>
import { ref, onMounted } from 'vue';
import { Switch } from '@cloudron/pankow';
import { ref, useTemplateRef } from 'vue';
import { Switch, Dialog } from '@cloudron/pankow';
import SettingsItem from '../components/SettingsItem.vue';
import Section from '../components/Section.vue';
import ProfileModel from '../models/ProfileModel.js';
const profileModel = ProfileModel.create();
const dialogItem = useTemplateRef('dialogItem');
const busy = ref(false);
const appUpp = ref(false);
const appDown = ref(false);
const appOutOfMemory = ref(false);
const backupFailed = ref(false);
const appAutoUpdateFailed = ref(false);
const certificateRenewalFailed = ref(false);
const diskSpace = ref(false);
const cloudronUpdateFailed = ref(false);
const manualUpdateRequired = ref(false);
const reboot = ref(false);
async function onSubmit() {
@@ -26,18 +28,22 @@ async function onSubmit() {
if (appDown.value) config.push('appDown');
if (appOutOfMemory.value) config.push('appOutOfMemory');
if (backupFailed.value) config.push('backupFailed');
if (appAutoUpdateFailed.value) config.push('appAutoUpdateFailed');
if (certificateRenewalFailed.value) config.push('certificateRenewalFailed');
if (diskSpace.value) config.push('diskSpace');
if (cloudronUpdateFailed.value) config.push('cloudronUpdateFailed');
if (manualUpdateRequired.value) config.push('manualUpdateRequired');
if (reboot.value) config.push('reboot');
const [error] = await profileModel.setNotificationConfig(config);
if (error) return console.error(error);
dialogItem.value.close();
busy.value = false;
}
onMounted(async () => {
async function open() {
const [error, result] = await profileModel.get();
if (error) return console.error(error);
@@ -47,58 +53,85 @@ onMounted(async () => {
appDown.value = config.indexOf('appDown') !== -1;
appOutOfMemory.value = config.indexOf('appOutOfMemory') !== -1;
backupFailed.value = config.indexOf('backupFailed') !== -1;
appAutoUpdateFailed.value = config.indexOf('appAutoUpdateFailed') !== -1;
certificateRenewalFailed.value = config.indexOf('certificateRenewalFailed') !== -1;
diskSpace.value = config.indexOf('diskSpace') !== -1;
cloudronUpdateFailed.value = config.indexOf('cloudronUpdateFailed') !== -1;
manualUpdateRequired.value = config.indexOf('manualUpdateRequired') !== -1;
reboot.value = config.indexOf('reboot') !== -1;
dialogItem.value.open();
}
defineExpose({
open
});
</script>
<template>
<Section :title="$t('notifications.settings.title')">
<Dialog ref="dialogItem"
:title="$t('notifications.settings.title')"
:confirm-label="$t('main.dialog.save')"
confirm-style="primary"
:confirm-busy="busy"
:confirm-active="!busy"
:reject-label="$t('main.dialog.close')"
reject-style="secondary"
@confirm="onSubmit"
>
<div>{{ $t('notifications.settingsDialog.description') }}</div>
<br/>
<SettingsItem>
<div>{{ $t('notifications.settings.appUp') }}</div>
<Switch v-model="appUpp" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="appUpp" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.appDown') }}</div>
<Switch v-model="appDown" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="appDown" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.appOutOfMemory') }}</div>
<Switch v-model="appOutOfMemory" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="appOutOfMemory" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.backupFailed') }}</div>
<Switch v-model="backupFailed" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="backupFailed" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.appAutoUpdateFailed') }}</div>
<Switch v-model="appAutoUpdateFailed" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.certificateRenewalFailed') }}</div>
<Switch v-model="certificateRenewalFailed" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="certificateRenewalFailed" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.diskSpace') }}</div>
<Switch v-model="diskSpace" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="diskSpace" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.cloudronUpdateFailed') }}</div>
<Switch v-model="cloudronUpdateFailed" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="cloudronUpdateFailed" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.manualUpdateRequired') }}</div>
<Switch v-model="manualUpdateRequired" :disabled="busy"/>
</SettingsItem>
<SettingsItem>
<div>{{ $t('notifications.settings.rebootRequired') }}</div>
<Switch v-model="reboot" :disabled="busy" @change="onSubmit()"/>
<Switch v-model="reboot" :disabled="busy"/>
</SettingsItem>
</Section>
</Dialog>
</template>
@@ -1,29 +1,13 @@
<script setup>
import { ref, onMounted } from 'vue';
import { onMounted } from 'vue';
import { FormGroup, MultiSelect } from '@cloudron/pankow';
import UsersModel from '../models/UsersModel.js';
import GroupsModel from '../models/GroupsModel.js';
defineProps(['hasFtp']);
const usersModel = UsersModel.create();
const groupsModel = GroupsModel.create();
defineProps(['hasFtp', 'users', 'groups']);
const accessRestriction = defineModel('acl');
const users = ref([]);
const groups = ref([]);
onMounted(async () => {
let [error, result] = await usersModel.list();
if (error) return console.error(error);
result.forEach(u => u.username = u.username || u.email);
users.value = result;
[error, result] = await groupsModel.list();
if (error) return console.error(error);
groups.value = result;
});
</script>
@@ -32,7 +16,7 @@ onMounted(async () => {
<div>
<FormGroup>
<label>{{ $t('app.accessControl.operators.title') }} <sup><a href="https://docs.cloudron.io/apps/#operators" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
<div description>{{ $t('app.accessControl.operators.description') }} <span v-if="hasFtp">{{ $t('app.accessControl.userManagement.descriptionSftp') }}</span></div>
</FormGroup>
<div style="margin-top: 10px; margin-left: 20px; display: flex; gap: 10px;">
@@ -1,8 +1,8 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
import ProfileModel from '../../models/ProfileModel.js';
import ProfileModel from '../models/ProfileModel.js';
const emit = defineEmits([ 'success' ]);
@@ -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>
@@ -5,8 +5,7 @@ const i18n = useI18n();
const t = i18n.t;
import { ref, useTemplateRef } from 'vue';
import { Dialog, TextInput, FormGroup, Button, InputGroup } from '@cloudron/pankow';
import { copyToClipboard } from '@cloudron/pankow/utils';
import { Dialog, TextInput, ClipboardButton, FormGroup, Button, InputGroup } from '@cloudron/pankow';
import UsersModel from '../models/UsersModel.js';
const usersModel = UsersModel.create();
@@ -34,13 +33,9 @@ async function onSend() {
dialog.value.close();
}
function onCopyLink() {
copyToClipboard(resetLink.value);
window.pankow.notify({ type: 'success', text: 'Copied to clipboard!' });
}
defineExpose({
async open(u) {
u = JSON.parse(JSON.stringify(u)); // make a copy
user.value = u;
formError.value = '';
email.value = u.fallbackEmail || u.email;
@@ -64,7 +59,7 @@ defineExpose({
<label class="control-label">{{ $t('users.passwordResetDialog.descriptionLink') }}</label>
<InputGroup>
<TextInput id="passwordResetLinkInput" style="flex-grow: 1;" v-model="resetLink" readonly/>
<Button tool @click="onCopyLink()" icon="fa fa-clipboard" />
<ClipboardButton :value="resetLink" />
</InputGroup>
</FormGroup>
+11 -3
View File
@@ -14,12 +14,20 @@ const udpPorts = defineModel('udp');
<div v-for="ports in [ tcpPorts, udpPorts ]" :key="ports">
<FormGroup v-for="(port, key) in ports" :key="key" style="margin-top: 10px;">
<Checkbox :label="port.title" v-model="port.enabled" />
<small>{{ port.description + '.' + (port.portCount >=1 ? (port.portCount + ' ports. ') : '') }}</small>
<small>{{ port.description + (port.portCount > 1 ? ('. ' + port.portCount + ' ports. ') : '') }}</small>
<small v-show="port.readOnly">{{ $t('appstore.installDialog.portReadOnly') }}</small>
<small class="has-error" v-if="error.port === port.value">Port already taken {{ port }}</small>
<NumberInput v-model="port.value" :disabled="!port.enabled" :min="1"/>
<div class="warning-label" v-show="domainProvider === 'cloudflare'">{{ $t('appstore.installDialog.cloudflarePortWarning') }}</div>
<div class="warning-label" v-show="domainProvider === 'manual'">{{ $t('appstore.installDialog.cloudflarePortWarning') }}</div>
<!-- 3276860999 (inclusive) is the usual ephemeral port range -->
<div class="warning-label" v-if="[port.value, port.value + port.portCount].some(p => p >= 32768 && p <= 60999)">{{ $t('appstore.installDialog.ephemeralPortWarning') }} <sup><a href="https://docs.cloudron.io/apps/#port-bindings" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup></div>
<div class="warning-label" v-if="domainProvider === 'cloudflare'">{{ $t('appstore.installDialog.cloudflarePortWarning') }}</div>
</FormGroup>
</div>
</template>
<style scoped>
.pankow-form-group small {
display: block;
margin-bottom: 0.5rem;
}
</style>
@@ -1,9 +1,8 @@
<script setup>
import { ref, useTemplateRef } from 'vue';
import { marked } from 'marked';
import { Dialog } from '@cloudron/pankow';
import { stripSsoInfo } from '../utils.js';
import { renderSafeMarkdown, stripSsoInfo } from '../utils.js';
const dialog = useTemplateRef('dialog');
const app = ref(null);
@@ -48,13 +47,13 @@ defineExpose({
</div>
</div>
<div v-if="app.manifest.postInstallMessage" v-html="marked.parse(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
<div v-if="app.manifest.postInstallMessage" v-html="renderSafeMarkdown(stripSsoInfo(app.manifest.postInstallMessage, app.sso))"></div>
<div class="app-info-checklist" v-show="hasPendingChecklistItems">
<label class="control-label">{{ $t('app.appInfo.checklist') }}</label>
<div v-for="(item, key) in app.checklist" :key="key">
<div class="checklist-item" v-show="!item.acknowledged">
<span v-html="marked.parse(item.message)"></span>
<span v-html="renderSafeMarkdown(item.message)"></span>
</div>
</div>
</div>
@@ -1,9 +1,9 @@
<script setup>
import { ref, useTemplateRef, computed } from 'vue';
import { ref, useTemplateRef } from 'vue';
import { EmailInput, PasswordInput, Dialog, FormGroup } from '@cloudron/pankow';
import { isValidEmail } from '@cloudron/pankow/utils';
import ProfileModel from '../../models/ProfileModel.js';
import ProfileModel from '../models/ProfileModel.js';
const emit = defineEmits([ 'success' ]);
@@ -15,15 +15,19 @@ const busy = ref (false);
const email = ref('');
const password = ref('');
const isFormValid = computed(() => {
if (!isValidEmail(email.value)) return false;
if (!password.value) return false;
const form = useTemplateRef('form');
const isFormValid = ref(false);
function checkValidity() {
isFormValid.value = form.value ? form.value.checkValidity() : false;
if (isFormValid.value) {
if (!isValidEmail(email.value)) isFormValid.value = false;
}
}
return true;
});
async function onSubmit() {
if (!isFormValid.value) return;
if (!form.value.reportValidity()) return;
busy.value = true;
formError.value = {};
@@ -56,6 +60,8 @@ defineExpose({
busy.value = false;
formError.value = {};
dialog.value.open();
setTimeout(checkValidity, 100); // update state of the confirm button
}
});
@@ -73,21 +79,21 @@ defineExpose({
reject-style="secondary"
@confirm="onSubmit"
>
<form @submit.prevent="onSubmit">
<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>
+38 -3
View File
@@ -11,6 +11,10 @@ defineProps({
type: String,
default: `${API_ORIGIN}/api/v1/cloudron/avatar`,
},
cloudronName: {
type: String,
default: 'Cloudron',
}
});
</script>
@@ -20,11 +24,13 @@ defineProps({
<div class="public-page-layout-root">
<div class="public-page-layout-left pankow-no-mobile" :style="{ 'background-image': `url('${API_ORIGIN}/api/v1/cloudron/background')` }">
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
<div class="cloudron-name">{{ cloudronName }}</div>
</div>
<div class="public-page-layout-right">
<div class="public-page-layout-mobile-logo">
<img class="cloudron-avatar" width="128" height="128" :src="iconUrl"/>
<div class="cloudron-name">{{ cloudronName }}</div>
</div>
<div class="public-page-layout-right-slot">
<slot></slot>
@@ -88,11 +94,25 @@ defineProps({
align-items: center;
}
.public-page-layout-left img {
margin-bottom: 20%;
@media (prefers-color-scheme: dark) {
.public-page-layout-left {
background-color: rgba(255,255,255,0.1);
}
}
.public-page-layout-left .cloudron-avatar {
margin-bottom: 20px;
border-radius: 10px;
}
.public-page-layout-left .cloudron-name {
font-family: var(--font-family--header);
font-weight: 400;
font-size: 1.75em;
margin-bottom: 1rem;
text-align: center;
}
.public-page-layout-right {
flex-basis: 70%;
display: flex;
@@ -121,6 +141,7 @@ defineProps({
@media (max-width: 576px) {
.public-page-layout-mobile-logo {
display: block;
text-align: center;
}
.public-page-layout-right {
@@ -133,11 +154,25 @@ defineProps({
padding-right: 20px;
justify-content: start;
flex-basis: unset;
gap: 20px;
text-align: center;
gap: 2rem;
}
.public-page-layout-right-slot {
max-width: unset;
text-align: left;
}
.cloudron-avatar {
border-radius: 10px;
width: 96px;
height: 96px;
}
}
@media (max-width: 576px) {
.actions {
align-items: unset;
}
}
@@ -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>
@@ -0,0 +1,77 @@
<script setup>
import { ref } from 'vue';
import { Icon } from '@cloudron/pankow';
const visible = ref(false);
const success = ref(false);
const TIMEOUT = 1500;
let timeoutId;
defineExpose({
success() {
clearTimeout(timeoutId);
success.value = true;
visible.value = true;
timeoutId = setTimeout(() => {
visible.value = false;
}, TIMEOUT);
},
error() {
clearTimeout(timeoutId);
success.value = false;
visible.value = true;
timeoutId = setTimeout(() => {
visible.value = false;
}, TIMEOUT);
}
});
</script>
<template>
<Transition name="bounce">
<div class="save-indicator" v-if="visible" :class="{ success: success, error: !success }"><Icon :icon="success ? 'fa-solid fa-check' : 'fa-solid fa-xmark'"/></div>
</Transition>
</template>
<style scoped>
.save-indicator {
position: absolute;
right: -10px;
}
.save-indicator.success {
color: var(--pankow-color-success);
}
.save-indicator.error {
color: var(--pankow-color-danger);
}
.bounce-enter-active {
animation: bounce-in 0.5s;
}
.bounce-leave-active {
animation: bounce-in 0.5s reverse;
}
@keyframes bounce-in {
0% {
transform: scale(0);
}
50% {
transform: scale(1.5);
}
100% {
transform: scale(1);
}
}
</style>
+37 -4
View File
@@ -1,6 +1,10 @@
<script setup>
import { inject } from 'vue';
import { inject, useTemplateRef, ref, onMounted, onUnmounted } from 'vue';
const mobileFilterBar = useTemplateRef('mobileFilterBar');
const isMobile = ref(false);
defineProps({
title: String,
@@ -16,18 +20,34 @@ function onTitleBadge() {
subscriptionRequiredDialog.value.open();
}
function checkForMobile() {
isMobile.value = window.innerWidth <= 576;
}
onMounted(() => {
checkForMobile();
window.addEventListener('resize', checkForMobile);
});
onUnmounted(() => {
window.removeEventListener('resize', checkForMobile);
});
</script>
<template>
<div class="section">
<h2 class="section-header">
<div>
<slot name="header-title">{{ title }}</slot>
<slot name="header-title-extra"></slot>
<div class="section-header-title-text">
<slot name="header-title">{{ title }}</slot>
<slot name="header-title-extra"></slot>
</div>
<div class="section-header-title-badge" v-if="titleBadge" @click="onTitleBadge()">{{ titleBadge }}</div>
</div>
<div><slot name="header-buttons"></slot></div>
<div><Teleport :disabled="!isMobile" :to="mobileFilterBar"><slot name="filter-bar"></slot></Teleport><slot name="header-buttons"></slot></div>
</h2>
<div class="section-mobile-filter-bar" v-show="isMobile && $slots['filter-bar']" ref="mobileFilterBar"></div>
<hr class="section-divider"/>
<div class="section-body">
<slot></slot>
@@ -59,6 +79,11 @@ function onTitleBadge() {
display: flex;
gap: 6px;
flex-wrap: wrap;
}
.section-header-title-text {
display: flex;
gap: 6px;
align-items: baseline;
}
@@ -80,6 +105,7 @@ function onTitleBadge() {
margin-bottom: 15px;
padding: 10px 15px;
padding-bottom: 25px;
overflow: hidden;
}
.section-header-title-badge {
@@ -94,4 +120,11 @@ function onTitleBadge() {
cursor: pointer;
}
.section-mobile-filter-bar {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin: 10px 15px;
}
</style>
+20 -18
View File
@@ -5,6 +5,7 @@ import { marked } from 'marked';
import { Button, PasswordInput, FormGroup, TextInput } from '@cloudron/pankow';
import PublicPageLayout from '../components/PublicPageLayout.vue';
import ProfileModel from '../models/ProfileModel.js';
import { startAuthFlow } from '../utils.js';
const profileModel = ProfileModel.create();
@@ -36,8 +37,17 @@ 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;
if (!form.value.reportValidity() || !isFormValid.value) return;
busy.value = true;
formError.value = {};
@@ -80,7 +90,7 @@ async function onSubmit() {
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = result.accessToken;
dashboardUrl.value = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
dashboardUrl.value = await startAuthFlow('cid-webadmin', '');
busy.value = false;
mode.value = MODE.DONE;
@@ -104,17 +114,15 @@ onMounted(async () => {
</script>
<template>
<PublicPageLayout :footerHtml="footer">
<PublicPageLayout :footer-html="footer" :cloudron-name="cloudronName">
<div>
<div v-if="mode === MODE.SETUP">
<small>{{ $t('setupAccount.welcomeTo') }}</small>
<h1>{{ cloudronName }}</h1>
<br/>
<div>{{ $t('setupAccount.description') }}</div>
<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;"/>
@@ -147,29 +155,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">
<small>{{ $t('setupAccount.welcomeTo') }}</small>
<h1>{{ cloudronName }}</h1>
<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">
<small>{{ $t('setupAccount.welcomeTo') }}</small>
<h1>{{ cloudronName }}</h1>
<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">
<small>{{ $t('setupAccount.welcomeTo') }}</small>
<h1>{{ cloudronName }}</h1>
<br/>
<h2>{{ $t('setupAccount.welcome') }}</h2>
<h3>{{ $t('setupAccount.success.title') }}</h3>
<Button :href="dashboardUrl">{{ $t('setupAccount.success.openDashboardAction') }}</Button>
</div>

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