Compare commits

..

1276 Commits

Author SHA1 Message Date
Girish Ramakrishnan 8d9043e590 logviewer: reduce it back to 100
a lot of delay with 300
2024-07-23 17:27:36 +02:00
Johannes Zellner 59c3e8817c frontend: Reduce initial logs to 300 lines only 2024-07-23 16:33:56 +02:00
Girish Ramakrishnan 3132b3035a 8.0.2 changes 2024-07-23 08:34:26 +02:00
Girish Ramakrishnan 7ebf5ca16a Bring back upload route to keep e2e happy
let's maybe remove it in next release
2024-07-23 08:28:44 +02:00
Johannes Zellner d96f132dc0 frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines 2024-07-22 23:55:47 +02:00
Girish Ramakrishnan b26ff08a3c shell: copy over code and signal values from cp object 2024-07-22 21:24:27 +02:00
Girish Ramakrishnan 44678cf5f1 sshfs: if remote copy fails, fallback to sshfs based copy
remote copy can file if there is no cp in the remote . for example,
if it was a windows server.
2024-07-22 20:53:19 +02:00
Girish Ramakrishnan 5084ee761e update postgresql conf notes 2024-07-22 18:53:51 +02:00
Girish Ramakrishnan 91f50ae949 mysql: add template custom.cnf 2024-07-22 18:53:51 +02:00
Johannes Zellner 15f04edcf1 frontend: update dependencies 2024-07-22 18:00:33 +02:00
Johannes Zellner 01945675ed Check if addons exists in database import 2024-07-22 16:45:13 +02:00
Johannes Zellner 185c16c3e2 remove apps upload api in favor of sftp container api 2024-07-22 16:20:15 +02:00
Johannes Zellner d25814b84b Remove stray console.log 2024-07-22 15:07:43 +02:00
Girish Ramakrishnan a09a3fd012 postgresql: add template custom.conf 2024-07-22 14:44:23 +02:00
Johannes Zellner 871fd83148 Use new sftp service image without multipart file upload 2024-07-22 13:29:56 +02:00
Girish Ramakrishnan dd8bc493e7 postgresql: add custom.conf include 2024-07-22 12:50:23 +02:00
Johannes Zellner 44d3baf51a dashboard: show longer pretty datetime for backups 2024-07-21 20:45:34 +02:00
Girish Ramakrishnan c85c0558b9 multipart: cleanup files after reading their contents
one idea is just use express.raw() . however, we have to implement some
file size limit there.

one case this does not handle is aborted uploads from a box.service restart.
for this rare case, a server reboot will clean up /tmp anyway.
2024-07-19 23:11:26 +02:00
Girish Ramakrishnan 7f11699fac remove urlencoded
we don't use this in our API
2024-07-19 22:44:22 +02:00
Girish Ramakrishnan 525e48ae59 json middleware is part of Express v4.16.0 2024-07-19 22:26:24 +02:00
Johannes Zellner a6369a7dde Fix linter error 2024-07-19 22:24:34 +02:00
Girish Ramakrishnan d5ea99603f backups: give is a low oomScoreAdjust to not get killed 2024-07-19 13:05:09 +02:00
Girish Ramakrishnan 083432cbfe test: add EnsureFileSizeStream test 2024-07-18 15:39:45 +02:00
Girish Ramakrishnan dbbce4160d tgz: underflow/overflow proxy stream
In tar, the entry header contains the file size. If we don't provide it those many bytes, the tar will become corrupt
Linux provides no guarantee of how many bytes can be read from a file. This is the case with sqlite and log files
which are accessed by other processes when tar is in action. This class handles overflow and underflow
2024-07-18 15:13:38 +02:00
Girish Ramakrishnan 885aac69c5 tgz: handle addEntryToPack to error 2024-07-18 14:47:31 +02:00
Girish Ramakrishnan b3c301fc2a lint 2024-07-18 13:31:29 +02:00
Girish Ramakrishnan 01deb4d285 update: updateConfig can be missing values, selectively update db 2024-07-17 08:58:43 +02:00
Girish Ramakrishnan aeddaa4566 apps: rework portBindings
ports is REST API input . Map of env var to the host port
portBinding is the database structure. Map of env var to host port, count, type etc

also, rename portCount -> count in various places to keep things consistent
2024-07-17 00:25:47 +02:00
Girish Ramakrishnan eb314ef507 lint 2024-07-16 22:07:22 +02:00
Girish Ramakrishnan 620c49cf76 Fix signature of checkForPortBindingsConflict 2024-07-16 19:31:54 +02:00
Girish Ramakrishnan 6d73dfdb40 parse port count as integer 2024-07-16 19:28:22 +02:00
Girish Ramakrishnan 232cdb8cb1 cloudron-support: do cert check before site check 2024-07-16 19:21:09 +02:00
Girish Ramakrishnan fd53174099 lint 2024-07-16 10:32:37 +02:00
Girish Ramakrishnan 9bf240d83b update: handle change in secondary domains and multiDomain flag 2024-07-16 10:32:31 +02:00
Girish Ramakrishnan 421567ff14 Add to changes 2024-07-15 21:52:04 +02:00
Girish Ramakrishnan ce05008fce setup: when activated redirect to adminFqdn 2024-07-15 21:52:01 +02:00
Girish Ramakrishnan a250cb9fe2 capitalize 2024-07-15 21:08:16 +02:00
Girish Ramakrishnan 012f8bc14e setup: show message on how to redo setup 2024-07-15 21:08:13 +02:00
Girish Ramakrishnan 11dce549bd refactor init sequence 2024-07-15 18:52:22 +02:00
Girish Ramakrishnan 5b567ac941 lint 2024-07-15 16:57:47 +02:00
Girish Ramakrishnan 5b103c78e5 lint 2024-07-15 16:54:48 +02:00
Girish Ramakrishnan bc96f9c5e5 update: match the ui in settings page 2024-07-15 16:28:00 +02:00
Girish Ramakrishnan d97d82b225 settings: do not show "skip backup" when updates is blocked
https://forum.cloudron.io/topic/12092/imho-the-skip-backup-checkbox-makes-no-sense
2024-07-15 09:48:04 +02:00
Girish Ramakrishnan e9b6002f63 s3: fix exists check 2024-07-14 22:04:12 +02:00
Girish Ramakrishnan 704999a05f backups: fix incorrect "memoryLimit must be a number" error 2024-07-14 18:21:12 +02:00
Girish Ramakrishnan ba99e3b9b7 already in setup script now 2024-07-14 17:06:13 +02:00
Girish Ramakrishnan 9adeaed1b9 support: add hidden troubleshooting section 2024-07-12 16:20:25 +02:00
Girish Ramakrishnan 10bd2e930f support: remove commented out sections 2024-07-12 14:25:11 +02:00
Girish Ramakrishnan 07396c9824 Revert "support: add route to repair apps"
This reverts commit 0bab0ed748.

It's better to somehow integrate this into the dashboard ...
2024-07-12 14:22:47 +02:00
Girish Ramakrishnan bf34b13b7f cloudron-support: add --patch 2024-07-12 11:06:06 +02:00
Girish Ramakrishnan 0bab0ed748 support: add route to repair apps 2024-07-11 18:30:29 +02:00
Girish Ramakrishnan 8754a208b1 tgz: preserve mode 2024-07-11 18:10:40 +02:00
Johannes Zellner 19100c7999 dashboard: make app update section a bit more explicit 2024-07-11 17:51:20 +02:00
Johannes Zellner d98ec77abf Update German translation 2024-07-11 16:47:39 +02:00
Johannes Zellner 34c2decd91 Remove indonesian and portuguese translation as they are way below 50% 2024-07-11 15:48:43 +02:00
Johannes Zellner 09fb4ea89f Add Indonesian (lang.id) translation name 2024-07-11 15:44:38 +02:00
Girish Ramakrishnan d6bb32aead syncer: expose as async 2024-07-10 19:10:55 +02:00
Girish Ramakrishnan 3a21191fba tgz: fix error handling 2024-07-10 19:10:24 +02:00
Girish Ramakrishnan ad4e0ba9aa tests: fix storage test 2024-07-08 22:29:45 +02:00
Girish Ramakrishnan baf598099f rsync: fix upload logic to match new upload api 2024-07-08 15:21:56 +02:00
Johannes Zellner 7d017d83d6 dashboard: do not open email client setup if disabled 2024-07-08 15:19:00 +02:00
Girish Ramakrishnan 7911780a16 const 2024-07-08 13:18:22 +02:00
Girish Ramakrishnan 1dc6b40a68 tgz: extract using tar-stream directly
we used have a fork of tar-fs. using tar-stream directly gives us
more control
2024-07-08 13:06:56 +02:00
Girish Ramakrishnan dd9e6e63ad apptask: only delete image if it is different 2024-07-08 12:59:34 +02:00
Girish Ramakrishnan 30633e7820 lint 2024-07-08 10:47:07 +02:00
Girish Ramakrishnan acfc67ed0a backuptask: typo in usage of getAvailableSize 2024-07-08 10:46:28 +02:00
Girish Ramakrishnan a99a8ef382 services: fix crash because of missing safe() 2024-07-08 10:30:10 +02:00
Girish Ramakrishnan 7aec713e6c shell: fix streaming of stdout/stderr with sudo 2024-07-08 10:09:00 +02:00
Girish Ramakrishnan 60c4dd3875 sudo: add explicit captureStdout flag 2024-07-08 09:58:25 +02:00
Girish Ramakrishnan 7d8ba8d42c tests: datalayout 2024-07-07 20:23:32 +02:00
Girish Ramakrishnan 7ff7842441 lint 2024-07-05 21:56:33 +02:00
Girish Ramakrishnan bcf497b460 translation.js -> translations.js
kept confusing my why i can't find this file! this is in line
with the rest of our code
2024-07-05 12:45:27 +02:00
Girish Ramakrishnan bf51a60986 change args of translation.translate 2024-07-05 12:42:33 +02:00
Girish Ramakrishnan 41809d1ca8 mailer: format is not used 2024-07-05 12:20:30 +02:00
Girish Ramakrishnan acb1445270 make these const 2024-07-05 11:41:07 +02:00
Girish Ramakrishnan 86530df37e mailer: add html version of test mail 2024-07-05 11:07:51 +02:00
Girish Ramakrishnan b64b513b14 Revert "use node-tar for extract"
This reverts commit 285feb4f8b.
2024-07-05 09:26:38 +02:00
Girish Ramakrishnan 285feb4f8b use node-tar for extract
we will switch over our tgz module to node-tar. Main advantage is that
it is used by npm. Currently, we have our own fork to ignore stat errors
in the other module.

unfortunately, I cannot get this to work with the create logic. It doesn't
support path modification - https://github.com/isaacs/node-tar/issues/271
so, will revert this immediately and keep this for future
2024-07-05 09:26:28 +02:00
Girish Ramakrishnan c6f4395578 Fix deprecation of Buffer.slice 2024-07-05 09:26:28 +02:00
Johannes Zellner 4981854c7f dashboard: do not duplicate app info in update section 2024-07-04 16:57:54 +02:00
Girish Ramakrishnan 65f6ff35e0 Update translations 2024-07-04 09:28:18 +02:00
Johannes Zellner d892cc5763 Add comment how to debug the openid provider 2024-07-03 11:33:58 +02:00
Johannes Zellner d122ece8e9 Attempt server side copy on sshfs via ssh exec
only so far tested agains hetzner storage boxes which apparently run BSD
unix tools
2024-07-02 19:51:34 +02:00
Girish Ramakrishnan a363e508b6 ami: disable route53
we got an email from AWS team that their policy prevents collection
of AMI credentials in AMI images
2024-07-02 16:09:36 +02:00
Girish Ramakrishnan e481606d0e lint 2024-07-02 16:09:36 +02:00
Girish Ramakrishnan a1e2c9fd08 cloudron-support: print_system 2024-07-01 14:08:55 +02:00
Girish Ramakrishnan f5931abdeb cloudron-support: print ubuntu version 2024-07-01 13:38:18 +02:00
Girish Ramakrishnan 4c9e05b08f cloudron-support: add netplan and product info 2024-07-01 08:24:01 +02:00
Girish Ramakrishnan 9c34727e88 cloudron-support: dig does not return error on SERVFAIL 2024-07-01 07:57:21 +02:00
Girish Ramakrishnan 939cd94ebb typo 2024-07-01 07:55:08 +02:00
Johannes Zellner 4a33415b06 Set notes on existing apps to empty string if NULL 2024-06-29 09:52:41 +02:00
Girish Ramakrishnan 082e659c7b disable rpcbind
rpcbind is required for NFSv2 and v3 . It seems this gets installed
by nfs-common. It was never used by us since the firewall blocks
port 111 anyways.

NFSv3 needs 2049 for NFS, 111 for portmap, 635 for mountd, 4045 for NLM, 4046 for NSM, 4049 for rquota ...

NFSv4 works better because there's just a single target port, plus the "heartbeat" of lease renewal would keep the TCP/IP session alive.

https://serverfault.com/questions/949127/nfs-client-firewall-settings-and-rpcbind
https://docs.redhat.com/en/documentation/Red_Hat_Enterprise_Linux/6/html/Storage_Administration_Guide/s2-nfs-methodology-portmap.html#s2-nfs-methodology-portmap
https://community.netapp.com/t5/Tech-ONTAP-Blogs/NFSv3-and-NFSv4-What-s-the-difference/ba-p/441316
2024-06-27 20:37:08 +02:00
Girish Ramakrishnan a8059c49e9 lint 2024-06-27 16:50:31 +02:00
Johannes Zellner f7b14b2ee8 dashboard: only show postinstall if notes are not just empty 2024-06-27 16:20:19 +02:00
Johannes Zellner 581a294af1 dashboard: give the checklist done button some space 2024-06-27 13:58:58 +02:00
Johannes Zellner 40e8ba38f0 dashboard: fix app grid item tooltip 2024-06-27 13:49:20 +02:00
Girish Ramakrishnan 65f4ec0f43 cloudron-support: check dns now and not just unbound 2024-06-27 12:58:37 +02:00
Johannes Zellner 8748ba1226 dashboard: show number of pending checklist items 2024-06-25 20:38:24 +02:00
Johannes Zellner 2ad8ee18a0 dashboard: keep checklist items up to date 2024-06-25 18:01:31 +02:00
Johannes Zellner 8b9dc5a6bf dashboard: fix eventlog for mailbox changes 2024-06-25 17:54:46 +02:00
Johannes Zellner a1a6570ee3 dashboard: show mailbox displayname in eventlog 2024-06-25 17:27:37 +02:00
Girish Ramakrishnan 6c68f7da2e apps: updateTime should be null if never updated
"TIMESTAMP NULL" is an attribute modifier to make the column nullable.
Without it, if you assign null, the timestamp becomes the current time!
2024-06-25 17:24:02 +02:00
Girish Ramakrishnan ccd5f6c2e5 app: move the installation time down 2024-06-25 17:24:02 +02:00
Johannes Zellner 73b20ae809 dashboard: do not show sso info in postinstall dialog 2024-06-25 16:56:33 +02:00
Johannes Zellner a4dd6cc928 dashboard: remove checkbox in background image branding 2024-06-25 16:26:06 +02:00
Girish Ramakrishnan 6f37bde55d import: add prefix support
it is incorrect to ignore the prefix. the keys (for s3) and permissions (managed mounts)
might work only inside the prefix.
2024-06-25 13:41:21 +02:00
Girish Ramakrishnan 8f1f3cea18 doc: add import fields 2024-06-25 13:41:21 +02:00
Johannes Zellner f715e21306 oidc: raise login event on consent not login form itself 2024-06-25 13:24:46 +02:00
Girish Ramakrishnan d9b478cf1f rename setupStorage to setupManagedStorage 2024-06-25 13:06:40 +02:00
Girish Ramakrishnan 36a768eb60 backups: document structure of backupConfig
I keep forgetting this, let's write this up once and for all
2024-06-25 13:06:40 +02:00
Johannes Zellner 0ebe6e545d dashboard: fix mobile dark-mode for back action 2024-06-25 12:26:18 +02:00
Johannes Zellner 5fb295e044 frontend: Update dependencies 2024-06-25 12:18:54 +02:00
Johannes Zellner be7e11a4f6 frontend: use new busy state on the directoryview 2024-06-25 11:59:14 +02:00
Girish Ramakrishnan d13bf9ac74 eventlog: fix display of directoryserver login event 2024-06-24 21:42:03 +02:00
Girish Ramakrishnan e909b6e643 mail: mail manager role cannot change server location 2024-06-24 21:31:29 +02:00
Girish Ramakrishnan 9555a93ddc redis: don't send password when noPassword 2024-06-24 21:25:48 +02:00
Johannes Zellner 1d9ad35019 dashboard: show postinstall message for admin notes unless set 2024-06-24 19:59:02 +02:00
Johannes Zellner 78aee78d9c dashboard: better login page background image label 2024-06-24 19:34:32 +02:00
Johannes Zellner 4b96d5879c dashboard: show who and when a checklist item was acked 2024-06-24 19:11:38 +02:00
Johannes Zellner 20396a8c7d Adjust checklist item api to support audits trail 2024-06-24 19:09:03 +02:00
Johannes Zellner 8510b12841 dashboard: show/hide done checklist items 2024-06-24 16:53:57 +02:00
Girish Ramakrishnan 345f9541fe mongodb: do not apply memory limit when no avx 2024-06-23 21:06:30 +02:00
Johannes Zellner c1c864ced7 dashboard: show app install time in info section 2024-06-22 15:50:50 +02:00
Girish Ramakrishnan 7a440a32d1 ldap connector: make auto-create true by default 2024-06-22 10:35:04 +02:00
Johannes Zellner ef1431f89b dashboard: sort by location in app list by default 2024-06-21 21:18:22 +02:00
Johannes Zellner 57cf0ec074 dashboard: improve app list view layout 2024-06-21 21:17:05 +02:00
Girish Ramakrishnan d795507ddd Update translations 2024-06-21 18:07:00 +02:00
Girish Ramakrishnan c3aafb2979 even more changes 2024-06-21 17:09:17 +02:00
Johannes Zellner 93d4472932 dashboard: improve list view column headers 2024-06-21 16:54:32 +02:00
Johannes Zellner 69934be88c frontend: remove home icon in filemanager 2024-06-21 16:45:56 +02:00
Johannes Zellner 8638bfb30b dashboard: show full mailbox address in eventlog 2024-06-21 15:48:06 +02:00
Johannes Zellner 5b3d6a3957 dashboard: do not show number of active app filters 2024-06-21 15:39:04 +02:00
Johannes Zellner 94dd0644d0 frontend: Mention /app/data in filemanager breadcrumbs 2024-06-21 15:39:04 +02:00
Girish Ramakrishnan f089329e12 more changes 2024-06-21 15:21:59 +02:00
Johannes Zellner 8554d374c9 dashboard: eventlog fix cpu quota display 2024-06-21 14:49:11 +02:00
Johannes Zellner 424ec1c90d dashboard: show pretty memory limits in eventlog 2024-06-21 14:43:10 +02:00
Johannes Zellner ce2f1b4170 dashboard: fix colspan typo 2024-06-21 14:42:58 +02:00
Johannes Zellner ce1146a9ef frontend: set dark background as early as possible to avoid white flashing 2024-06-21 10:57:21 +02:00
Johannes Zellner f065821587 frontend: slightly improve local development 2024-06-21 10:57:01 +02:00
Johannes Zellner 18c518f385 dashboard: improve postinstall dialog header 2024-06-20 15:57:45 +02:00
Johannes Zellner 6ba1953acb Revert "lint"
This reverts commit 36887abf88.
2024-06-20 15:47:49 +02:00
Johannes Zellner 324ee4641f dashboard: ensure configure button is not overlapping the checklist indicator 2024-06-20 14:58:45 +02:00
Johannes Zellner 9ee4490498 frontend: also update the package lock file 2024-06-20 13:36:36 +02:00
Johannes Zellner 0fa32c9572 frontend: update pankow for dark mode fixes 2024-06-20 13:36:13 +02:00
Johannes Zellner b3e5563e15 dashboard: checklist bubble goes right 2024-06-20 13:15:15 +02:00
Johannes Zellner 55038dee51 frontend: update dependencies 2024-06-20 13:06:41 +02:00
Girish Ramakrishnan b54eaf2964 more changes 2024-06-20 13:02:54 +02:00
Johannes Zellner 98e97a0f9b dashboard: show pending admin checklist in apps list/grid 2024-06-18 16:37:21 +02:00
Johannes Zellner f15b4a4f4b dashboard: Better state handling for app info notes 2024-06-18 15:49:52 +02:00
Johannes Zellner bd7641f502 frontend: fix autoscroll in logviewer 2024-06-17 18:25:50 +02:00
Girish Ramakrishnan 2d04ec2308 appstore: check response is an image 2024-06-15 17:31:49 +02:00
Girish Ramakrishnan ba0ab68f50 appstore: validate the id and the version 2024-06-15 17:11:11 +02:00
Girish Ramakrishnan 825fe21bd9 lint 2024-06-15 17:03:54 +02:00
Girish Ramakrishnan 072ca73259 Update manifestformat for more permissive ids 2024-06-15 12:38:53 +02:00
Girish Ramakrishnan b333a136e8 cloudron-support: some newlines 2024-06-14 17:08:23 +02:00
Girish Ramakrishnan e34cf7fd77 cloudron-support: more fixes to --recreate-docker 2024-06-14 13:04:40 +02:00
Girish Ramakrishnan 1c7099b3f0 typo 2024-06-14 11:45:15 +02:00
Girish Ramakrishnan d74ee441ac oidc: add doc link to various endpoints 2024-06-14 11:37:42 +02:00
Girish Ramakrishnan 7bad90009e cloudron-support: better recreate-docker 2024-06-14 11:02:53 +02:00
Girish Ramakrishnan 424bc588f6 cloudron-support: implement --recreate-docker 2024-06-13 18:51:11 +02:00
Girish Ramakrishnan 852e1e1687 cloudron-support: implement --recreate-containers 2024-06-13 17:55:22 +02:00
Girish Ramakrishnan 649c06b641 notification: do not send login notification for external users 2024-06-13 16:55:35 +02:00
Girish Ramakrishnan 6b4df0bd65 lint 2024-06-13 16:55:35 +02:00
Johannes Zellner e67324b05c Update translations 2024-06-13 16:02:13 +02:00
Johannes Zellner d688f5e080 dashboard: slightly better admin notes edit handling 2024-06-13 15:48:35 +02:00
Johannes Zellner c3f9d688f1 frontend: more dark mode fixes 2024-06-13 12:19:03 +02:00
Johannes Zellner 7affc6e987 frontend: initial dark mode for filemananger 2024-06-12 19:57:21 +02:00
Johannes Zellner 9f26608681 frontend: remove unused css 2024-06-12 19:37:10 +02:00
Girish Ramakrishnan d34b102e52 mandatory2fa: fix workflow when using external LDAP
* Always allow the mandatory 2fa setting to be saved
* Show warning for user if they have no 2fa setup and if not external 2fa
* If they get locked out anyway, they have to use CLI tool
* redirect for mandatory 2fa only if not external 2fa as well
2024-06-12 12:26:40 +02:00
Girish Ramakrishnan 077f95049e test: user directory profile route 2024-06-12 11:11:56 +02:00
Girish Ramakrishnan b570f2f77d userdirectory: add eventlog entry 2024-06-12 10:52:10 +02:00
Girish Ramakrishnan b4e7e394c3 split routes and model code into user-directory.js 2024-06-12 10:49:01 +02:00
Girish Ramakrishnan e1f87161a8 mandatory 2fa: revoke oidc sessions of non-2fa users 2024-06-12 10:16:49 +02:00
Johannes Zellner 57bf3709f3 proxyauth: also preserve request uri for proxyauth addon apps 2024-06-11 19:15:58 +02:00
Johannes Zellner 9d258d33cf Use 127.0.0.53 as a resolver for nginx 2024-06-11 18:59:19 +02:00
Johannes Zellner 62e322c451 proxyauth: stash path and query for further use in the session 2024-06-11 18:59:19 +02:00
Johannes Zellner 9a04ee2d1f frontend: update pankow for inputdialog fixes 2024-06-11 18:59:19 +02:00
Girish Ramakrishnan 5852fac71a backups: validate mountOptions is an object for managed providers 2024-06-11 14:40:54 +02:00
Johannes Zellner f315a378dc frontend: also selectively show filemanager in terminal if localstorage addon exists 2024-06-11 14:01:57 +02:00
Johannes Zellner dcee792aaa frontend: show filemanager,terminal and restart buttons only where it makes sense 2024-06-11 14:01:57 +02:00
Girish Ramakrishnan d0df897f93 typo 2024-06-11 14:01:13 +02:00
Johannes Zellner 915e3ecc94 dashboard: Show eventlog source IP in details 2024-06-11 12:05:58 +02:00
Johannes Zellner 76dadd1f8b dashboard: do not show eventlog source ip - too noisy with ipv6 2024-06-11 11:40:04 +02:00
Johannes Zellner 73fdcae916 dashboard: Show eventlog source in app view 2024-06-11 11:36:49 +02:00
Girish Ramakrishnan 941162a05f networking: ipv4 configuration can be missing 2024-06-10 19:18:11 +02:00
Johannes Zellner 22b8ec6144 frontend: fixes to use pankow-viewers 2024-06-10 11:59:34 +02:00
Girish Ramakrishnan a0c7f3f896 pin superagent to 9.0.1
9.0.2 broke basic ipv6 queries -https://github.com/ladjs/superagent/pull/1805
2024-06-10 11:28:33 +02:00
Girish Ramakrishnan 692be297b3 app proxy: protect code accessing containerId 2024-06-10 11:23:29 +02:00
Girish Ramakrishnan db3eabcd2f mail: haraka limits plugin fix
https://github.com/haraka/haraka-plugin-limit/pull/63
2024-06-10 09:43:14 +02:00
Johannes Zellner fee78bb488 frontend: ask user if terminal should be really closed on ctrl+w 2024-06-09 15:35:10 +02:00
Johannes Zellner dda6f43b8a frontend: update dependencies 2024-06-09 15:27:21 +02:00
Girish Ramakrishnan b5fad74ea0 Update packages 2024-06-08 22:37:20 +02:00
Girish Ramakrishnan ef42106a16 redis: cleanup tmp rdb files 2024-06-08 22:34:30 +02:00
Girish Ramakrishnan bba1922120 dockerproxy: fix test 2024-06-08 22:26:34 +02:00
Girish Ramakrishnan f386c326e2 apptask: only move app uses localstorage addon 2024-06-06 16:26:02 +02:00
Girish Ramakrishnan 3b26f6f5ea use optional chaining 2024-06-06 15:19:19 +02:00
Girish Ramakrishnan 52701e1173 add note 2024-06-06 15:16:04 +02:00
Girish Ramakrishnan 3c7d24916c backups: show generic error in the dialog 2024-06-06 11:41:33 +02:00
Girish Ramakrishnan 4fac0cb535 const fixes 2024-06-06 11:33:42 +02:00
Girish Ramakrishnan 00f6ef7603 backups: cannot change config in demo mode 2024-06-06 11:32:15 +02:00
Girish Ramakrishnan 556b9fe20c test: fix updatechecker test 2024-06-03 19:34:22 +02:00
Girish Ramakrishnan 3dcd0975f7 test: fix various routes tests
* system/disks routes is gone
* provision routes now return 405 instead of 409 when re-setup/re-activated
2024-06-03 19:27:23 +02:00
Girish Ramakrishnan db7e88e302 Update translations 2024-06-03 15:35:39 +02:00
Girish Ramakrishnan 9c5fb2823c Update test packages 2024-06-03 10:38:53 +02:00
Girish Ramakrishnan d12b2ae2db Revert "database: change db fields"
This reverts commit 0227ae1d96.

unclear why this doesn't work now
2024-06-03 10:37:59 +02:00
Girish Ramakrishnan 449d68122b progressstream: add stats func 2024-05-31 16:55:41 +02:00
Girish Ramakrishnan 0227ae1d96 database: change db fields 2024-05-31 16:55:41 +02:00
Girish Ramakrishnan 265e58e5cb ovh: add rbx region 2024-05-29 16:53:04 +02:00
Girish Ramakrishnan 9054f30aef lint 2024-05-25 13:42:44 +02:00
Girish Ramakrishnan 36887abf88 lint 2024-05-25 13:10:53 +02:00
Girish Ramakrishnan 4ca5fcf472 postgresql: set process limits 2024-05-23 21:50:47 +02:00
Girish Ramakrishnan c4b01dea22 mail: rename fields in spam acl 2024-05-23 17:04:01 +02:00
Girish Ramakrishnan 6d4cc4a6b8 const fixes 2024-05-23 10:58:59 +02:00
Girish Ramakrishnan 4229e9921c blacklisted -> blocked 2024-05-23 09:53:47 +02:00
Johannes Zellner 92b6a7e335 dashboard: do not crash if sysinfo cache still contains removed volume info 2024-05-22 11:39:19 +02:00
Girish Ramakrishnan cb8731b915 syslog: also replace CR (\r) 2024-05-21 16:48:29 +02:00
Johannes Zellner 0c80b7af1d dashboard: show checklist items in postinstall dialog 2024-05-20 18:13:17 +02:00
Girish Ramakrishnan 4ce9c46215 dyndns: update mail server location 2024-05-17 13:23:47 +02:00
Girish Ramakrishnan bbf402368f lint: const 2024-05-17 13:18:53 +02:00
Girish Ramakrishnan 37d1dc7c6d add to changes 2024-05-16 14:56:57 +02:00
Johannes Zellner a677dc3981 Fixup crontab tests 2024-05-15 15:17:42 +02:00
Johannes Zellner 77163cc1b2 remove legacy system/disks route 2024-05-15 14:34:30 +02:00
Girish Ramakrishnan 5d41a84fec add comment 2024-05-15 14:14:05 +02:00
Johannes Zellner 890de53b0a syslog: handle potential multiline syslog input 2024-05-15 13:45:32 +02:00
Johannes Zellner a1f2b5b696 frontend: update dependencies 2024-05-13 17:49:19 +02:00
Girish Ramakrishnan 6eda037544 lint: const fixes 2024-05-13 17:02:20 +02:00
Girish Ramakrishnan eb5b8b42dc updater: delete any old dirs from failed updates 2024-05-13 17:00:58 +02:00
Girish Ramakrishnan 4a5022d14d lint: const fixes 2024-05-13 08:43:33 +02:00
Girish Ramakrishnan 54c6f9c4f8 logs: do not push empty lines 2024-05-13 08:37:49 +02:00
Johannes Zellner dee60e9958 dashboard: improve checklist handling 2024-05-11 11:26:46 +02:00
Johannes Zellner bbefa38355 dashboard: show who installed an app in the eventlog 2024-05-10 17:56:05 +02:00
Girish Ramakrishnan 6681f2e5c8 netcup: dns fixes 2024-05-04 18:37:40 +02:00
Johannes Zellner 1728756dc4 dashboard: give appstore tiles more horizontal space 2024-05-03 12:30:26 +02:00
Johannes Zellner 1f0860e45d oidc: hide login form while initializing the view 2024-05-03 11:48:25 +02:00
Johannes Zellner 9eb91a3ae9 Update pankow 2024-05-02 18:18:34 +02:00
Johannes Zellner ad50ea5aee frontend: remove primeicons 2024-05-02 15:18:48 +02:00
Johannes Zellner 73045fd7fc frontend: update dependencies 2024-05-02 15:07:07 +02:00
Johannes Zellner 11aeccc822 frontend: remove primevue from terminal 2024-05-02 15:05:04 +02:00
Johannes Zellner 310a8c1c63 frontend: remove primevue from logsviewer 2024-05-02 13:08:56 +02:00
Johannes Zellner 23153e5b86 frontend: filemanager is now without primevue 2024-05-01 14:51:16 +02:00
Johannes Zellner 130d8a1ba0 Frontend: stop using primevue confirm service 2024-05-01 14:17:44 +02:00
Johannes Zellner 8d9ecf3352 frontend: remove unused InputText 2024-05-01 13:12:54 +02:00
Johannes Zellner 6080cfa351 frontend: replace Dialogs with pankow Dialog 2024-05-01 13:11:52 +02:00
Johannes Zellner 4e04b2075f frontend: use pankow InputDialog from prompts 2024-05-01 12:53:45 +02:00
Johannes Zellner 9f415826fd frontend: remove unused margins 2024-05-01 12:53:45 +02:00
Girish Ramakrishnan 54d92b8bf7 backups: uploadPartSize only makes sense for s3 2024-05-01 12:39:32 +02:00
Girish Ramakrishnan f1e8b91f61 backups: remove limit object from storage config
this is causing UI to get confused
2024-05-01 12:06:04 +02:00
Girish Ramakrishnan a1bd1a0fa1 domains: add list/get/del test as normal user 2024-04-30 10:36:12 +02:00
Girish Ramakrishnan b142cd5039 domains: when listing, send all fields
also remove the certificate key from responses
2024-04-30 09:47:50 +02:00
Girish Ramakrishnan b548856c29 domains: remove wildcard field check
it is part of tlsConfig object
2024-04-30 09:06:04 +02:00
Girish Ramakrishnan a0df52000a typo 2024-04-29 15:51:16 +02:00
Girish Ramakrishnan e98a1a9767 docker container can use system dns
only mail container needs unbound for dnsbl
2024-04-29 15:48:30 +02:00
Johannes Zellner ad2eaff60e frontend: use pankow Menu in filemanger 2024-04-29 15:39:56 +02:00
Johannes Zellner 3df7b74f65 dashboard: preserve path when relogin is required 2024-04-29 15:22:01 +02:00
Girish Ramakrishnan 67c1b2cb71 installer: remove custom nginx upgrade logic 2024-04-29 14:23:19 +02:00
Girish Ramakrishnan 6c0e84a31d installer: remove verbose extract 2024-04-29 14:14:36 +02:00
Girish Ramakrishnan c49a440211 init-ubuntu: resolvconf is not needed anymore
unbound is still needed since it's running but not for resolv.conf
2024-04-29 13:22:19 +02:00
Girish Ramakrishnan caedf6a8e7 remove resolvconf and enable systemd-resolved 2024-04-29 13:19:52 +02:00
Girish Ramakrishnan 203330d1b8 lint: const 2024-04-29 13:05:07 +02:00
Girish Ramakrishnan c8d66384c7 domains: check if wildcard is boolean in tlsConfig 2024-04-29 12:52:12 +02:00
Girish Ramakrishnan 74447d2690 lint 2024-04-29 12:49:20 +02:00
Girish Ramakrishnan b66ddedc86 domains: remove unused wildcard check 2024-04-29 12:45:57 +02:00
Girish Ramakrishnan 8df97de8c6 Ubuntu 24.04
* update docker to 26.0.1
* cloudron-syslog needs to have correct perms for fifo socket
2024-04-29 11:07:10 +02:00
Girish Ramakrishnan cd5cae33ce dns: switch over to systemd for the host
this changes unbound to listen to 127.0.0.150 (150 is roman CL)

we cannot only bind on docker bridge because unbound is relied
upon for the initial domain setup. docker itself is only initialized
when the platform initializes
2024-04-29 11:06:03 +02:00
Girish Ramakrishnan 608ce53e7d scripts: remove unused cloudron-logs 2024-04-29 10:21:33 +02:00
Johannes Zellner d2ae6c2353 dashboard: grid view is the default 2024-04-29 09:32:00 +02:00
Johannes Zellner 7eda1136ea oidc: starting with new .json model files is not worth a log line 2024-04-29 09:19:37 +02:00
Girish Ramakrishnan a756fa9e9b remove dead code 2024-04-28 10:52:30 +02:00
Girish Ramakrishnan afb5e5ac5d add to changes 2024-04-27 19:27:11 +02:00
Girish Ramakrishnan efa1acddd4 dns: unregister domains if type is disabled 2024-04-27 18:43:31 +02:00
Girish Ramakrishnan e00db115ad restore: fix crashes 2024-04-27 12:46:37 +02:00
Girish Ramakrishnan 366f247910 oidc: only start in set callback 2024-04-27 11:51:10 +02:00
Girish Ramakrishnan 2a6368af60 remove usage of constants.DASHBOARD_SUBDOMAIN 2024-04-27 11:10:24 +02:00
Girish Ramakrishnan 5420630453 oidc: start the server when dashboard domain is set
the activation logic has changed to use oidc flow. this requires
the oidc server to be started and available. otherwise, the redirection
after owner creation fails.
2024-04-27 11:02:50 +02:00
Girish Ramakrishnan 4e39eb89fd const 2024-04-27 10:48:23 +02:00
Girish Ramakrishnan a783944700 notfound: better error message for IP 2024-04-26 21:25:33 +02:00
Girish Ramakrishnan 8a987db177 provision: add route to detect ipv4 and ipv6 2024-04-26 20:53:32 +02:00
Girish Ramakrishnan 834a7d0f55 rename setupdns to setup 2024-04-26 20:32:23 +02:00
Girish Ramakrishnan 051bcb7819 rename setup to activation 2024-04-26 20:26:57 +02:00
Girish Ramakrishnan 126587ba82 lint: constness 2024-04-26 20:09:36 +02:00
Girish Ramakrishnan 860ebcbe6a provision: add activation guard 2024-04-26 20:06:56 +02:00
Girish Ramakrishnan 25f395ed63 Update DNS A record text 2024-04-26 19:13:34 +02:00
Girish Ramakrishnan 2da361a1f2 waitfordns: resolve and check against NS' IPv6 address 2024-04-26 19:12:53 +02:00
Johannes Zellner 4e363dc77a frontend: move more UI elements to pankow 2024-04-26 17:11:02 +02:00
Girish Ramakrishnan 23e20b9b83 waitfordns: better debugs 2024-04-26 14:46:01 +02:00
Girish Ramakrishnan e70a6ffbb9 zoneName filter is gone 2024-04-26 14:25:02 +02:00
Girish Ramakrishnan cab236123f reindent branding page 2024-04-26 14:10:12 +02:00
Johannes Zellner cab7e0d8a3 Fixup indentation in setup pages 2024-04-26 14:07:53 +02:00
Girish Ramakrishnan 2f425f8119 provision: add ipv6 config 2024-04-26 12:20:15 +02:00
Girish Ramakrishnan 017e46fa0f rename sysinfo to ipv4Config 2024-04-26 12:20:15 +02:00
Johannes Zellner 9efcd9060e frontend: Use pankow breadcrumbs 2024-04-25 19:35:54 +02:00
Girish Ramakrishnan abdd5d3e0e eslint: upgrade 2024-04-25 19:00:37 +02:00
Girish Ramakrishnan cf40346e1a eslint: add node globals 2024-04-25 18:26:35 +02:00
Girish Ramakrishnan b6d80fb443 eslint.config.js for the new eslint 2024-04-25 18:14:06 +02:00
Girish Ramakrishnan f6e4f1aefc network: ipv4 can be disabled 2024-04-25 15:50:42 +02:00
Girish Ramakrishnan dbf66b8e89 fix indent 2024-04-25 15:06:17 +02:00
Girish Ramakrishnan 53ad3902ac remove unused function 2024-04-25 15:06:17 +02:00
Girish Ramakrishnan cae2bfbdc2 domains: add desec provider 2024-04-24 21:29:42 +02:00
Girish Ramakrishnan 58d6142460 ovh: storage location has changed 2024-04-24 16:37:41 +02:00
Johannes Zellner 2ca4838ac7 dashboard: app config has new info tab 2024-04-23 17:07:30 +02:00
Girish Ramakrishnan 3787f90283 appstore: bump timeout to 60s instead of 30s
this timeout is hit on some servers (which have some networking
issue). unfortunately, this triggers a bug in superagent -
https://github.com/ladjs/superagent/issues/1801
2024-04-23 11:41:51 +02:00
Girish Ramakrishnan 9064375e25 cloudron-support: remove bad nginx configs 2024-04-23 10:00:06 +02:00
Girish Ramakrishnan 033036bd1a cloudron-support: check service uptime 2024-04-22 17:45:43 +02:00
Girish Ramakrishnan 5d74d80829 cloudron-support: check if node binary exists 2024-04-22 16:41:26 +02:00
Girish Ramakrishnan 88231e3d35 sftp: add rate limit 2024-04-21 21:04:00 +02:00
Girish Ramakrishnan 1aa683aeab add comments on the rate limits 2024-04-21 21:02:55 +02:00
Girish Ramakrishnan c2326bc5cc oidc: add rate limit for login requests 2024-04-21 20:58:12 +02:00
Girish Ramakrishnan 55db3ae517 Fix link 2024-04-21 16:48:40 +02:00
Johannes Zellner 4b0dbf0183 dashboard: localstorage has no concept of booleans 2024-04-20 22:02:05 +02:00
Johannes Zellner 2725e001a5 frontend: update dependencies 2024-04-20 14:10:34 +02:00
Johannes Zellner 02a0f65e4b dashboard: update fontawesome and sass 2024-04-20 13:41:04 +02:00
Johannes Zellner 9fd964022e dashboard: html-templates is gone 2024-04-20 13:38:47 +02:00
Johannes Zellner ec7dabc1c7 oidc: also allow login on aliased app domains 2024-04-19 19:03:23 +02:00
Girish Ramakrishnan 95eeb9ce93 s/your/the 2024-04-19 18:33:17 +02:00
Girish Ramakrishnan d137cdf881 update cron module
CronJob -> CronJob.from
CronJob(time) -> CronTime
2024-04-19 18:31:47 +02:00
Girish Ramakrishnan a926a3e8a8 update google cloud modules
https://github.com/googleapis/nodejs-storage/releases/tag/v7.0.0
2024-04-19 18:09:17 +02:00
Girish Ramakrishnan e8b3516d34 update marked 2024-04-19 18:00:02 +02:00
Girish Ramakrishnan 54e5e0cb7e update commander
https://github.com/tj/commander.js/releases/tag/v12.0.0
2024-04-19 17:56:15 +02:00
Girish Ramakrishnan baa4620523 update jsdom
https://github.com/jsdom/jsdom/releases/tag/24.0.0
2024-04-19 17:53:26 +02:00
Girish Ramakrishnan fcd1532a4d update jose
breaking changes: https://github.com/panva/jose/releases/tag/v5.0.0
2024-04-19 17:45:26 +02:00
Girish Ramakrishnan 66b768b176 Update packages 2024-04-19 17:40:42 +02:00
Girish Ramakrishnan eeae8c92d0 nodejs: update to 20.12.2 2024-04-19 17:40:42 +02:00
Johannes Zellner d35bfbb0fd dashboard: finish checklist display
pending showing acknowledged items later
2024-04-19 14:32:34 +02:00
Johannes Zellner 4516b0c57c Do not return but continue in a loop 2024-04-19 14:29:41 +02:00
Johannes Zellner 49243822af dashboard: show app checklist 2024-04-19 14:17:54 +02:00
Johannes Zellner 16521d5434 Fix updateChecklist usage 2024-04-19 12:15:13 +02:00
Girish Ramakrishnan 1afa2e87ec mailserver: a056bcfd broke mail server restart
after proxying, we never restarted the mail server

also add note that restart has to reconfigure
2024-04-19 10:48:08 +02:00
Girish Ramakrishnan 18ec929501 lint 2024-04-19 10:48:08 +02:00
Johannes Zellner 7d6636bb54 Only add checklist items if they apply due to sso state 2024-04-18 16:05:38 +02:00
Johannes Zellner 3c7e6b59f0 Add initial support for apps.checklist 2024-04-17 16:54:54 +02:00
Johannes Zellner daa8a60da2 oidc: Inject currently hardcoded CLOUDRON_OIDC_PROVIDER_NAME env var
This is designed to be used in the packages for the login button:
"Login with ${CLOUDRON_OIDC_PROVIDER_NAME}"
2024-04-17 15:06:22 +02:00
Johannes Zellner f231d51d0b Make oidc authproxy login button translatable 2024-04-17 14:21:07 +02:00
Girish Ramakrishnan 308f315ed5 troubleshoot: print box version 2024-04-17 09:26:32 +02:00
Girish Ramakrishnan a572374ad7 updatechecker: deep compare update object from appstore
When 'changelog' , 'unstable' fields change the box code is not
getting it.
2024-04-16 19:30:14 +02:00
Girish Ramakrishnan 1cf315634c appstore: check the type of unstable field 2024-04-16 19:19:27 +02:00
Johannes Zellner b0d2bdbad9 Make it login with cloudron in authproxy 2024-04-16 14:56:18 +02:00
Johannes Zellner 255fb0cac0 proxyauth: show intermediate login button page 2024-04-16 13:43:12 +02:00
Johannes Zellner c3be0018fe proxyauth: send user to oidc login instead of /login 2024-04-16 11:29:00 +02:00
Girish Ramakrishnan 37e2269387 import: add seal option 2024-04-15 22:20:04 +02:00
Girish Ramakrishnan 5dbe2ce2e4 cifs: enable seal by default 2024-04-15 22:00:28 +02:00
Johannes Zellner 1008ec4fa1 proxyauth: remove basic auth login form 2024-04-15 18:52:07 +02:00
Johannes Zellner d36d1cf1da dashboard: wait on refreshApp on submitting notes 2024-04-15 17:38:02 +02:00
Johannes Zellner 21d7438bbe proxyauth: user OpenID instead of basic auth 2024-04-15 15:59:16 +02:00
Girish Ramakrishnan caf1c37171 motd: mention troubleshooting tool 2024-04-15 13:46:44 +02:00
Girish Ramakrishnan 0a748ac78a better AVX error message 2024-04-15 10:10:13 +02:00
Johannes Zellner 76c4002a04 oidc: Add profile picture claim 2024-04-14 12:05:45 +02:00
Johannes Zellner 201a07f717 dashboard: Some dark mode fixes for list view 2024-04-12 19:51:46 +02:00
Johannes Zellner 5b2eb51511 dashboard: show app count in list view 2024-04-12 14:12:03 +02:00
Johannes Zellner 36ab5800a3 oidc: enable CORS for internal apps 2024-04-11 19:10:29 +02:00
Girish Ramakrishnan a79486275e tldjs: update rules using --tldjs-update-rules
the rules we use are 6 years old! we still need to use the public
suffix list to figure out the zone name by default for ease of use.

Domains like co.uk will only appear in the suffix list and not in the
tld list (https://www.iana.org/domains/root/db)

To verify if the list is updated:

node -e "console.log(require('tldjs').getDomain('whatever.framer.ai'))"

The above will output "whatever.framer.ai"
2024-04-11 18:28:52 +02:00
Girish Ramakrishnan 6dc70a8f3b dashboard: tld and angular-tld are not used 2024-04-11 18:08:21 +02:00
Johannes Zellner 8e990e4e0a dashboard: Set app info as default configure tab 2024-04-11 13:45:34 +02:00
Girish Ramakrishnan f11becfcc8 async'ify
crazy this has gone unnoticed for so long!
2024-04-10 18:52:39 +02:00
Johannes Zellner 8d04374764 dashboard: info tab translations 2024-04-10 18:48:20 +02:00
Johannes Zellner 87ae95aa4f Add per-app notes feature 2024-04-10 18:34:58 +02:00
Girish Ramakrishnan 0fa1ec44b1 app: add description for memory 2024-04-10 18:28:32 +02:00
Girish Ramakrishnan b4e4f26361 Rework cpuShares into cpuQuota
cpuShares is the relative weight wrt other apps. This is used when
there is contention for CPU. If we want this, maybe we implement
a UI where we show all the apps and let the user re-order them.
As it stands, it is confusing.

cpuQuota is a more straightforward "hard limit" of the CPU% that you
want the app to consume.

Can be tested with : stress -c 8 -t 20s
2024-04-10 18:25:14 +02:00
Girish Ramakrishnan 2afaf1f36d more changes 2024-04-10 12:52:42 +02:00
Girish Ramakrishnan f236213356 backups: memory max is RAM 2024-04-10 12:48:07 +02:00
Girish Ramakrishnan efd0be5e2c services: send the default memory limit 2024-04-10 12:42:25 +02:00
Johannes Zellner 6612f48d0a dashboard: make filter bar persistent and inline instead of popover 2024-04-10 12:24:56 +02:00
Girish Ramakrishnan f1679f1614 compute app excess based on RAM*2 2024-04-10 12:12:49 +02:00
Girish Ramakrishnan 8b7dca00af app memory: make slider go till RAM
anything above RAM is useless
2024-04-10 12:12:49 +02:00
Johannes Zellner 59fa26b0fb dashboard: we hardly use warning button colour 2024-04-09 19:55:47 +02:00
Johannes Zellner 7a92222050 dashboard: show active filter numbers 2024-04-09 19:55:07 +02:00
Girish Ramakrishnan be2775e12e memoryLimit: redefine to not include swap
Currently, we allocate 50% as RAM and 50% as swap. The manifest is
usually quite conservative on memory values. This means that we set
up a system where the app is applying memory pressure almost immediately.
This then swaps things randomly and increases cpu usage (kswapd shows
up in the profile).

To rethink the whole situation: we should not cap apps with a swap limit at all.
The memory hard limit is what is important. By redefining memoryLimit , we are
doubling every container's memory and it's good that we over allocate this.
2024-04-09 18:59:40 +02:00
Girish Ramakrishnan 6c3f8b9b84 various changes 2024-04-09 18:48:46 +02:00
Johannes Zellner f02157857c dashboard: add / eventhandler for search 2024-04-09 15:41:50 +02:00
Girish Ramakrishnan 470b0d6be7 update some modules 2024-04-09 15:31:46 +02:00
Girish Ramakrishnan 2b1b304c6e backup/import/restore: fix crash with root path calcuation
rootPath was calculated before the arguments were validated
2024-04-09 13:53:48 +02:00
Johannes Zellner 5460a64951 dashboard: Make app list columns sortable 2024-04-09 13:51:57 +02:00
Girish Ramakrishnan 62faf616c5 import: acceptSelfSignedCerts is validated at provider 2024-04-09 13:24:33 +02:00
Girish Ramakrishnan 3f2f4c7c6b restore: acceptSelfSignedCerts is validated by provider 2024-04-09 13:20:01 +02:00
Girish Ramakrishnan 5e49a33e8f backups: rootPath is needed only when testing storage 2024-04-09 13:03:31 +02:00
Girish Ramakrishnan 5fb7d53018 backups: encryptedFilenames and mountOptions are validated at provider level 2024-04-09 12:31:10 +02:00
Girish Ramakrishnan 424a3c2b53 validateEncryptionPassword need not by exported or async 2024-04-09 12:23:43 +02:00
Girish Ramakrishnan 6e629b984b typo in error message 2024-04-09 11:53:58 +02:00
Girish Ramakrishnan c73609211a import: fix typo in mountPoint parameter
mountpoint provider supports prefix (except not via UI). It's more
natural for the user to enter the actual mountpoint than the filesystem
path directly.
2024-04-08 19:21:59 +02:00
Johannes Zellner e5477351f8 dashboard: do not specify tooltip location in list view 2024-04-08 17:57:59 +02:00
Johannes Zellner d89f8d99a3 dashboard: only show relevant actions in list view 2024-04-08 16:44:15 +02:00
Johannes Zellner da472dff19 dashboard: use new image edit indicator in apps and applinks configuration 2024-04-08 16:28:38 +02:00
Johannes Zellner 2dc501dcbd dashboard: clear user selection when adding groups 2024-04-08 12:55:54 +02:00
Johannes Zellner 052b705c3c dashboard: preserve apps view type in localStorage 2024-04-06 16:16:48 +02:00
Johannes Zellner 24c8fca971 Better mobile list view and display app task progress 2024-04-06 16:12:08 +02:00
Johannes Zellner 86edabee4d Some visual improvements to the list view 2024-04-06 15:47:46 +02:00
Johannes Zellner d6f162a8ca dashboard: add initial version of app list view 2024-04-06 12:30:44 +02:00
Johannes Zellner 9e05a4eab7 Show background in all oidc pages 2024-04-06 10:52:25 +02:00
Johannes Zellner 32d9490856 Revert "dashboard: first use profile background if not exist try branding background"
This reverts commit 8db6da2de9.
2024-04-06 10:00:16 +02:00
Johannes Zellner 91d9f66eb8 dashboard: max-height is wrong here 2024-04-05 20:03:52 +02:00
Johannes Zellner 86986d8f34 Allow img-src blob: 2024-04-05 19:59:38 +02:00
Johannes Zellner 03ef9f109f dashboard: better image upload/edit indicator 2024-04-05 17:37:58 +02:00
Johannes Zellner 67a8228886 Show placeholder image for branding background 2024-04-05 17:15:31 +02:00
Johannes Zellner 8db6da2de9 dashboard: first use profile background if not exist try branding background 2024-04-05 17:11:37 +02:00
Johannes Zellner 544b8180b2 dashboard: add UI to change background image 2024-04-05 16:26:59 +02:00
Johannes Zellner 2515b032d0 Add branding background UI 2024-04-05 14:31:41 +02:00
Girish Ramakrishnan 6086b0e797 typo 2024-04-05 12:11:43 +02:00
Girish Ramakrishnan 2760e25c0f users: validate groupIds items 2024-04-05 11:59:16 +02:00
Girish Ramakrishnan 76aa0b4a70 add to changes 2024-04-04 18:25:35 +02:00
Girish Ramakrishnan 0e23687c7f cloudron-setup: lower memory requirement further for lightsail 2024-04-04 17:51:27 +02:00
Johannes Zellner 028b820d48 oidc: Reload the login view if session is gone 2024-04-04 17:32:58 +02:00
Johannes Zellner 2c81458954 Show branding background image in login view 2024-04-04 15:38:44 +02:00
Johannes Zellner ebe1883f8e Also trigger first time oidc auto login flow for initial admin creation 2024-04-04 11:42:57 +02:00
Girish Ramakrishnan 030e468829 docker: prune volumes on infra change 2024-04-04 11:36:26 +02:00
Johannes Zellner 68724bcb4f Revert "oidc: enable rpInitiated logout"
This reverts commit a6f4b2896a.
2024-04-04 10:41:00 +02:00
Johannes Zellner 6186bb54e4 Revert "oidc: allow post logout redirect back to the app"
This reverts commit 3ddf72a24d.
2024-04-04 10:40:53 +02:00
Johannes Zellner a4e822dec2 Make autologin token only one-time use 2024-04-04 10:29:36 +02:00
Johannes Zellner 5744cb7318 auto login from activation 2024-04-04 10:26:48 +02:00
Johannes Zellner 2f6a66dbd7 oidc: enable auto login when a token is provided 2024-04-03 18:11:21 +02:00
Johannes Zellner 91d3980e3b Add cloudron background branding apis 2024-04-03 17:27:22 +02:00
Johannes Zellner 3ddf72a24d oidc: allow post logout redirect back to the app 2024-04-03 15:49:03 +02:00
Johannes Zellner a6f4b2896a oidc: enable rpInitiated logout 2024-04-02 20:38:12 +02:00
Johannes Zellner c79ddbf948 dashboard: attempt to use firefox background color 2024-04-02 17:55:08 +02:00
Girish Ramakrishnan de99b8ecce Fix AVX support edge cases
* Always show restart button. When using a local VM, you can dynamically
switch flags. So, let the user rebuild. Show error if we cannot.
* The logs button is an "a" tag which is clickable despite ng-disabled
2024-04-01 23:05:20 +02:00
Girish Ramakrishnan 8b0bcde7ec cloudflare: result is now null and not empty array 2024-04-01 17:58:40 +02:00
Girish Ramakrishnan d862f1f5b4 cloudflare: fix crash when result is null 2024-04-01 17:31:20 +02:00
Girish Ramakrishnan 1c4f6315a6 mongodb: optional avx support in service routes 2024-04-01 17:31:20 +02:00
Girish Ramakrishnan 44eaac6685 cloudron-setup: add warning when installing with no avx 2024-04-01 17:31:20 +02:00
Johannes Zellner a89576965d Revert to lighter background 2024-04-01 12:42:44 +02:00
Girish Ramakrishnan 774f14327c addons: optional start mongodb based on AVX 2024-03-30 19:20:24 +01:00
Girish Ramakrishnan 6bd9391160 syslog: fix tests 2024-03-30 19:17:28 +01:00
Girish Ramakrishnan a82fb0c2cb typo from 110e68331 2024-03-30 19:17:28 +01:00
Girish Ramakrishnan 110e683318 rename checkManifestConstraints function 2024-03-30 18:25:37 +01:00
Girish Ramakrishnan 781ee77280 services: remove docker dynamic env hook 2024-03-30 18:25:37 +01:00
Johannes Zellner adc9894fde Use a darker gray background 2024-03-29 17:05:46 +01:00
Johannes Zellner c7bf5f2abc dashboard: sync list hover background with main background 2024-03-28 19:24:45 +01:00
Johannes Zellner 601e868afc dashboard: more font improvements 2024-03-28 17:22:17 +01:00
Johannes Zellner 25b1259c4c dashboard: use noto-sans instead of the dated Roboto 2024-03-28 17:03:53 +01:00
Johannes Zellner 1a8a111c79 dashboard: freshenup background, navbar and footer colors 2024-03-28 15:25:03 +01:00
Girish Ramakrishnan 497b3016c0 7.7.2 changes 2024-03-27 10:12:37 +01:00
Johannes Zellner fe9bd52b04 frontend: make uploads cancellable 2024-03-26 09:54:59 +01:00
Johannes Zellner 0705c77333 Frontend: update pankow for pretty fileupload size 2024-03-25 14:56:07 +01:00
Girish Ramakrishnan b66e77a2d8 Fix crash when system has no swap 2024-03-22 10:39:35 +01:00
Girish Ramakrishnan 4b4c8d8052 7.7.2 changes 2024-03-21 19:11:57 +01:00
Girish Ramakrishnan 4ee56782ba move syslog.js to top level 2024-03-21 19:09:51 +01:00
Girish Ramakrishnan 104997d77c syslog: change it to unix domain socket
docker is using a extra udp port for every container. when there is
a lot of containers, a lot of random udp ports get used up. this causes
problems when installing apps that require contiguous port ranges
2024-03-21 18:59:08 +01:00
Girish Ramakrishnan 8e07b3c96d remove unused variable 2024-03-21 17:11:17 +01:00
Johannes Zellner 4e618540f8 dashboard: preserve app link paths 2024-03-18 11:40:17 +01:00
Girish Ramakrishnan 49941a34b9 backups: deleted apps must also be displayed in contents 2024-03-14 16:14:50 +01:00
Johannes Zellner 771b797a23 frontend: update dependencies to fix filemanager empty folder content layout 2024-03-13 11:42:47 +01:00
Girish Ramakrishnan d09915bf6e scheduler: typo
(cherry picked from commit 09e00e6d58)
2024-03-12 18:06:24 +01:00
Johannes Zellner 264c94ff34 dashboard: remove bootstrap slider component 2024-03-12 17:33:38 +01:00
Johannes Zellner a90df99331 dashboard: migrate rsync concurrency settings to native range slider 2024-03-12 17:31:07 +01:00
Johannes Zellner 78f0d61627 dashboard: make backup upload part size steps explicit with native widget 2024-03-12 17:09:35 +01:00
Johannes Zellner 8c106b3435 dashboard: replace old slider with native widget 2024-03-12 16:44:08 +01:00
Johannes Zellner 42555c7231 dashboard: use native slider for mailbox storage quota 2024-03-12 15:43:08 +01:00
Johannes Zellner ab035a2afe dashboard: use native slider for mail size 2024-03-12 15:35:20 +01:00
Johannes Zellner 3a30eed3cd dashboard: remove commented slider 2024-03-12 15:29:30 +01:00
Johannes Zellner 4cb390374b dashboard: use native range slider for services 2024-03-12 15:28:47 +01:00
Girish Ramakrishnan 50179dd7eb 7.7.1 changes 2024-03-12 11:28:27 +01:00
Girish Ramakrishnan 2956c3360c postgresql: fix whitelist ext loading 2024-03-12 11:27:42 +01:00
Girish Ramakrishnan c634bdbd34 scheduler: do not create jobs of suspended apps
otherwise, when an app is uninstalling, it creates the docker containers
by calling getDynamicEnvironment. This ends up adding addonConfigs for the
docker addon and prevents the app from getting uninstalled.
2024-03-12 00:55:06 +01:00
Johannes Zellner 1892c0cd80 dashboard: use native slider element for app memory and cpu 2024-03-11 21:36:18 +01:00
Johannes Zellner 63b395982c dashboard: use less gulp processing for css turns out it actually made the files much larger 2024-03-11 19:03:42 +01:00
Johannes Zellner d50c8539b2 dashboard: update dependencies 2024-03-11 18:49:30 +01:00
Girish Ramakrishnan 90c8348c9c postgresql: fix upgrade route 2024-03-11 15:55:08 +01:00
Girish Ramakrishnan 1426cbec81 postgresql: fix for vectors update
we used:
psql -Uroot  --dbname=postgres --command="ALTER SYSTEM SET shared_preload_libraries = 'vectors.so'"

the above wrote to the auto config file and required a reboot. this resulted in
2024-03-11 09:39:13.250 UTC [34] ERROR:  pgvecto.rs: pgvecto.rs must be loaded via shared_preload_libraries.
	ADVICE: If you encounter this error for your first use of pgvecto.rs, please read `https://docs.pgvecto.rs/getting-started/installation.html`. You should edit `shared_preload_libraries` in `postgresql.conf` to include `vectors.so`, or simply run the command `psql -U postgres -c 'ALTER SYSTEM SET shared_preload_libraries = "vectors.so"'`.
2024-03-11 09:39:13.250 UTC [34] STATEMENT:  CREATE EXTENSION vectors
ERROR:  pgvecto.rs: pgvecto.rs must be loaded via shared_preload_libraries.
ADVICE: If you encounter this error for your first use of pgvecto.rs, please read `https://docs.pgvecto.rs/getting-started/installation.html`. You should edit `shared_preload_libraries` in `postgresql.conf` to include `vectors.so`, or simply run the command `psql -U postgres -c 'ALTER SYSTEM SET shared_preload_libraries = "vectors.so"'`.
2024-03-11 13:32:25 +01:00
Girish Ramakrishnan 7047915995 typo 2024-03-10 19:56:36 +01:00
Girish Ramakrishnan 49b514054f fixup mail fk constraints
it's possible previous releases bad a bug that they did not clear the mail domain
fields properly. this migration fixes it up.
2024-03-10 12:09:20 +01:00
Johannes Zellner bf27374dcc Fix postgresaddon migration for pgvectors 2024-03-07 13:59:30 +01:00
Johannes Zellner 3de1c6e499 Use postgres addon with immich hacks exposed as service api 2024-03-06 19:08:03 +01:00
Johannes Zellner d77285f2c4 frontend: update dependencies 2024-03-06 14:48:38 +01:00
Johannes Zellner 96eeb70076 Update postgres addon to 1.5.10
This contains a hack for immich in apptask to migrate the extension on
immich app update
2024-03-06 13:20:58 +01:00
Girish Ramakrishnan 6a39e442ac platform: use execArgs 2024-03-06 10:46:00 +01:00
Girish Ramakrishnan 91e030be44 sftp: fix buffer (stdin/stdout) overflow 2024-03-06 10:36:08 +01:00
Johannes Zellner 405e20e18e frontend: xtermjs moved to new node module naming scheme 2024-03-03 18:26:17 +01:00
Johannes Zellner 138f770630 frontend: update dependencies 2024-03-03 18:16:19 +01:00
Johannes Zellner eadc4fda30 Optional VectorRS is gone 2024-03-03 12:40:04 +01:00
Girish Ramakrishnan 35c5f19eac groups ui fixes 2024-03-01 18:45:40 +01:00
Girish Ramakrishnan 6d8ae180b3 initial indonesian translation 2024-03-01 18:45:20 +01:00
Girish Ramakrishnan 0fea30969f Remove bad assert 2024-03-01 14:52:54 +01:00
Girish Ramakrishnan 3ff8f5cb33 scheduler: proper crash when app is still being installed 2024-03-01 10:38:49 +01:00
Girish Ramakrishnan b6162a3bef docker addon: env var can be stored in the db 2024-03-01 10:31:41 +01:00
Girish Ramakrishnan 09ca67f408 restore: give a proper example in the placeholder 2024-02-29 19:37:34 +01:00
Johannes Zellner cadb1ad674 dashboard: show port count info 2024-02-29 15:31:32 +01:00
Johannes Zellner dec7bc3ca3 Check for portBindings with range outside the db constraint for now 2024-02-29 15:20:17 +01:00
Girish Ramakrishnan d87460a3cd encoding removed by mistake 2024-02-29 11:51:57 +01:00
Girish Ramakrishnan f076711ad3 add missing await 2024-02-29 10:41:07 +01:00
Girish Ramakrishnan 6149a5ac12 typo 2024-02-29 09:00:22 +01:00
Girish Ramakrishnan 44c61f7bd7 mail: do port 25 connectivity check with ipv4 2024-02-28 20:47:46 +01:00
Girish Ramakrishnan 4ea47da269 use execFile 2024-02-28 20:37:11 +01:00
Girish Ramakrishnan 35f2c0ec7d use --force option to not error 2024-02-28 19:59:38 +01:00
Girish Ramakrishnan 3316dd1f42 fixup various shell usage 2024-02-28 18:59:45 +01:00
Girish Ramakrishnan 07527fe2b1 shell: when using shell use child_process.exec
arg splitting messes up arguments and debug output
2024-02-28 18:34:07 +01:00
Girish Ramakrishnan 03207f62ba acme2: der is a binary format 2024-02-28 18:13:44 +01:00
Girish Ramakrishnan bcc78d81a6 shell: also print the args 2024-02-28 17:56:20 +01:00
Girish Ramakrishnan 0d38e443d1 groups: local groups can have remote and local users 2024-02-28 17:39:08 +01:00
Girish Ramakrishnan 50a069a7fa apphealthmonitor: only treat 5xx codes as truly erroneous 2024-02-28 17:39:08 +01:00
Girish Ramakrishnan 7455490074 Fix tests 2024-02-28 16:02:42 +01:00
Girish Ramakrishnan 64bb53abc3 services: startTurn needs a shell 2024-02-28 16:02:42 +01:00
Girish Ramakrishnan 18a680a85b groups: only the local groups of a user can be set 2024-02-28 15:56:03 +01:00
Girish Ramakrishnan e26f71b603 externalldap: cannot set members of external group 2024-02-28 15:56:03 +01:00
Girish Ramakrishnan f98fe43843 test: add ldap group test 2024-02-28 14:25:19 +01:00
Johannes Zellner 26dad82cd3 Add busy indicator to proxy auth login view 2024-02-28 13:10:36 +01:00
Girish Ramakrishnan 73d1860995 turn: remove quotes 2024-02-28 13:00:29 +01:00
Girish Ramakrishnan aca5c254d2 add release file as of date 2024-02-28 11:46:26 +01:00
Girish Ramakrishnan 3521815646 Next release is 7.7.0 2024-02-28 11:24:37 +01:00
Girish Ramakrishnan aecc16af5d add inboxDomain fk constraint 2024-02-27 13:45:08 +01:00
Girish Ramakrishnan 5927f397a3 translate port bindings after validation 2024-02-27 13:19:19 +01:00
Girish Ramakrishnan 1e85c86e74 clone: also clone crontab, enableTurn, enableRedis etc 2024-02-27 11:49:12 +01:00
Girish Ramakrishnan 6640929b01 remove unnecessary variable 2024-02-27 11:44:42 +01:00
Girish Ramakrishnan 7a333ace11 minor variable rename 2024-02-27 11:35:14 +01:00
Johannes Zellner 32bce25ad5 frontend: update dependencies 2024-02-26 18:09:27 +01:00
Johannes Zellner 5dc023d801 terminal: fix horizontal overflow in firefox 2024-02-26 18:09:15 +01:00
Johannes Zellner e3f31e6560 Ensure we keep the oidc secret on app update 2024-02-26 17:20:00 +01:00
Johannes Zellner e582e147cb dashboard: fix typo for external ldap group membership listing 2024-02-26 15:08:51 +01:00
Girish Ramakrishnan 6525504923 profile: store preferred language in the database 2024-02-26 13:30:35 +01:00
Girish Ramakrishnan 6d6107161e dashboard rename userInfo to getProfile 2024-02-26 12:38:33 +01:00
Girish Ramakrishnan 3196864f0d dashboard: rename refreshUserInfo to refreshProfile 2024-02-26 12:38:33 +01:00
Girish Ramakrishnan d7596beaf3 index: avoid some callback hell 2024-02-26 11:56:31 +01:00
Girish Ramakrishnan 23de5b5a61 appstore: move existing apps sync to common code 2024-02-26 11:37:23 +01:00
Johannes Zellner d98b09f802 Forward portCount during the portBinding translation 2024-02-25 16:52:10 +01:00
Johannes Zellner 97c012b3df Use full portBindings object internally also for validation 2024-02-25 16:28:57 +01:00
Johannes Zellner 867b8e0253 Also adjust portbindings env variable name check according to the manifest uppercase fix 2024-02-25 16:18:02 +01:00
Johannes Zellner 80400db92a Handle portCount in translatePortBindings 2024-02-25 14:33:57 +01:00
Johannes Zellner 72ff84be47 Update manifestformat 2024-02-25 13:59:55 +01:00
Girish Ramakrishnan 13e62bc738 logs: use stream.destroy() instead of custom hooks 2024-02-24 17:35:37 +01:00
Girish Ramakrishnan 0e83658aa3 make sudo commands terminate properly
sudo forks and execs the program. sudo also hangs around as the parent of the program waiting on the program and also forwarding signals.
sudo does not forward signals when the originator comes from the same process group. recently, there has been a change where it will
forward signals as long as sudo or the command is not the group leader (https://www.sudo.ws/repos/sudo/rev/d1bf60eac57f)
for us, this means that calling kill from this node process doesn't work since it's in the same group (and ubuntu 22 doesn't have the above fix).
the workaround is to invoke a kill from a different process group and this is done by starting detached
another idea is: use "ps --pid cp.pid -o pid=" to get the pid of the command and then send it signal directly

see also: https://dxuuu.xyz/sudo.html
2024-02-24 16:19:07 +01:00
Johannes Zellner 8e4506382d dashboard: make real Roboto Bold font-face available 2024-02-23 19:38:22 +01:00
Johannes Zellner 7a0b74d79b dashboard: Sort app grid items by label || fqdn 2024-02-23 18:11:06 +01:00
Johannes Zellner 1026728ab7 dashboard: Ensure fqdn of applink has the schema removed 2024-02-23 17:57:24 +01:00
Johannes Zellner 909fe5dc15 Add appPortBindings port count column 2024-02-23 17:57:24 +01:00
Johannes Zellner aed9801501 Update postgres addon for pgvector_rs 0.2.0 2024-02-23 17:57:24 +01:00
Girish Ramakrishnan 41f92c52e9 add to changes 2024-02-23 17:47:21 +01:00
Girish Ramakrishnan d0dc104ede logs: make logPaths work
we have to tail via sudo script

Fixes #811
2024-02-23 17:46:22 +01:00
Girish Ramakrishnan ce42680888 update mail container (solr, spam acl) 2024-02-23 11:37:08 +01:00
Girish Ramakrishnan 4ebff09f73 lint 2024-02-22 16:50:35 +01:00
Girish Ramakrishnan 8fd7daade6 rsync: empty check was removed by mistake 2024-02-22 14:47:44 +01:00
Girish Ramakrishnan e6aef755e3 shell: merge spawn into sudo 2024-02-22 12:43:23 +01:00
Girish Ramakrishnan c4b8d3b832 restore: add help link to backup path 2024-02-22 12:03:21 +01:00
Girish Ramakrishnan c38457b48d restore: better placeholder text for backup id 2024-02-22 12:01:03 +01:00
Girish Ramakrishnan 60994f9ed1 shell: docker run needs shell
don't want to get into parsing quotes!
2024-02-22 10:59:39 +01:00
Girish Ramakrishnan a6f078330f shell: no need to promise scoping 2024-02-21 19:40:27 +01:00
Girish Ramakrishnan cfd5c0f82b shell: rewrite exec to use execFile
this also renames execFile to execArgs
2024-02-21 18:54:43 +01:00
Girish Ramakrishnan 14c9260ab0 shell: exec encoding is utf8 by default and no shell
explicitly mark calls that require the shell
2024-02-21 17:47:25 +01:00
Girish Ramakrishnan 23cac99fe9 shell: remove spawn 2024-02-21 13:35:56 +01:00
Girish Ramakrishnan 2237d2bbb7 shell: remove usage of .spawn 2024-02-21 13:27:04 +01:00
Girish Ramakrishnan 62ca0487dc cloudron-support: docker info output 2024-02-21 12:54:08 +01:00
Girish Ramakrishnan 0e858dc333 cloudron-support: dump cloudron version 2024-02-21 12:51:50 +01:00
Girish Ramakrishnan fa3e908afc df can hang 2024-02-21 12:47:30 +01:00
Girish Ramakrishnan c1bb4de6a3 reverseproxy: use async exec 2024-02-21 12:33:04 +01:00
Girish Ramakrishnan 9b94cf18d0 convert more execSync to async 2024-02-21 11:00:12 +01:00
Girish Ramakrishnan b51071155a Use the async shell exec 2024-02-20 22:57:36 +01:00
Girish Ramakrishnan 1128edc23e update: remove dead pre-flight checks 2024-02-20 22:48:12 +01:00
Johannes Zellner df9c7010e2 Make backup memory limit slider more predictable with a minimum of 1 GB 2024-02-20 22:12:20 +01:00
Girish Ramakrishnan 54c7757e38 Fix crash 2024-02-20 21:53:52 +01:00
Girish Ramakrishnan 3da3ccedcb volumes: only wait for 5 seconds for mount status
mountpoint -q can never exit if the nfs mount disappears, for example
2024-02-20 21:38:57 +01:00
Girish Ramakrishnan 26eb739b46 shell: add options to exec 2024-02-20 21:11:09 +01:00
Johannes Zellner 7ce5b53753 dashboard: use snap bounds instead of ticks for memory slider 2024-02-20 14:37:18 +01:00
Girish Ramakrishnan 298d446e5f backups: make ui show min 1GB 2024-02-19 17:06:38 +01:00
Girish Ramakrishnan 450dd70ea2 backups: up min memory limit to 1GB 2024-02-19 17:02:14 +01:00
Girish Ramakrishnan 1d1a7af48e rsync: bump the buffer size to 80MB 2024-02-19 14:15:28 +01:00
Girish Ramakrishnan 003bc457bf setupdns: fix typo with bunny DNS 2024-02-18 18:45:20 +01:00
Girish Ramakrishnan bfafcea0b9 Update changes 2024-02-17 16:42:37 +01:00
Johannes Zellner 66da8dd4dc Always resetup oidc client record for apps 2024-02-15 12:40:58 +01:00
Girish Ramakrishnan 307a3ee015 apps: rename the config functions 2024-02-10 11:53:25 +01:00
Girish Ramakrishnan 95be147eb4 make config.json readable 2024-02-10 10:40:56 +01:00
Girish Ramakrishnan 2bf711f1f7 acme2: default to using secp256r1 key
the secp384r1 is not getting accepted by a few mail servers.

the upstream server is TLS 1.2 and advertises:
        {0xC0, 0x2C} TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384
        {0xCC, 0xA9} TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305_SHA256
        {0xC0, 0x2B} TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
        {0xC0, 0x24} TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA384
        {0xC0, 0x23} TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256
        {0xC0, 0x09} TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA

the connection fails with:
client connection error: Error: C0E703901F7F0000:error:0A0000C1:SSL routines:tls_post_process_client_hello:no shared cipher:../deps/openssl/openssl/ssl/statem/statem_srvr.c:2241:

node's current cipher list is https://nodejs.org/api/tls.html#modifying-the-default-tls-cipher-suite.
It says default cipher suite prefers GCM ciphers. ECDHE-ECDSA-AES256-GCM-SHA384 and ECDHE-ECDSA-AES128-GCM-SHA256
are the valid TLS 1.2 options but neither of these are selected.

the public key strength is somehow tied to cipher selection, I am not entirely sure how. from what i remember
`ecdsa_secp384r1_sha384` was listed in signature_algorithms extension.

Note that one document I found said that exchange server has a further _P256 and _P384 to cipher combinations.
Which suggests to me that one can also select specific curve+cipher combination.

anyway, with this curve, atleast the connection work with TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256
2024-02-09 22:01:55 +01:00
Johannes Zellner c3d2c7bcde Update minior version dependency updates 2024-02-09 19:54:50 +01:00
Johannes Zellner 38e32942cb oidc: remove env var for disabled session/end route 2024-02-09 19:37:54 +01:00
Johannes Zellner febd24b203 Expose port count as _COUNT env varible 2024-02-09 15:49:29 +01:00
Johannes Zellner d1afa3fdca Update package.lock 2024-02-08 18:41:30 +01:00
Johannes Zellner a82d1ea832 Use portCount from manifest with 1 as default 2024-02-08 18:25:25 +01:00
Johannes Zellner 7d9e8da660 Update manifest format for portCount support 2024-02-08 18:17:08 +01:00
Johannes Zellner ec990bd16a WIP: Add some portrange support 2024-02-08 17:39:22 +01:00
Girish Ramakrishnan fb12c0e499 typo 2024-02-08 11:51:56 +01:00
Girish Ramakrishnan 3d1a4f8802 mongodb: update mongo to 6.0 2024-02-08 11:37:03 +01:00
Girish Ramakrishnan c978e3b7ea scheduler: add debug if scheduler is running too long 2024-02-08 10:54:07 +01:00
Girish Ramakrishnan 0b201cee71 mail: update haraka to 3.0.3 2024-02-08 10:36:56 +01:00
Johannes Zellner 8b7c5a65d6 Fixup profile avatar tests 2024-02-06 20:48:27 +01:00
Girish Ramakrishnan 8a63f0368e Fix parsing of displayName
Currently, we only have one field for the name. The first part is
first name. The rest is last name. Obviously, this won't work in all
cases but is the best we can do for the moment.
2024-02-06 16:53:03 +01:00
Girish Ramakrishnan ce4bf7e10c Fix cloudron installation on netcup
https://forum.cloudron.io/topic/10097/cloudron-install-error-dpkg-error/
https://twitter.com/netcup/status/1735265955364720757
2024-01-31 17:24:29 +01:00
Girish Ramakrishnan 479946173f df: run async
df hangs on some systems and this brings down the box code

happens on erroneous cifs/sshfs volumes
2024-01-30 12:23:20 +01:00
Girish Ramakrishnan 176baa075f Fix some typos 2024-01-30 11:53:54 +01:00
Girish Ramakrishnan bfbc41d5a7 Add changes 2024-01-29 23:42:59 +01:00
Girish Ramakrishnan d2b303ffd6 directoryserver: cloudflare warning 2024-01-29 23:39:26 +01:00
Girish Ramakrishnan 00bbb4242d cloudron-support: display last cert renewal log file 2024-01-29 15:08:24 +01:00
Girish Ramakrishnan 0a4b0688a8 cloudron-support: add dashboard cert check 2024-01-29 14:44:42 +01:00
Johannes Zellner 9efe399399 oidc: add picture claim 2024-01-29 13:55:31 +01:00
Johannes Zellner b03240ccb8 Send avatarType explicitly in profile 2024-01-29 13:51:03 +01:00
Johannes Zellner 35eb17a922 dashboard: no need for additional avatar query args 2024-01-29 13:27:22 +01:00
Johannes Zellner c8b997f732 Always send an image as avatar 2024-01-29 13:21:19 +01:00
Johannes Zellner 80e83e0c05 Always send images for profile 2024-01-27 22:55:10 +01:00
Girish Ramakrishnan 9491b5aa39 cloudron-support: add node version check 2024-01-25 15:06:22 +01:00
Girish Ramakrishnan 243a254f3e filesystem: remove hook should not rm recursively
this causes a bug in the backupcleaner when it tries to prune
empty directories when using the filesystem backend.

the bug is hit when a box backup is getting cleaned up but
one or more app backups are preserved.
2024-01-25 11:50:48 +01:00
Johannes Zellner 2d1e0ec890 Ensure we never set more memory than swap for containers 2024-01-24 15:54:57 +01:00
Girish Ramakrishnan 793ee38f79 external ldap: show proper error message on timeout 2024-01-23 23:27:06 +01:00
Girish Ramakrishnan 5240068f2f Update translations 2024-01-23 23:04:46 +01:00
Johannes Zellner b8be174610 Send proper content type for avatar 2024-01-23 17:57:22 +01:00
Girish Ramakrishnan b923925a6c better describe 2024-01-23 13:18:14 +01:00
Girish Ramakrishnan 61f5669d76 externalldap: no need to make REST API calls and start server 2024-01-23 13:16:40 +01:00
Girish Ramakrishnan cf707ba657 move the require 2024-01-23 12:44:23 +01:00
Girish Ramakrishnan 660260336c dockerproxy: await on close 2024-01-23 12:38:57 +01:00
Girish Ramakrishnan 0447086882 remove spurious log 2024-01-23 12:13:28 +01:00
Girish Ramakrishnan 29a96e5df1 ldap test: more unbinding 2024-01-23 11:58:00 +01:00
Girish Ramakrishnan c95bb248fb typo: invoke the function 2024-01-23 11:45:25 +01:00
Girish Ramakrishnan d3551826c1 platform: add deactivated for tests to uninitialize properly 2024-01-23 11:42:02 +01:00
Girish Ramakrishnan d2c21627de ldap: server.close has a callback after all 2024-01-23 10:47:09 +01:00
Girish Ramakrishnan 81e21effa4 test: clear cron jobs to make node exit 2024-01-23 10:24:48 +01:00
Girish Ramakrishnan 2d03941745 cron: clean old jobs variable properly 2024-01-23 10:19:56 +01:00
Girish Ramakrishnan 2401c9cee7 test: unbind ldap client 2024-01-23 10:12:29 +01:00
Girish Ramakrishnan 4f0bbcc73b externaldap: 2fa validation for supported sources
a request to verify password to externaldap.js logic can come from
* cloudron app (via ldapserver.js)
* dashboard (via oidc.js) or proxy auth (proxyauth.js) or CLI (accesscontrol.js)

the only supported source is the 'cloudron' provider at this point
2024-01-22 21:35:19 +01:00
Girish Ramakrishnan 5b9700e099 ldapserver: remove totp logic
none of the apps send totptoken and it's dead code
2024-01-22 14:12:40 +01:00
Girish Ramakrishnan d7dda61775 profile: unify password verification check 2024-01-22 14:03:23 +01:00
Girish Ramakrishnan 3220721f84 directoryserver: test all combinations of 2fa checks
directory server cannot know the source of the requesting client.
there are 3 sources - external app, cloudron app, cloudron dashboard.

the 2fa is requested by client by passing `+totpToken=xxx` . totpToken
is ignored if the user has no 2fa setup. If present, it is validated.
2024-01-22 13:14:29 +01:00
Girish Ramakrishnan 0ed144fe81 hide user import/export buttons until we know the use case
maybe people can just script using the REST API
2024-01-20 12:44:23 +01:00
Girish Ramakrishnan 13b9bed48b externalldap: when using cloudron source, disable local 2fa setup 2024-01-20 12:44:19 +01:00
Girish Ramakrishnan c99c24b3bd users: cannot update profile fields of external user 2024-01-20 11:23:35 +01:00
Girish Ramakrishnan bd1ab000f3 users: do not call setGroups when ldap groups synced 2024-01-20 00:32:49 +01:00
Girish Ramakrishnan a1fd5bb996 users: cannot edit groups with external ldap group sync 2024-01-20 00:11:10 +01:00
Girish Ramakrishnan 9ef29343b3 lint: camel case the variables 2024-01-19 23:35:02 +01:00
Girish Ramakrishnan 8bdcdd7810 groups: members cannot be set for external groups 2024-01-19 23:23:25 +01:00
Girish Ramakrishnan a1217e52c8 group: cannot set name of ldap group 2024-01-19 22:28:48 +01:00
Girish Ramakrishnan a8d37b917a groups: remove unused addMember 2024-01-19 17:25:36 +01:00
Girish Ramakrishnan 06ce351d82 externalldap: set group members as a single transaction 2024-01-19 17:24:35 +01:00
Girish Ramakrishnan f43a601e86 profile: email change now requires password 2024-01-18 18:11:42 +01:00
Johannes Zellner 0dfadc5922 remove extra quotes on digitalocean DNS TXT records 2024-01-17 18:35:48 +01:00
Johannes Zellner c8cd67258a dashboard: show mailbox login in eventlog correctly 2024-01-17 16:17:22 +01:00
Johannes Zellner 7499aa9201 Do not fail is we don't have a servicesConfig yet 2024-01-17 13:13:48 +01:00
Johannes Zellner 0f4ea17f29 dashboard: ensure we show postinstall also from app config screen 2024-01-16 13:54:42 +01:00
Johannes Zellner b7631689b0 Add useVectorRsExtension for postgresql service 2024-01-16 12:53:43 +01:00
Girish Ramakrishnan afe670b49c cloudflare: use response.text since json may not be valid 2024-01-16 12:34:18 +01:00
Girish Ramakrishnan ee43dff35f externalldap: reset group source when disabled 2024-01-13 22:35:23 +01:00
Girish Ramakrishnan 1faf83afe4 groups: external groups cannot be updated 2024-01-13 22:33:46 +01:00
Girish Ramakrishnan ce0b66db7d login: show error on password reset 2024-01-13 21:56:18 +01:00
Girish Ramakrishnan 01d33c45bd profile: hide password reset for external users 2024-01-13 21:45:03 +01:00
Girish Ramakrishnan 63766dd10f do not send email reset for external users 2024-01-13 21:37:02 +01:00
Girish Ramakrishnan 8771158f10 Fix test 2024-01-13 21:29:40 +01:00
Girish Ramakrishnan 46a589f794 Use BAD_STATE consistently for demo mode 2024-01-13 21:15:41 +01:00
Girish Ramakrishnan a007a8e40c externalldap: sync log history 2024-01-13 16:50:10 +01:00
Girish Ramakrishnan 6e42cf4ec5 externalldap: available on all plans
looks like an oversight that this needs a subscription
2024-01-13 16:49:35 +01:00
Girish Ramakrishnan 257dc4e271 external ldap: run syncer every 4 hours
hardcoded for now but we should make this configurable
2024-01-13 15:53:14 +01:00
Girish Ramakrishnan 4136272382 externalldap: add eventlog 2024-01-13 13:22:26 +01:00
Girish Ramakrishnan 4f9e43859c directoryserver: comments can be provided in allowlist 2024-01-13 12:54:10 +01:00
Girish Ramakrishnan b57ad9b8c1 directoryserver: allowlist always needs a single IP/range 2024-01-13 12:30:43 +01:00
Girish Ramakrishnan b8c297b178 ldap allow list is not a json 2024-01-13 12:29:00 +01:00
Girish Ramakrishnan a389b863f9 directory server: add eventlog entry 2024-01-13 12:24:28 +01:00
Girish Ramakrishnan 40c82b3e48 external directory: reset auth source when disabled
this allows existing users to login (including the owner itself)

The alternative is to have some system where we have unique superadmin users across cloudrons which don’t get trampled upon by a sync. This is a bit unrealistic. For the future, we could also design this such that ldap auth is asked for in the initial step i.e at superadmin creation time.

If LDAP connection is lost/down, user can always use 'cloudron-support —owner-login'
2024-01-13 11:51:12 +01:00
Girish Ramakrishnan 2ca94f3159 user: remove make local feature
we discussed a bit on what this does and it's confusing as it stands:

* Use case of this is lost in the realms of time
* Possible guess by is that it was to move users of different Cloudron to a central cloudron
* Currently, the design is a bit flawed because the make user local button doesn’t pin the user. The state is lost in next synchronization.
* Maybe, one should use export/import user for this use case
* Let’s disable this button for now, feature is not complete.
2024-01-13 11:02:25 +01:00
Girish Ramakrishnan 33a97d0e50 cloudflare: validate response fields 2024-01-12 14:52:24 +01:00
Girish Ramakrishnan cef0b6d0d8 test: bump retries 2024-01-11 16:31:12 +01:00
Girish Ramakrishnan 7a5e990ad4 email: rewrite loading of email status using async
we start a bunch of requests in the background for each domain. when
we switch views immediately, to say the eventlog, these requests are
still active in the background.

canceling the requests will require a much bigger refactor.

https://forum.cloudron.io/topic/10434/email-event-log-loading-very-slowly-seems-tied-to-overall-email-domain-list-health-checks
2024-01-09 17:34:54 +01:00
Girish Ramakrishnan ca31dc8d78 namecheap: fix TLD
continuation of 6cdb448f62
2024-01-09 09:44:24 +01:00
Girish Ramakrishnan 5b7667fa4d external ldap: ensure dashboard login does totp check 2024-01-08 11:55:35 +01:00
Girish Ramakrishnan 6cdb448f62 namecheap: pass the TLD correctly
this is safe because namecheap does not allow external domains to be hosted.
otherwise, we would have to use tldjs
2024-01-08 11:54:37 +01:00
Girish Ramakrishnan 053f81a53e externalldap: add tests 2024-01-07 22:04:22 +01:00
Girish Ramakrishnan c842d02d6f namecheap: slow down requests for rate limit
https://www.namecheap.com/support/knowledgebase/article.aspx/9739/63/api-faq/#z
2024-01-07 22:01:42 +01:00
Girish Ramakrishnan 4ddcd547ba directoryserver: leave it to client to decide totp check
initially, the idea was to make the server enforce it. this is more secure. however,
we have 3 kinds of clients - an external cloudron dashboard which needs totp,
an external cloudron app, which doesn't have totp and external apps that don't have totp either.

given that the directory server is IP restricted, this is a reasonable compromise until
we move wholesale to oidc.

a directoryserver setting like "enforce totp" also does not work since this policy will be
applied to all clients.
2024-01-07 20:38:36 +01:00
Girish Ramakrishnan 7bb68ea6b5 rename ldap.js to ldapserver.js
this makes it clearer it is server module and not some generic ldap thing
2024-01-06 13:31:32 +01:00
Girish Ramakrishnan e13f427267 directoryserver: 2fa validation tests 2024-01-06 13:25:12 +01:00
Girish Ramakrishnan c422e2d570 users: add tests for 2fa and relaxed 2fa 2024-01-06 13:15:55 +01:00
Girish Ramakrishnan b3f91c4868 make branding and email config available to admin 2024-01-04 21:46:46 +01:00
Johannes Zellner 19dd56c160 filemanager: Skip rename if name didn't change 2024-01-04 16:00:28 +01:00
Johannes Zellner c577d3d91f filemanager: ask user for confirmation on rename conflict 2024-01-04 15:47:26 +01:00
Johannes Zellner 4f57bed03a Update translation 2024-01-04 15:46:59 +01:00
Johannes Zellner 29663a1229 Update sftp addon 2024-01-04 11:59:56 +01:00
Johannes Zellner d9d4798f69 frontend: update dependencies 2024-01-04 11:59:48 +01:00
Girish Ramakrishnan 32d3c0b920 cloudron-support: suppress mysql message 2024-01-03 22:01:53 +01:00
Girish Ramakrishnan 2224ccab7c fix doc links 2024-01-03 21:25:37 +01:00
Johannes Zellner 8d3d3ba875 dashboard: fix crash on uninstalled app 2024-01-03 18:49:49 +01:00
Johannes Zellner 4ad2b2829b dashboard: remove console.log 2024-01-03 18:48:49 +01:00
Girish Ramakrishnan 1ca46a064c ldap: use proper error message instead of dn
the dn is already in lde_dn field of the error object.
lde_message is the message
2024-01-03 15:23:22 +01:00
Girish Ramakrishnan e42579521c Fix tests 2024-01-03 15:12:07 +01:00
Girish Ramakrishnan 96be06188b ldap: send proper error messages 2024-01-03 15:12:07 +01:00
Johannes Zellner 10172e0211 Add login busy indicator 2024-01-03 14:55:07 +01:00
Girish Ramakrishnan 70c8a5a6be directoryserver: totp check must be enforced 2024-01-03 14:40:51 +01:00
Johannes Zellner af42f150f2 Update sftp addon 2024-01-03 13:20:32 +01:00
Girish Ramakrishnan ba16fdaf60 domain: handle alias domain conflict during deletion 2024-01-02 17:18:37 +01:00
Girish Ramakrishnan c5480bfcc1 mail: update limit plugin 2024-01-02 15:50:34 +01:00
Girish Ramakrishnan 79448e9ff9 oidc: fix error message with correct username but bad password 2023-12-29 18:15:33 +01:00
Girish Ramakrishnan e49398eb47 Bump request timeout to a minute, some servers are just too slow 2023-12-29 16:19:52 +01:00
Girish Ramakrishnan fa842034ed update: continue to update apps if box update never starts
https://forum.cloudron.io/topic/10699/no-automatic-app-updates-with-pending-box-update
2023-12-28 12:16:03 +01:00
Girish Ramakrishnan 672b472359 hetzner: typo in error message 2023-12-27 20:41:34 +01:00
Girish Ramakrishnan 37ed87f9c1 route53: retry on rate limit
route53 has a limit of 5 req/sec/region - https://docs.aws.amazon.com/Route53/latest/DeveloperGuide/DNSLimitations.html#limits-api-requests

see https://forum.cloudron.io/topic/10656/improve-dns-updates-to-avoid-rate-limits/
2023-12-27 12:23:09 +01:00
Johannes Zellner 25ba312636 Use postgres addon with pgvecto_rs extension 2023-12-22 22:45:41 +01:00
Johannes Zellner 340ea3fe9b Fix variable usage bug for noop backup provider 2023-12-18 13:23:40 +01:00
Girish Ramakrishnan d264f8b05c cloudron-support: box check 2023-12-15 15:45:29 +01:00
Girish Ramakrishnan 54672d9fce cloudron-support: fix variable name 2023-12-14 18:01:44 +01:00
Johannes Zellner 5ac9a7f1ef Do not bind to ipv6 for port 53 apps (adguard) 2023-12-14 18:00:03 +01:00
Girish Ramakrishnan b906b0f7f2 cloudron-support: delete extra dashboard conf files 2023-12-14 17:40:03 +01:00
Johannes Zellner 758e1965f1 cloudron-support: improve troubleshooting 2023-12-14 17:28:30 +01:00
Johannes Zellner 8ff437c4d2 cloudron-support: Add colors 2023-12-14 17:22:03 +01:00
Girish Ramakrishnan 4374124985 cloudron-support: whois may not have expiry info 2023-12-14 17:12:07 +01:00
Girish Ramakrishnan 8b5afaa12c cloudron-support: check if whois installed 2023-12-14 17:07:51 +01:00
Girish Ramakrishnan a54c6d3c32 install whois 2023-12-14 17:05:22 +01:00
Girish Ramakrishnan 93af9379bd cloudron-support: add option to disable dnssec 2023-12-14 17:04:05 +01:00
Girish Ramakrishnan 39deb41e2e cloudron-support: troubleshoot 2023-12-14 16:53:51 +01:00
Johannes Zellner d7c0a947fb dashboard: open internal app configure screen from disk usage info 2023-12-14 13:04:03 +01:00
Johannes Zellner 09b438850e Show disk content label in usage bar tooltip 2023-12-14 13:01:59 +01:00
Johannes Zellner cbefd4195f Add some 7.6.3 changes 2023-12-13 17:44:33 +01:00
Girish Ramakrishnan 849c8bf6ac cloudron-support: diag is too short 2023-12-13 16:59:00 +01:00
Johannes Zellner 00268b1da9 Use postgresql addon 5.1.5 which fixes the search_path issue 2023-12-13 16:47:40 +01:00
Girish Ramakrishnan 5f5e6084d7 cloudron-support: rework script into functions 2023-12-13 16:47:15 +01:00
Girish Ramakrishnan 852c4d1300 cloudron-support: remove --reset-appstore-account
the preferred way now is to delete it in cloudron.io instead
2023-12-13 16:21:47 +01:00
Girish Ramakrishnan 81fe6f884b cloudron-support: rename enable-ssh to enable-remote-access 2023-12-13 16:21:14 +01:00
Girish Ramakrishnan 9780e4184e cloudron-setup: typo 2023-12-13 09:32:38 +01:00
Girish Ramakrishnan 1af1660312 cloudron-setup: t2 has lesser memory now 2023-12-11 15:39:25 +01:00
Girish Ramakrishnan 1206f5dc88 Fix the support text 2023-12-10 13:11:22 +01:00
Girish Ramakrishnan 793c4ac017 add some debugs to the firewall script 2023-12-08 11:05:55 +01:00
Girish Ramakrishnan 620e3af525 add to changelog 2023-12-08 10:17:30 +01:00
Johannes Zellner c7b2e15d16 Use new postgres addon image with vectors extension enabled 2023-12-08 09:15:45 +01:00
Girish Ramakrishnan 48f0c75c57 network: increase maxelem of the ipsets 2023-12-07 23:20:24 +01:00
Girish Ramakrishnan 93d3b24300 firewall: max 65536 elements 2023-12-07 21:52:51 +01:00
Girish Ramakrishnan 21f830eb8c network: disable save button when in progress 2023-12-07 21:29:23 +01:00
Girish Ramakrishnan c195cb00c0 backup: redact nested password in configs 2023-12-07 13:38:38 +01:00
Girish Ramakrishnan f7a53e1b15 also flush the ipv6 blocklist 2023-12-06 22:20:25 +01:00
Girish Ramakrishnan 759f3f29f0 hetzner: accomodate other nameservers 2023-12-05 18:13:34 +01:00
Girish Ramakrishnan be35926fd1 ovh: accomodate anycast.me servers 2023-12-05 14:04:16 +01:00
Johannes Zellner 45fd046b9b Make systeminfo strings translatable 2023-12-04 16:59:24 +01:00
Girish Ramakrishnan 2b8d0f60e7 add to changes 2023-12-04 15:35:35 +01:00
Girish Ramakrishnan 0e0199fc94 typo 2023-12-04 09:09:43 +01:00
Johannes Zellner 7a730c445b dashboard: Show system stats 2023-12-04 01:51:33 +01:00
Johannes Zellner 4d29592450 Do not invalidate session sudo but only for the command we want to test 2023-12-04 01:42:46 +01:00
Girish Ramakrishnan 44be454a1e system: return activation time if we have it 2023-12-04 01:41:56 +01:00
Girish Ramakrishnan cbf1b47332 system: merge info and dmi routes
also return uptimeSecs instead of abstract date
2023-12-04 01:11:26 +01:00
Girish Ramakrishnan eb64bd296a system: return uptime and reboot required 2023-12-04 00:46:12 +01:00
Girish Ramakrishnan 72083f59cd system: dmi information 2023-12-04 00:31:18 +01:00
Girish Ramakrishnan 8a20b603f5 system: cpu route 2023-12-04 00:23:25 +01:00
Girish Ramakrishnan d45c433bc7 fix dockerproxy test 2023-12-04 00:11:11 +01:00
Girish Ramakrishnan 470417fcbe more test fixing 2023-12-03 21:18:16 +01:00
Girish Ramakrishnan 8e28d2a5aa Fix support tests 2023-12-03 20:04:17 +01:00
Girish Ramakrishnan 344578006c make oidc test stable 2023-12-03 20:04:17 +01:00
Johannes Zellner e19fd5cf17 Make support help items translatable 2023-12-03 18:03:25 +01:00
Girish Ramakrishnan 943325baa3 better sudoers configuration check 2023-12-03 17:50:50 +01:00
Johannes Zellner 702de2557e Update translations 2023-12-03 16:46:15 +01:00
Johannes Zellner 159f3419a5 Hide support ticket UI 2023-12-03 16:46:15 +01:00
Johannes Zellner b1fb3bccd8 Add help section in support 2023-12-03 16:46:15 +01:00
Johannes Zellner 8927634636 Remove supportConfig route 2023-12-03 16:46:15 +01:00
Girish Ramakrishnan b9e584752b Fix system test 2023-12-03 15:52:31 +01:00
Johannes Zellner 5857c05e01 Remove noisy debug for applinks 2023-12-03 15:11:16 +01:00
Johannes Zellner 81eb4bdebb Improve jsdom usage for applink icons 2023-12-03 14:24:45 +01:00
Johannes Zellner da18427125 Better error feedback on appstore login 2023-12-02 18:20:13 +01:00
Johannes Zellner df0b4ace5e Update translations 2023-12-02 18:20:13 +01:00
Johannes Zellner 5971d3bf77 Better error handling for setupToken 2023-12-02 18:20:13 +01:00
Johannes Zellner cca3138f05 Remove appstore web token api 2023-12-02 18:20:13 +01:00
Johannes Zellner 242c091add Add ability to register a Cloudron with a setupToken only 2023-12-02 18:20:13 +01:00
Girish Ramakrishnan 6f0788c9e4 typo 2023-12-01 17:29:06 +01:00
Girish Ramakrishnan 15132a30da Fix linode object storage
36c4772b17 broke linode object storage
2023-12-01 17:27:10 +01:00
Johannes Zellner 3245370280 New postgres addon for newly required extensions 2023-11-30 13:00:53 +01:00
Girish Ramakrishnan 740c0fe318 dockerproxy: all volumes to be mounted in child containers
this will allow jupyterhub notebooks to access volumes
2023-11-27 23:06:11 +01:00
Johannes Zellner 8d20ca2053 frontend: update dependencies 2023-11-27 13:09:56 +01:00
Johannes Zellner cdd8e34cfc Move owner/chown model into directoryModel 2023-11-27 13:09:42 +01:00
Girish Ramakrishnan a056bcfdfe mailserver: fix sending of double header 2023-11-26 15:40:21 +01:00
Girish Ramakrishnan b5065a381f update packages 2023-11-26 09:46:51 +01:00
Girish Ramakrishnan 56324e3e8e Fixup sshd comment 2023-11-24 15:46:24 +01:00
Girish Ramakrishnan e64182d791 mail: make redis non-persistent
it keeps emitting warnings non-stop about bgsave not working
2023-11-23 14:27:58 +01:00
Johannes Zellner 573eaee287 frontend: unify owner models for apps and volumes 2023-11-21 14:51:24 +01:00
Johannes Zellner 771bfd0244 Do not underline a tags on hover 2023-11-21 12:57:37 +01:00
Johannes Zellner 2db96a5242 frontend: update dependencies 2023-11-21 12:51:57 +01:00
Girish Ramakrishnan 8459d231c2 setup/restore: fix error with static ip configuration 2023-11-18 17:53:53 +01:00
Girish Ramakrishnan efd42b7293 ovh: fix nameserver matching
there's a whole bunch: ovh.ca, ovh.us, ovh.com, ovhcloud.com, ovh.co.uk

https://forum.cloudron.io/topic/10435/limitation-with-dns-using-ovh-in-validating-name-server-domains-domain-nameservers-are-not-set-to-ovh
2023-11-16 10:27:59 +01:00
Johannes Zellner fe1c483b78 logviewer: preserve horizontal scroll position 2023-11-14 14:24:58 +01:00
Girish Ramakrishnan bf381aff7f redis: use default instead of redisuser
suggested at https://github.com/redis/node-redis/issues/1591
2023-11-14 10:50:25 +01:00
Girish Ramakrishnan 1a43c05d48 sftp: fix crash when app has no addons 2023-11-13 21:58:44 +01:00
Girish Ramakrishnan 804a3f8adb Capitalize dnsimple properly 2023-11-13 18:30:24 +01:00
Girish Ramakrishnan 1122137d12 typo in dnsimple configuration 2023-11-11 12:57:10 +01:00
Girish Ramakrishnan b88afbac4e dns: add ovh backend 2023-11-06 15:22:24 +01:00
Girish Ramakrishnan 8e468788a9 dockerproxy: fix typo 2023-11-04 13:28:02 +01:00
Girish Ramakrishnan 7f9e5303be add voip category 2023-11-03 10:24:38 +01:00
Girish Ramakrishnan 08c48df862 add qbittorrent to blacklist 2023-11-01 23:54:20 +01:00
Girish Ramakrishnan 1bc3875519 cloudron-support: check for active owner as well 2023-11-01 12:30:31 +01:00
Girish Ramakrishnan c69cf4731a remove extra space 2023-10-31 21:51:46 +01:00
Johannes Zellner 4ad5bd71f1 Try to only use sensible icons for applinks 2023-10-31 14:55:24 +01:00
Girish Ramakrishnan 1ddc1cec20 Fix role definitions 2023-10-30 18:40:20 +01:00
Girish Ramakrishnan 934c701be2 vultr: fix copy of large objects
https://forum.cloudron.io/topic/10266/backups-are-failing
2023-10-26 09:51:07 +02:00
Johannes Zellner fadd4165df Update pankow with item activation debouncing 2023-10-25 16:19:44 +02:00
Johannes Zellner 538454b11b Update dependencies 2023-10-25 16:03:48 +02:00
Johannes Zellner e4464afd56 Use new graphite container for whisper cleanup 2023-10-24 01:06:37 +02:00
Girish Ramakrishnan eb1f3d8b55 dns: add dnsimple 2023-10-24 00:26:10 +02:00
Johannes Zellner e7208278fc Only collect stats for app main containers 2023-10-23 22:23:23 +02:00
Johannes Zellner e87370354b Update dependencies 2023-10-23 16:16:20 +02:00
Johannes Zellner fc3bd3a0fe Deletion confirmation dialog moved out of pankow 2023-10-23 16:16:00 +02:00
Johannes Zellner 2270f5789a frontend: Update pankow 2023-10-21 18:59:57 +02:00
Johannes Zellner 7ef20c273e Update sftp service for folder copy 2023-10-21 17:37:44 +02:00
Johannes Zellner 39942dc5b0 frontend: update dependencies 2023-10-21 17:13:31 +02:00
Johannes Zellner 37a6e60e90 Do not allow newlines in CSP rules 2023-10-18 13:53:21 +02:00
Johannes Zellner 1f8c55f536 Add docker-volume disk usage info 2023-10-17 16:51:57 +02:00
Johannes Zellner 36c4772b17 Add missing Linode S3 regions 2023-10-17 15:35:33 +02:00
Girish Ramakrishnan 47d7536e24 du: add dovecot index log to the exclude list 2023-10-17 10:00:27 +02:00
Johannes Zellner 9d9a407c3d Noop provider does not have a rootPath set 2023-10-16 16:36:31 +02:00
Johannes Zellner 7d731d7600 dashboard: paint backup failure notifications red 2023-10-16 14:18:18 +02:00
Girish Ramakrishnan dd9db22e9c Fix transient du error
du error: Command failed: du -Dsb "/home/yellowtent/boxdata/mail" du: cannot access '/home/yellowtent/boxdata/mail/vmail/user@example.com/mail/dovecot-uidlist.lock': No such file or directory .
2023-10-13 15:52:36 +05:30
Girish Ramakrishnan 6830c4fc67 redis: fix issue when restoring optional redis 2023-10-11 14:53:25 +05:30
Girish Ramakrishnan 2f3fba346f volumes: throw error for unsupported update 2023-10-09 10:31:31 +05:30
Girish Ramakrishnan 5bae308cae docker: Fix crah when docker has no space 2023-10-09 07:38:57 +05:30
Johannes Zellner ed71f9ac68 The oidc client signing algorithm is not really so important to show it toplevel 2023-10-06 15:32:40 +02:00
Johannes Zellner 5e7bc78d35 Set custom oidc client id and secret in the backend 2023-10-06 15:16:57 +02:00
Girish Ramakrishnan 41319bc817 ldap server close has no callback 2023-10-01 14:33:19 +05:30
Girish Ramakrishnan ceb908bee7 Use constants.TEST 2023-10-01 13:52:19 +05:30
Girish Ramakrishnan 0e195679bf Make tests pass 2023-10-01 13:42:02 +05:30
Girish Ramakrishnan 9c78b2df9a dockerproxy: lint 2023-10-01 12:12:02 +05:30
Girish Ramakrishnan 4844f6d927 dashboard: remove old domain config on switch 2023-09-29 09:26:42 +05:30
Girish Ramakrishnan 64381e2a04 backups: remove validation mount point after testing it
this also moves out the attempt validation logic from mounts code
into volumes. mounts.tryAddMount is also used in backup code
2023-09-29 08:01:58 +05:30
Johannes Zellner 8d0abf214c First attempt a temporary mountpoint 2023-09-28 10:56:46 +02:00
Johannes Zellner 8426b11a90 Add volumes translation 2023-09-28 09:43:48 +02:00
Girish Ramakrishnan 661bd47202 more changes 2023-09-28 10:34:49 +05:30
Girish Ramakrishnan 8e12281b86 tests: Fix the hash 2023-09-28 10:11:50 +05:30
Girish Ramakrishnan 51409d3031 runtimedirs: .cache is already symlinked in base image 2023-09-27 22:21:39 +05:30
Johannes Zellner e1f88b9cd8 Show openid icon for login indicator 2023-09-27 16:13:45 +02:00
Girish Ramakrishnan 28397379e8 app proxy: check the manifest id and not appStoreId
when installing via REST API or CLI, appStoreId will be empty.
2023-09-27 19:30:52 +05:30
Girish Ramakrishnan 7d5d857c28 More changes 2023-09-27 17:08:46 +05:30
Johannes Zellner 3bde6e7475 Fixup eventlog tests 2023-09-27 09:12:06 +02:00
Girish Ramakrishnan 6bfd047c0f use npm ci 2023-09-27 11:21:12 +05:30
Girish Ramakrishnan 925ca1d79d Update base image for more symlink fixes 2023-09-27 11:20:05 +05:30
Girish Ramakrishnan efa1a2d5ca Update changes 2023-09-27 08:27:22 +05:30
Johannes Zellner 0fd4a831c8 Do not set frame-action and default-src CSP for openid routes
If set chrome wants a rule with * and safari on iOS wants an explicit
schema, so not setting any works with both.
2023-09-26 23:37:55 +02:00
Johannes Zellner 31ef53c530 Revert "Set custom csp rule for OpenID consent form submit based on schema"
This reverts commit b0115acf42.
2023-09-26 23:37:13 +02:00
Johannes Zellner b0115acf42 Set custom csp rule for OpenID consent form submit based on schema 2023-09-26 22:32:37 +02:00
Johannes Zellner e91536b9e1 Alert() should also update type 2023-09-26 14:14:09 +02:00
Johannes Zellner 8f87070b45 Add color coded indicator to notifications 2023-09-26 12:58:19 +02:00
Johannes Zellner b72a5e9c69 Add notification types 2023-09-22 17:58:13 +02:00
Girish Ramakrishnan fc6c8c5b7f cloudron-setup: DO memory has become lower 2023-09-22 06:56:47 +05:30
Girish Ramakrishnan 26cf5b8b80 app proxy: set the Host header
The Host header will help the destination request identify which
service the request is meant for. This can potentially be an internal
endpoint identifier.

X-Forwarded-Host is meant to have the external facing server endpoint.

This means that: if the user wants to expose internal.service.com which
resolves to some internal IP as external.service.com, then:
* Host header has to be internal.service.com
* X-Forwarded-Host is external.service.com
* proxy_pass to internal.service.com
2023-09-21 21:54:02 +05:30
Girish Ramakrishnan 26d6464360 add to changes 2023-09-21 13:05:10 +05:30
Girish Ramakrishnan 17e6266384 mail: make virtual "All Mail" togglable
Mac clients behave poorly when this virtual mailbox is present
2023-09-21 13:04:41 +05:30
Girish Ramakrishnan 9d0914ecc1 mail: remove unused dialog
this dialog merged into the main page
2023-09-21 13:04:41 +05:30
Johannes Zellner 328c61b67f Show volume edit options for network mounts 2023-09-20 20:24:19 +02:00
Johannes Zellner 981d76ef7f Add rest api to update volume mount options 2023-09-20 16:28:47 +02:00
Girish Ramakrishnan a2450be63a Update addons to new base image 2023-09-20 19:36:40 +05:30
Girish Ramakrishnan c1a53f7b29 oidc: loginRedirectUri can be empty string
this is required for ttrss
2023-09-20 14:47:27 +05:30
Johannes Zellner 51d49ef60a Use local buildFilePath 2023-09-20 10:04:24 +02:00
Johannes Zellner 585bd04c42 Update pankow 2023-09-20 09:49:25 +02:00
Johannes Zellner 0fa45f102b Update pankow and other frontend modules 2023-09-18 16:26:30 +02:00
Girish Ramakrishnan 4997ad0468 Fix status codes of 2fa routes 2023-09-13 21:07:07 +05:30
Girish Ramakrishnan 348eb16cef api: fix background image route 2023-09-13 20:08:52 +05:30
Girish Ramakrishnan c376f2473e directoryserver: check secret only if it exists 2023-09-13 20:08:52 +05:30
Johannes Zellner 2484cf490b Ensure dyndns every 10min 2023-09-13 16:14:25 +02:00
Johannes Zellner 8874ef1184 terminal: fix app restart 2023-09-13 10:41:34 +02:00
Girish Ramakrishnan 964dc990a6 network: simply use ip instead of ipv4/ipv6
this makes it simpler for openapi docs
2023-09-12 20:34:55 +05:30
Girish Ramakrishnan 58bf5ec677 Fix typo causing mailFqdn to be undefined 2023-09-12 18:03:36 +05:30
Johannes Zellner 93d4271bce Clear potential postinstall flag after backup import 2023-09-11 15:11:53 +02:00
Girish Ramakrishnan 4653d6fdef waitfordns: ignore REFUSED error 2023-09-10 06:17:46 +05:30
Girish Ramakrishnan cbfb52b920 updater: if update just finished, update info is obsolete 2023-09-09 20:46:24 +05:30
Girish Ramakrishnan 8880d46dd5 add comment on why these are strings 2023-09-09 07:57:53 +05:30
Girish Ramakrishnan 20a4136eb5 remove morgan
morgan breaks our log output parsing. debug() puts a timestamp in
the front.
2023-09-07 17:08:05 +05:30
Girish Ramakrishnan bbc6714be8 backup: show app backup details 2023-09-05 09:15:12 +05:30
Girish Ramakrishnan f8e2947015 backup: display the remote path in details 2023-09-05 08:56:46 +05:30
Girish Ramakrishnan 5a3ffa20ce backupcleaner: dump the retention 2023-09-05 08:48:48 +05:30
Girish Ramakrishnan d0c66ed3f7 services: default to 256MB for all 2023-09-05 08:35:53 +05:30
Girish Ramakrishnan 253f509fc6 redis: set default memory limit to 256M
The 'reset to default' functionality in services view has 256M hardcoded
2023-09-04 18:22:57 +05:30
Girish Ramakrishnan 8f9bc8817d cloudron-setup: add AVX check for 7.6 and beyond 2023-09-02 12:02:37 +05:30
Girish Ramakrishnan f22a2b2053 mongo: update to 5.0 2023-09-02 09:40:50 +05:30
Johannes Zellner 74ab6d2794 Fixup backup task stop button translation 2023-08-31 22:47:22 +02:00
Girish Ramakrishnan e9f54a325c turn: add ddos mitigation settings 2023-08-31 15:41:20 +05:30
Girish Ramakrishnan d03e401d94 lodash is not used 2023-08-30 09:24:43 +05:30
Girish Ramakrishnan 7fe2de448e remove pipeline() chain
it cannot be chained afaict
2023-08-29 17:44:02 +05:30
Girish Ramakrishnan 35828fe1c7 hush: wait for close event instead of finish event
use stream.pipeline to cover all the corner cases
2023-08-29 11:44:52 +05:30
Girish Ramakrishnan 6b30b6211a Add to Changes 2023-08-29 06:48:52 +05:30
Girish Ramakrishnan 1c714bc1f2 lint 2023-08-29 06:11:12 +05:30
Johannes Zellner 24981e1f81 Fix renew certs call from cron 2023-08-28 23:55:13 +02:00
Girish Ramakrishnan d2c702f890 eventlog: always use AuditSource objects as source field 2023-08-28 08:13:56 +05:30
Johannes Zellner 246c45c1bc Fixup logviewer URL for backup failed email 2023-08-26 09:36:17 +02:00
Girish Ramakrishnan 5eaae1c960 system: fix crash updating disk usage 2023-08-26 08:05:52 +05:30
Johannes Zellner 27dd54dbeb filemanager: Do not attach a generic esc handler for viewer exit
This intereferes too much with the text editor.
image viewer already handles this internally
2023-08-25 13:59:44 +02:00
Girish Ramakrishnan 9c3173e8ef Fix broken directory server config migration 2023-08-25 16:44:08 +05:30
Johannes Zellner 0e507bad7e Add explicit billing issue ticket type 2023-08-25 12:44:52 +02:00
Girish Ramakrishnan 34c997401f backups: add contabo object storage 2023-08-25 09:51:27 +05:30
Girish Ramakrishnan f6977cd15a add to changes 2023-08-23 18:59:07 +05:30
Girish Ramakrishnan 91a4334b42 mail: use 25MB instead of 25MiB for databytes 2023-08-23 18:58:09 +05:30
Johannes Zellner 07937424ae dashboard: disable 2fa setup for external users 2023-08-23 14:15:49 +02:00
Johannes Zellner c98a7b7850 filemanager: Always show app or volume name 2023-08-23 14:13:07 +02:00
Johannes Zellner 0895f65582 filemanager: fix logs button link 2023-08-22 14:29:04 +02:00
Girish Ramakrishnan 68aab74185 Fix progress callback message 2023-08-22 16:28:48 +05:30
Girish Ramakrishnan 3c93cf07fc cloudron-setup: fix the installation line 2023-08-22 16:26:44 +05:30
Johannes Zellner ec8a0e51b9 dashboard: give dashboard domain change label more space 2023-08-22 10:31:53 +02:00
Girish Ramakrishnan 0bb354bc4f mail: fix acl and perm issue with virtual All Mails 2023-08-22 10:31:48 +05:30
Girish Ramakrishnan 095bef8ca6 mail: namespace ordering broke usage reporting 2023-08-22 09:47:13 +05:30
Johannes Zellner 03529174de filemanager: also condense common buttons 2023-08-21 20:29:44 +02:00
Johannes Zellner 25d06690ec terminal: do not show labels for common buttons 2023-08-21 20:29:44 +02:00
Girish Ramakrishnan e833b859eb cloudron-setup: docker images are downloaded as part of installer now 2023-08-21 22:26:58 +05:30
Girish Ramakrishnan 4b6d4fe6be another take on prune images 2023-08-21 22:17:28 +05:30
Girish Ramakrishnan f152331615 Fix issue where backup config disappeared 2023-08-21 22:17:28 +05:30
Johannes Zellner c7ced6a487 dashboard: Remove verbose OpenID URLs 2023-08-21 18:09:47 +02:00
Girish Ramakrishnan 1ad94708b4 apps have to reconfigured in main thread
they cannot be done in the task process
2023-08-21 21:35:09 +05:30
Johannes Zellner 61047e374c terminal: wait for DOM to update the a-tag before opening it 2023-08-21 17:48:14 +02:00
Girish Ramakrishnan bf2531337f Fix crash on mail server change 2023-08-21 21:15:58 +05:30
Johannes Zellner be481ef006 frontend: update dependencies 2023-08-21 17:34:54 +02:00
Johannes Zellner 3bd5f9b027 filemanager: Use different owner map for apps and volumes 2023-08-21 17:34:40 +02:00
Johannes Zellner d05e16dc11 filemanager: Show uid if username is not known 2023-08-21 16:54:13 +02:00
Girish Ramakrishnan 91a4883b50 typo 2023-08-21 19:43:53 +05:30
Girish Ramakrishnan 79af6c1a68 On dashboard or email location change, reconfigure immediately 2023-08-21 18:34:07 +05:30
Girish Ramakrishnan 9e093db7d8 mailserver: fix crash when restarting 2023-08-21 15:19:42 +05:30
Girish Ramakrishnan 2427f15231 typo in branding route 2023-08-21 15:01:43 +05:30
Girish Ramakrishnan b895cc6aad capitalize progress 2023-08-21 14:40:57 +05:30
Johannes Zellner 40884705b4 Fixup demo note text 2023-08-17 13:45:07 +02:00
Johannes Zellner 98e43a6f5a Add login note for demo Cloudron 2023-08-17 13:38:47 +02:00
Girish Ramakrishnan 28bfab6700 LOCATION_TYPE can move into location.js 2023-08-17 16:05:19 +05:30
Girish Ramakrishnan 5c98b6f080 crash fixes 2023-08-17 13:02:36 +05:30
Girish Ramakrishnan 3d0ba557e5 add Location class 2023-08-17 10:44:07 +05:30
Girish Ramakrishnan de7879afb5 store subdomain in database instead of fqdn
this makes it more consistent with the locations table
2023-08-16 21:58:56 +05:30
Girish Ramakrishnan 1133a41b77 Fix proxy config not generated on restore 2023-08-16 12:52:52 +05:30
Girish Ramakrishnan e33ae8ae11 add missing export 2023-08-16 10:28:44 +05:30
Girish Ramakrishnan aa8c23c8b3 rework backup root
notes:
* backup root cannot come from backend. for dynamic mounts backend cannot know where it is mounted
* backupConfig is 3 parts - format / mount / password . there is also this rootPath (which should not be in db)
* password should be stored separately in settings at some point
* format has to be passed along everywhere because we allow restore from  same backupConfig but different format. we do this by saving the format in the backups table

fixes #819
2023-08-15 22:51:45 +05:30
Girish Ramakrishnan da49a69562 backups: testConfig is really testStorage 2023-08-15 19:59:00 +05:30
Girish Ramakrishnan 9dedf0ec05 validate the backup format 2023-08-15 19:57:51 +05:30
Girish Ramakrishnan cd9d49116e backups: move limits and storage into separate keys 2023-08-15 10:48:56 +05:30
Girish Ramakrishnan 630853abb5 move mountObjectFromBackupConfig into backups 2023-08-15 08:55:38 +05:30
Girish Ramakrishnan e6b85c2df7 remount does not need a backend hook 2023-08-15 08:55:38 +05:30
Girish Ramakrishnan d0fca9eeb9 trigger location changed only if activated 2023-08-14 14:20:20 +05:30
Girish Ramakrishnan 8cc08c734e Add to changes 2023-08-14 11:32:08 +05:30
Girish Ramakrishnan 4b1b38be63 make tests work again 2023-08-14 11:08:38 +05:30
Girish Ramakrishnan 4acbb7136a proper task name for dashboard change 2023-08-14 10:45:12 +05:30
Girish Ramakrishnan abff970169 make use of fqdn function 2023-08-14 09:35:08 +05:30
Girish Ramakrishnan 2b53ea0260 Fix dashboard config not getting generated 2023-08-14 02:08:10 +05:30
Girish Ramakrishnan a7be30a816 better naming of the dashboard functions 2023-08-13 10:38:07 +05:30
Girish Ramakrishnan e723c3c19b move dashboard change routes under dashboard/ 2023-08-13 10:06:01 +05:30
Girish Ramakrishnan 7b32cb16f3 move platform status into services 2023-08-12 22:29:09 +05:30
Girish Ramakrishnan 68a3c267e5 move config route under dashboard
it's essentially giving info for various parts of the ui
2023-08-12 22:25:49 +05:30
Girish Ramakrishnan 070f6e5de3 move startup logic to platform.js 2023-08-12 22:25:46 +05:30
Girish Ramakrishnan 559125cd3c remove unused require 2023-08-12 18:02:55 +05:30
Girish Ramakrishnan c62091b077 system: getUbuntuVersion 2023-08-11 21:47:49 +05:30
Girish Ramakrishnan f71e622fdb keep dropdown alphabetical 2023-08-11 21:09:36 +05:30
Girish Ramakrishnan eee49a8291 move dashboard setting into dashboard.js 2023-08-11 21:04:10 +05:30
Girish Ramakrishnan 27ce8f9351 oidc: fix crash when rendering error 2023-08-11 18:38:03 +05:30
Johannes Zellner cacf0d34f5 Add oidc views footer 2023-08-11 13:53:23 +02:00
Johannes Zellner 34f2386a9d dashboard: merge main.js into index.js 2023-08-11 12:25:40 +02:00
Johannes Zellner 4936475c2a Merge oidc settings for user directory view 2023-08-11 11:32:45 +02:00
Girish Ramakrishnan cd0b51dac2 Do not continue processing after redirect 2023-08-11 11:43:26 +05:30
Girish Ramakrishnan 1041b3b8ab plural 2023-08-11 07:35:57 +05:30
Girish Ramakrishnan 955a43723f cleanup status route
this is now purely a healthcheck route and nothing else

at some point, we will server render password reset and setup account views
2023-08-10 22:29:48 +05:30
Girish Ramakrishnan 1cdd528b45 separate the provision status and cloudron status 2023-08-10 22:29:47 +05:30
Johannes Zellner 98719aa942 Remove unused includes in oidc views 2023-08-10 17:06:00 +02:00
Girish Ramakrishnan 57772662aa move provisioning routes into /provision/ 2023-08-10 16:52:10 +05:30
Girish Ramakrishnan 6c4aa605df move various login routes under auth/ 2023-08-10 16:24:10 +05:30
Girish Ramakrishnan 9ba6908764 use list pattern when listing 2023-08-10 16:21:12 +05:30
Johannes Zellner d3b58483bd Update translations 2023-08-10 00:09:24 +02:00
Johannes Zellner 63ed900087 Purge user settings from settings view elements 2023-08-10 00:05:56 +02:00
Johannes Zellner b5ab7851c1 Remove user directory settings and oidc from users view 2023-08-09 23:53:36 +02:00
Johannes Zellner 4de2a477c6 Remove user directory from users view 2023-08-09 23:42:45 +02:00
Johannes Zellner 094fdad9a7 Remove externalldap from users view 2023-08-09 23:39:54 +02:00
Johannes Zellner 6eefe4c7c9 Duplicate users view into user settings view 2023-08-09 23:38:43 +02:00
Johannes Zellner 621ffb404c Remove unused subscription modals 2023-08-09 23:36:29 +02:00
Johannes Zellner 527c2f0baf Remove unused status api properties and label others 2023-08-09 17:48:03 +02:00
Johannes Zellner 842d7e6b61 Add block device selector in restore view 2023-08-09 12:14:37 +02:00
Johannes Zellner fb4921e2d3 Do not ignore mount failures on restore 2023-08-08 20:52:32 +02:00
Girish Ramakrishnan e6c43c84e4 hardcode yellowtent user uid
when we use an external disk, we chown 777 the mountpoint so that the
yellowtent user can write to it. the files are created as the 'yellowtent'
user.

when this disk is attached to another server for a restore, the new server's
yellowtent user may not be able to access the files if the uid does not match
between the old and new server.

for this, reason hardcode the uid
2023-08-08 23:18:43 +05:30
Johannes Zellner 8777a60b99 Make disk backup config known in restore view 2023-08-08 18:36:55 +02:00
Girish Ramakrishnan c6db1c70c0 docker: fix image prune
it seems docker images --digests cloudron/sftp --format "{{.ID}} {{.Repository}}:{{.Tag}}@{{.Digest}}
broke at some point
2023-08-08 21:21:00 +05:30
Johannes Zellner 7d9e697d85 dashboard: remove some debug console.logs() 2023-08-08 15:52:09 +02:00
Johannes Zellner 10646e9e04 Add generic disk (partition) backup provider to replace ext4 and xfs 2023-08-08 15:11:22 +02:00
Johannes Zellner 5ef8d8d3b0 Add uuid to block device listing 2023-08-08 12:34:19 +02:00
Johannes Zellner e9f3f13564 Only always use token types from tokens.js 2023-08-07 19:26:04 +02:00
Girish Ramakrishnan 8f20a09791 Fix update route crash 2023-08-05 08:48:03 +05:30
Girish Ramakrishnan 67ee82abb9 remove settings.dashboardOrigin 2023-08-04 22:10:14 +05:30
Girish Ramakrishnan 4cdf37b060 settings: move mailFqdn/Domain into mailServer 2023-08-04 22:02:24 +05:30
Girish Ramakrishnan 946e5caacb split mail and mailserver
mail = all the per-domain code
mailserver = all the mail server level code
2023-08-04 20:54:39 +05:30
Girish Ramakrishnan fb9d8c23e1 move appstore urls into appstore.js 2023-08-04 15:41:41 +05:30
Girish Ramakrishnan 37ae142a16 keep the cloudron routes close 2023-08-04 14:17:13 +05:30
Girish Ramakrishnan 6aad89ae6e demo is just a constant, not a setting 2023-08-04 14:13:30 +05:30
Girish Ramakrishnan d79d24efad remove settings route entirely, redundant by now 2023-08-04 14:03:04 +05:30
Girish Ramakrishnan 2cdbf4d2c5 move server routes into /system 2023-08-04 13:42:21 +05:30
Girish Ramakrishnan 1264cd1dd7 reverseproxy: move renew and trusted ip routes 2023-08-04 13:19:48 +05:30
Girish Ramakrishnan a49cb0b080 move sync_dns out of cloudron route into domains 2023-08-04 12:55:57 +05:30
Girish Ramakrishnan a4c3d39cc3 Fix eventlog route 2023-08-04 12:46:54 +05:30
Girish Ramakrishnan da73067315 rename change notifiers to have handle prefix 2023-08-04 11:54:15 +05:30
Girish Ramakrishnan e73b75e4b5 settings: move backup settings 2023-08-04 11:54:12 +05:30
Girish Ramakrishnan 77c66d9a02 settings: move provider to provision 2023-08-04 11:01:45 +05:30
Girish Ramakrishnan 775246946a settings: move language and tz into cloudron.js 2023-08-04 10:58:04 +05:30
Girish Ramakrishnan ec23c7d2b8 Suppress aws sdk warning
https://github.com/aws/aws-sdk-js/issues/4354#issuecomment-1664694545
2023-08-04 09:21:48 +05:30
Girish Ramakrishnan 5603b9e811 move updater routes and settings under /api/v1/updater 2023-08-03 15:35:27 +05:30
Johannes Zellner db26a6beb9 dashboard: only show volumes UI for admins and owners 2023-08-03 10:43:28 +02:00
Girish Ramakrishnan 47d57a3971 fold sysinfo into network
the backends are network backends
2023-08-03 13:38:42 +05:30
Girish Ramakrishnan a4d57e7b08 refactor into getServiceConfig 2023-08-03 12:52:47 +05:30
Girish Ramakrishnan bbc6ba1a35 settings: move service setting into services.js
this also introduces getJson/setJson
2023-08-03 11:50:00 +05:30
Girish Ramakrishnan 3caf0c3902 Fix crash in getConfig 2023-08-03 09:03:47 +05:30
Girish Ramakrishnan d12e6ee2b3 settings: make user_directory setting route 2023-08-03 08:29:12 +05:30
Girish Ramakrishnan d475df8d63 settings: rename to directory_server_config 2023-08-03 07:35:14 +05:30
Girish Ramakrishnan 92a103d635 settings: move ipv6/ipv4 config into network
this also rename sysinfo_config to ipv4_config
2023-08-03 06:40:04 +05:30
Girish Ramakrishnan f2e56cbdd8 Fix crash on startup 2023-08-03 06:39:35 +05:30
Girish Ramakrishnan c97441f7d9 settings: remove cookie secret default 2023-08-03 02:48:24 +05:30
Girish Ramakrishnan 67e4c90d37 settings: move directory server config to it's own route 2023-08-03 02:48:21 +05:30
Girish Ramakrishnan 4a34c390f8 settings: move externaldap setting 2023-08-03 02:43:26 +05:30
Girish Ramakrishnan a19e502198 settings: move dynamic dns to network
and add tests
2023-08-02 23:02:40 +05:30
Girish Ramakrishnan fccc2d04a9 settings: move support config to support 2023-08-02 23:02:40 +05:30
Girish Ramakrishnan eb4213d61d settings: cloudronId is only ever set
we use subscription API to get the cloudronId, never from database
2023-08-02 23:02:40 +05:30
Girish Ramakrishnan e0d07c3c19 settings: move branding settings into branding.js 2023-08-02 23:02:40 +05:30
Girish Ramakrishnan 85a73af303 settings: remove appstore listing config
this is not used anymore
2023-08-02 23:02:40 +05:30
Girish Ramakrishnan be4c3575fb settings: move web/api token to appstore 2023-08-02 23:02:40 +05:30
Girish Ramakrishnan e1fd369c6d settings: move cookie secret into oidc 2023-08-02 23:02:40 +05:30
Girish Ramakrishnan 77e6b69a63 settings: remove unstable apps key
it's not used anymore
2023-08-02 23:02:40 +05:30
Girish Ramakrishnan c7f2a04e8c settings: move reverse proxy config 2023-08-02 23:02:39 +05:30
Girish Ramakrishnan c4a8255fdd settings: move firewall config to network 2023-08-02 23:02:39 +05:30
Girish Ramakrishnan 8fe992318e settings: move trusted ip setting to reverseproxy 2023-08-02 23:02:39 +05:30
Johannes Zellner f2317c2a81 show filemanager button in app mounts section 2023-08-02 13:33:40 +02:00
Girish Ramakrishnan 516dd89d92 settings: list already applies default logic 2023-08-02 15:35:05 +05:30
Girish Ramakrishnan 68b4bf1667 backupformat: print the backupFilePath 2023-08-02 09:50:34 +05:30
Johannes Zellner 30880de82f filemanager: close viewer on esc 2023-08-01 18:45:24 +02:00
Girish Ramakrishnan ee836e6646 mail: 'my' location is available as mail location
move the reserve domains check to app location validation code
2023-08-01 19:33:59 +05:30
Girish Ramakrishnan 7d929aca54 rsync: fix crash 2023-08-01 19:03:24 +05:30
Girish Ramakrishnan e65c1fb718 graphs: show old backup size and location if > 1GB 2023-08-01 18:44:27 +05:30
Girish Ramakrishnan 0722692210 graphs: always show /var/backups size
often this has old backups
2023-08-01 17:38:48 +05:30
Johannes Zellner 28dab0bc9b dashboard: add separator between disks 2023-08-01 14:01:57 +02:00
Girish Ramakrishnan 54e33a0ece graphs: no disk speed for network disks 2023-08-01 17:17:10 +05:30
Girish Ramakrishnan 80bf8e3ffe Update packages 2023-08-01 11:42:58 +05:30
Johannes Zellner 8e10477170 Add direcotry server tests for member and uniquemember attributes 2023-07-31 13:19:42 +02:00
Johannes Zellner 650966a7e5 directoryserver: Add member and uniquemember attributes
https://datatracker.ietf.org/doc/html/rfc4519#section-2.17
https://datatracker.ietf.org/doc/html/rfc4519#section-2.40
2023-07-31 13:13:07 +02:00
Johannes Zellner 65769e5701 ldap uses lower-case attributes 2023-07-31 13:12:39 +02:00
Johannes Zellner 7099102a79 filemanager: do not rely on history when closing viewers 2023-07-31 11:31:27 +02:00
Girish Ramakrishnan 740e69c8dd change redirections to 301 2023-07-31 06:04:49 +05:30
Johannes Zellner 72ccac2753 frontend: update pankow for dragndrop fixes 2023-07-30 19:43:31 +02:00
Johannes Zellner ae5748ffd1 frontend: update pankow 2023-07-30 13:53:52 +02:00
Girish Ramakrishnan 4a522ce99b cloudflare: key type selector should be first 2023-07-30 15:53:47 +05:30
Johannes Zellner b3916622e8 filemanager: bring some drag'n'drop functionality via pankow 2023-07-28 19:31:53 +02:00
Johannes Zellner 56e1f53890 Fix oidc tests after removing logoutRedirectUri 2023-07-28 16:47:10 +02:00
Girish Ramakrishnan 1f4c71dcd6 tests: configure apps needs an array 2023-07-28 14:46:31 +05:30
Girish Ramakrishnan 0ab4bc543f Fix backup.download tests 2023-07-28 13:15:08 +05:30
Girish Ramakrishnan 99bc30ad07 Update packages 2023-07-28 09:36:02 +05:30
Girish Ramakrishnan ab67c04f27 mail: add virtual All Mail mailbox 2023-07-27 22:56:36 +05:30
Girish Ramakrishnan 041faa10d9 turn: fix config for file logging and auth 2023-07-27 17:11:55 +05:30
Johannes Zellner f67fd2bc79 dashboard: Show service memory usage percent 2023-07-27 10:46:41 +02:00
Johannes Zellner 2a7b320834 logviewer: remove extra gap in top buttons 2023-07-26 19:49:44 +02:00
Johannes Zellner 348012823b More filemanger addon fixes 2023-07-26 16:41:16 +02:00
Johannes Zellner a4e2ed2253 New sftp addon to fix permission issue when files get overwritten 2023-07-26 14:36:21 +02:00
Johannes Zellner 3eedbdd163 logviewer: fix button margins for non-app types 2023-07-26 12:53:15 +02:00
Johannes Zellner bdc07bbbc7 frontend: update dependencies bringing in list view sorting 2023-07-25 16:42:13 +02:00
Girish Ramakrishnan d9a9ae2add oidc: log which app the user logged into 2023-07-25 18:40:48 +05:30
Girish Ramakrishnan b533e5273d oidc: set authType to oidc 2023-07-25 18:40:48 +05:30
Johannes Zellner e13d905f32 Store OpenID cookie secret in settings db and make it unique per instance 2023-07-25 12:40:05 +02:00
Girish Ramakrishnan be24ed64f8 lint 2023-07-25 13:21:41 +05:30
Girish Ramakrishnan ecc4d58bb2 oidc: comment out some debugs 2023-07-25 12:31:05 +05:30
Girish Ramakrishnan 9a359a27f5 backups: download is now async 2023-07-25 10:33:03 +05:30
Girish Ramakrishnan 2bec56145e add to changes 2023-07-25 10:33:03 +05:30
Johannes Zellner e97747762e Raise login event 2023-07-24 20:49:58 +02:00
Girish Ramakrishnan 3d5c21d9ca backups: encrypted backups must have .enc extension 2023-07-24 22:25:06 +05:30
Girish Ramakrishnan febac9e8ca backups: put the dashboard domain in the backup config 2023-07-24 21:31:02 +05:30
Johannes Zellner c3574614bc filemanager: make footer render the custom branding 2023-07-24 12:07:23 +02:00
Johannes Zellner fcfc8ce66d frontend: update readme 2023-07-24 10:32:43 +02:00
Johannes Zellner 4c185fb3b4 Reconfigure apps on dashboard domain change, if they use oidc addon 2023-07-21 20:02:35 +02:00
Johannes Zellner 00b5438ec5 oidc: explicitly disable rpInitiatedLogout 2023-07-20 16:43:58 +02:00
Johannes Zellner d361962d5c dashboard: fixup pencil icons in oidc view 2023-07-20 13:40:39 +02:00
Johannes Zellner 5489285406 oidc: remove now unsupported provider logout handling 2023-07-20 13:26:07 +02:00
Johannes Zellner be4b93ea2a namecheap: ensure we don't fail if no dns records exist 2023-07-19 14:51:42 +02:00
Johannes Zellner bd2e51ba1b frontend: update pankow dependency 2023-07-19 11:48:35 +02:00
Johannes Zellner 18c54aa8c6 logviewer: hide some buttons on mobile to avoid overflow 2023-07-19 11:45:34 +02:00
Johannes Zellner 3a3972822e Update translations 2023-07-18 18:56:38 +02:00
Johannes Zellner dd750d5d68 Remove old filemanager assets 2023-07-18 18:55:44 +02:00
Johannes Zellner 978faa1f68 terminal: support ctrl+shift+c/v for copy paste 2023-07-18 18:05:07 +02:00
Johannes Zellner 024a9c6e2b Remove old logs viewer 2023-07-18 17:44:22 +02:00
Johannes Zellner ac33570645 Remove old terminal 2023-07-18 17:26:13 +02:00
Johannes Zellner 9399b430d6 terminal: remove unused placeholder element 2023-07-18 17:18:19 +02:00
Johannes Zellner 1affadad8e Use vuejs based terminal in all places 2023-07-18 12:39:18 +02:00
Johannes Zellner f2c511902c fatalError needs to be a boolean false for the dialog widget 2023-07-17 19:37:07 +02:00
Johannes Zellner 6940de7465 terminal: show fatal error for invalid appid 2023-07-17 19:33:22 +02:00
Girish Ramakrishnan 9b872bbbd6 add hyphen in notfound 2023-07-17 09:59:29 +05:30
Girish Ramakrishnan 7a71c86bd8 cloudron-setup: validate setup token upfront
this allows use to re-run setup
2023-07-16 10:33:31 +05:30
Girish Ramakrishnan 2e20d757b1 cloudron-setup: validate the setup token 2023-07-16 10:01:47 +05:30
Girish Ramakrishnan 050a82039a getBackupProviderStatus -> getProviderStatus 2023-07-15 11:00:45 +05:30
Johannes Zellner 159ff1704f Always use full origin for api origin 2023-07-14 18:45:25 +02:00
Johannes Zellner be16ad6953 Terminal: add download file dialog 2023-07-14 18:18:55 +02:00
Johannes Zellner c1b393d926 Terminal: add file upload to /tmp 2023-07-14 17:32:56 +02:00
Johannes Zellner 1f4827f5c5 terminal: improve topbar button layout 2023-07-14 16:53:12 +02:00
Johannes Zellner b239e81065 terminal: support cron/scheduler 2023-07-14 16:39:27 +02:00
Johannes Zellner ee2cd0b573 Give success buttons our color scheme 2023-07-14 16:39:12 +02:00
Johannes Zellner c3d4769956 terminal: support addon injection 2023-07-14 16:06:03 +02:00
Johannes Zellner 698a5be41a frontend: update dependencies 2023-07-14 15:44:46 +02:00
Johannes Zellner d162ffe508 First version of vuejs terminal 2023-07-14 14:48:58 +02:00
Girish Ramakrishnan 6bf7a1a2d8 Add missing ISTATE 2023-07-14 18:09:07 +05:30
Girish Ramakrishnan 1d69207e6e redis: do not list in services when disabled 2023-07-14 18:01:21 +05:30
Girish Ramakrishnan 754cb17254 Update translations 2023-07-14 17:44:03 +05:30
Girish Ramakrishnan e1ff5f1cae ui: optional redis
fixes #810
2023-07-14 12:43:32 +05:30
Girish Ramakrishnan 866cf75012 add a TODO 2023-07-14 08:34:05 +05:30
Johannes Zellner 4c24de53e4 Some layout fixes for the apps service tab 2023-07-13 17:15:10 +02:00
Johannes Zellner d75c8e2858 various filemanager and logs improvements 2023-07-13 15:37:27 +02:00
Girish Ramakrishnan 25328d884f redis: make optional
part of #810
2023-07-13 16:46:09 +05:30
Girish Ramakrishnan f34840e1a3 mail: use the new services change task type 2023-07-13 16:46:09 +05:30
Johannes Zellner 4cb017e0e1 logs: fix page title and favicon 2023-07-13 12:14:37 +02:00
Girish Ramakrishnan 519b258a25 make turn service optional
part of #810
2023-07-13 15:32:28 +05:30
Girish Ramakrishnan a2c53df042 typo 2023-07-13 12:49:58 +05:30
Girish Ramakrishnan a28ca8fed2 backups: Clean cache if anything other than limits changes 2023-07-13 12:46:42 +05:30
Girish Ramakrishnan 68e56f903d validate encryption password separately 2023-07-13 12:42:38 +05:30
Girish Ramakrishnan 95314d46e2 backup policy must be inserted 2023-07-13 12:27:44 +05:30
Girish Ramakrishnan c86059e070 backups: move limits into a sub object
fixes #817
2023-07-13 12:17:57 +05:30
Girish Ramakrishnan 1a5cbfb2a1 delete spurious mountStatus while we are at it 2023-07-13 11:10:40 +05:30
Girish Ramakrishnan 9cebde3005 backups: split config and policy
keeping them together makes the test/validation quite complicated.
for example, when policy is changed, we test the storage backends

part of #817
2023-07-13 11:07:06 +05:30
Girish Ramakrishnan 7926ff2811 test: only suppress starttask.sh output and not sudo
the remote support logic uses sudo output in tests
2023-07-13 09:13:28 +05:30
Girish Ramakrishnan 13a8926f60 sudo: suppress starttask.sh logs in test 2023-07-13 09:01:14 +05:30
Johannes Zellner 8aec0f52ba logs: some style improvments 2023-07-12 20:45:15 +02:00
Johannes Zellner 0ccbc76f31 logs: fix logline hover background for timestamp 2023-07-12 14:40:35 +02:00
Johannes Zellner 76fa45c88d logs: Remove unused import 2023-07-12 14:39:26 +02:00
Johannes Zellner 1d4a680851 Fix focus state on p-buttons 2023-07-12 14:37:50 +02:00
Johannes Zellner e9f6a163d9 Use new logsviewer 2023-07-12 14:33:57 +02:00
Johannes Zellner caa160b3fd Move filemanager/ to frontend/ 2023-07-12 14:22:58 +02:00
Johannes Zellner 9b6957b52f logs: fix autoscrolling 2023-07-12 14:16:48 +02:00
Johannes Zellner f48b04ca87 Reimplement the logsviewer in primevue 2023-07-12 13:56:10 +02:00
Girish Ramakrishnan 0ab72f5900 appdata: cannot use cifs or sshfs
Fixes #827
2023-07-11 21:37:26 +05:30
Johannes Zellner 1bf91413c4 filemanager: improve prev/next in image viewer 2023-07-11 17:27:33 +02:00
Johannes Zellner c25521cded filemanager: prevent page from reload during deletion operation 2023-07-11 15:39:08 +02:00
Johannes Zellner 783c6c20c1 filemanager: ellipse string when deleting many files 2023-07-11 15:29:14 +02:00
Girish Ramakrishnan 5beb7d7d92 Fix tests 2023-07-11 18:45:49 +05:30
Johannes Zellner 2d4e7c9c0a filemanager: Do not reload whole state on folder change 2023-07-11 15:02:37 +02:00
Johannes Zellner 39498616a6 native eventhandler can't use this 2023-07-11 15:02:37 +02:00
Johannes Zellner da4c4f5530 Update translations 2023-07-11 15:02:37 +02:00
Girish Ramakrishnan b56a7f854c remotesupport: remove superfluous sshd_config check 2023-07-11 18:09:40 +05:30
Johannes Zellner d22680bc86 filemanager: support prev/next in image viewer 2023-07-11 13:37:03 +02:00
Johannes Zellner aa00742093 filemanager: give pasting busy indicator and prevent tab closing 2023-07-11 12:38:35 +02:00
Johannes Zellner 63c5aa1984 If we have a file conflict append -copy until we don't 2023-07-11 12:26:06 +02:00
Girish Ramakrishnan 13e4093d05 test: mysql 8.0 2023-07-11 15:37:31 +05:30
Girish Ramakrishnan 4c422e48b2 check-install: print all sudo instructions at once 2023-07-11 14:58:09 +05:30
Girish Ramakrishnan 249e6ffa2c redis: add no auth warning 2023-07-11 14:19:24 +05:30
Johannes Zellner 8eadce1201 Update pankow to have some page up/down support 2023-07-10 20:35:08 +02:00
Girish Ramakrishnan b8c14b1d7f Fix translations 2023-07-10 23:23:25 +05:30
Girish Ramakrishnan e410844350 mail: validate the mail server hostname 2023-07-10 23:05:17 +05:30
Girish Ramakrishnan 0049e269d3 email: move server location to it's own card
comples #826
2023-07-10 22:29:49 +05:30
Johannes Zellner 287ad9034d filemanager: update dependencies to support selectAll 2023-07-10 17:10:29 +02:00
Girish Ramakrishnan f8ec24b973 lint 2023-07-10 20:32:53 +05:30
Johannes Zellner 2cfa5511d5 Do not require applink icons to be pngs 2023-07-10 15:56:59 +02:00
Johannes Zellner 25abd8a67d Support more favicon cases for applinks 2023-07-10 15:22:56 +02:00
Johannes Zellner 3a5d570e3c Do not update applink icon if it is not set in update 2023-07-10 14:21:06 +02:00
Girish Ramakrishnan df54ba3a0a Add AVX check in preparation for mongodb 5 2023-07-09 12:54:12 +05:30
Girish Ramakrishnan 78877f3731 Show upgrade fail message that ubuntu 18.04 is now required 2023-07-09 12:53:59 +05:30
Girish Ramakrishnan d9d38ae402 dyndns: keep going if one or more domains fail to update 2023-07-09 08:09:36 +05:30
Girish Ramakrishnan 23f0eba1bd dyndns: run as a task
this lets us display logs
2023-07-08 21:21:06 +05:30
Girish Ramakrishnan 56b7cc4041 better error message when deleting domain
Fixes #815
2023-07-08 17:48:07 +05:30
Girish Ramakrishnan 07457703b1 mail: consistently use disk size to calculate usage
In the mail overivew page, we use disk size
In the per mailbox page, we use quota size
2023-07-08 09:56:56 +05:30
Johannes Zellner 5fc0a5f9a2 filemanager: Fix BASE_URL of fallback icon when deployed 2023-07-07 17:39:43 +02:00
Johannes Zellner c0b2d61583 Give oidc login button a id for easier testing 2023-07-07 10:45:55 +02:00
Johannes Zellner d74993f6ac Use sftp 3.7.3 to fix symlink deletion 2023-07-07 10:38:17 +02:00
Girish Ramakrishnan a651aa44f4 7.5.1 changes 2023-07-07 08:22:21 +05:30
Girish Ramakrishnan cf63261760 mail: fix issue where mail usage were reported incorrectly 2023-07-07 08:15:26 +05:30
Johannes Zellner e16eba7c66 Do not use translation templates in JS due to escaping issues 2023-07-06 19:01:39 +02:00
Johannes Zellner 736829445c Remove dead code 2023-07-05 11:25:09 +02:00
Girish Ramakrishnan 20856c9ee8 Remove obsolete development section
we are now a mono repo. dashboard and hotfix tools are here.
2023-07-05 13:44:53 +05:30
Johannes Zellner f1c6130cbd Fixup linter error 2023-07-04 16:23:59 +02:00
Johannes Zellner 7443847697 Use branding cloudron name for oidc login 2023-07-04 16:23:48 +02:00
Johannes Zellner 0294859839 dashboard: only selectively apply text-stroke 2023-07-02 17:49:40 +02:00
Johannes Zellner ccb925be5d dashboard: use text-stroke instead of drop-shadow to avoid z-index breakage 2023-07-02 12:31:43 +02:00
Girish Ramakrishnan 7835533838 typo 2023-07-01 13:34:58 +05:30
Girish Ramakrishnan 779997e7fc 7.4.3 changelog
(cherry picked from commit a08ac8de1b)
2023-07-01 13:10:51 +05:30
Girish Ramakrishnan b0e2129e2f add today's release file 2023-07-01 13:08:00 +05:30
Girish Ramakrishnan f9478d1e76 postgresql: add fix for taiga 2023-06-30 22:06:23 +05:30
Girish Ramakrishnan ab2056138e Give more time to resolve 2023-06-30 19:10:23 +05:30
Girish Ramakrishnan 5f0bcf62dd dig: use built-in resolver timeout 2023-06-30 19:09:19 +05:30
Johannes Zellner 94e2ce2968 filemanager: some fixes from the pankow module 2023-06-30 15:17:43 +02:00
Girish Ramakrishnan aea58a2b76 lint 2023-06-30 18:27:18 +05:30
Johannes Zellner 5433552710 filemanager: allow pasting on non-folders to cwd 2023-06-30 14:14:51 +02:00
Johannes Zellner d2b39351b8 Clear the correct mail status notification 2023-06-29 11:35:07 +02:00
Johannes Zellner a3649ea039 filemanager: placeholder for dark theme 2023-06-26 17:55:48 +02:00
Johannes Zellner f7ca78a8a6 filemanager: Only init vue app after we fetch language files to avoid UI shaking 2023-06-26 16:35:31 +02:00
Girish Ramakrishnan 853677ab2e appstore: fix crash because of error.message access 2023-06-26 18:06:37 +05:30
Johannes Zellner 7aae3790a7 oidc: Do not support logout 2023-06-26 13:02:57 +02:00
Girish Ramakrishnan 4cd54f1026 release: make changelog case insensitive 2023-06-25 19:19:23 +05:30
Girish Ramakrishnan 0eb32b8a58 Update CHANGES 2023-06-25 16:36:55 +05:30
Girish Ramakrishnan 37e3278f23 Update mail container for haraka fixes 2023-06-25 15:52:52 +05:30
Johannes Zellner 7cee40b491 filemanager: Remove back/goup button 2023-06-22 18:56:52 +02:00
Johannes Zellner fae23bd4fc filemanager: update pankow 2023-06-22 18:12:11 +02:00
Johannes Zellner 148a189bb2 filemanager: further fix the current folder entry 2023-06-22 18:11:05 +02:00
Johannes Zellner c3778f94c4 filemanager: set correct name for activeDirectory 2023-06-22 15:51:24 +02:00
Johannes Zellner b7fbffcb42 various filemanager fixes 2023-06-22 15:20:54 +02:00
Girish Ramakrishnan 6259849958 apphealth: timeout is already in msecs 2023-06-22 18:24:59 +05:30
Johannes Zellner eb767bb3b1 filemanager: add missing colon for props 2023-06-22 13:23:43 +02:00
Johannes Zellner a6f01b2455 Ensure all filemanager buttons explicitly use Noto font 2023-06-22 12:57:04 +02:00
Johannes Zellner 4fe055c3a8 oidc: automatically submit consent form
Fixes #828
2023-06-21 13:14:45 +02:00
Girish Ramakrishnan 79d9cce2e7 Fix ptr record link 2023-06-21 16:43:03 +05:30
Johannes Zellner 9fbfdd08d8 Update translation 2023-06-20 15:33:46 +02:00
Johannes Zellner 879569c661 filemanager: show busy state when extraction is in progress 2023-06-20 15:33:26 +02:00
Johannes Zellner 5814793dc1 filemanager: Integrate download and extract logic 2023-06-20 15:21:58 +02:00
Johannes Zellner 299e40c389 Allow cors for translation 2023-06-20 10:40:27 +02:00
Johannes Zellner 38860cd70c Redirect to / on dashboard 404 2023-06-19 15:02:28 +02:00
Johannes Zellner c8fe2611ba Also fix bottom bar for password reset 2023-06-19 14:08:10 +02:00
Johannes Zellner af9175b30c Better login action bar styling 2023-06-19 13:55:58 +02:00
Johannes Zellner 35453a0c2d Translate the oidc login view 2023-06-19 11:50:53 +02:00
Johannes Zellner fd91bf0498 Update translations 2023-06-18 20:19:12 +02:00
Johannes Zellner 3b02ef5591 filemanager: inject tr() for pankow 2023-06-18 20:11:48 +02:00
Johannes Zellner 2966763e9e filemanager: pankow has translation support 2023-06-18 18:35:55 +02:00
Johannes Zellner 6d7759a1af filemanager: add translation support 2023-06-18 17:39:40 +02:00
Johannes Zellner 70e7ca395d Update filemanager dependencies 2023-06-16 17:15:09 +02:00
Johannes Zellner 922c587ca9 Fix context menu closing with new pankow version 2023-06-16 17:13:45 +02:00
Johannes Zellner a555d70868 Add real info to filemanager readme 2023-06-16 12:49:47 +02:00
Johannes Zellner 6f6907363e Dashboard login view is gone and replaced with oidc 2023-06-15 18:05:06 +02:00
Girish Ramakrishnan 77d601f0cc mailbox: fix crash when editing quota of new mailboxes 2023-06-15 20:59:25 +05:30
Johannes Zellner 8e99f67fb7 use 'development' client only if apiOrigin template value is empty 2023-06-15 16:41:14 +02:00
Johannes Zellner 9d3fa94960 Add separate password reset view 2023-06-15 16:34:58 +02:00
Johannes Zellner b6739e9d77 Support local development dashboard login 2023-06-15 15:44:16 +02:00
Johannes Zellner 33c1b4ae3b oidc: also send profile with auth code
this helps us to be a bit more conforming with google and MS oidc
provider
2023-06-14 16:49:35 +02:00
Johannes Zellner 67c0a4f513 Copy selected terminal text with ctrl shift c 2023-06-13 15:27:16 +02:00
Johannes Zellner ce1181531a Update dashboard dependencies and fixup apps icon for new fontawesome 2023-06-13 13:54:34 +02:00
Girish Ramakrishnan 54682a1370 remove duplicate require 2023-06-04 18:23:26 +02:00
Girish Ramakrishnan dc5342b9fc automation tag is better 2023-06-04 18:18:22 +02:00
Girish Ramakrishnan 83bb7c475d add devops category 2023-06-04 18:11:34 +02:00
Johannes Zellner 638bdc902b Add implicit grants for dashboard 2023-06-04 17:39:31 +02:00
Johannes Zellner 874064de67 Only store dashboard accessTokens in tokensdb 2023-06-04 17:39:31 +02:00
Johannes Zellner 1f134ff070 Skip consent screen for dashboard login 2023-06-04 17:39:31 +02:00
Johannes Zellner 2c334170bd oidc dashboard login 2023-06-04 17:39:29 +02:00
Johannes Zellner 35efdf6cbd Support both sets of Hetzner nameservers 2023-05-31 18:25:09 +02:00
Girish Ramakrishnan e02f3d7064 Fix dashboard crash when installing app with no addons 2023-05-30 11:06:33 +02:00
Girish Ramakrishnan a5e83a4d84 Expose alias domains as CLOUDRON_ALIAS_DOMAINS
This can be useful for app to set them in trusted hosts. Or alternately,
show different text when accessed from different domains.
2023-05-25 11:47:41 +02:00
Girish Ramakrishnan e6ba2a6e7a replace usage of _.extend with Object.assign 2023-05-25 11:45:14 +02:00
Johannes Zellner 79dd50910c oidc: render error page instead of raw error body 2023-05-23 12:13:55 +02:00
Johannes Zellner c4d267ecb1 filemanager: add restart logic 2023-05-23 11:38:57 +02:00
Johannes Zellner 2011dd9a83 Explicitly add noto font to filemanager assets 2023-05-23 11:08:06 +02:00
Johannes Zellner b07131cd0f oidc: add password reset link to login view 2023-05-22 20:32:33 +02:00
Johannes Zellner d3fe165e2c oidc: Remove console.log in login screen 2023-05-22 20:19:30 +02:00
Johannes Zellner bf19de3a90 Fixup filemanager links 2023-05-22 16:27:48 +02:00
Johannes Zellner 58a0b3d8e7 Ensure localPath is quoted in case it contains spaces 2023-05-21 14:14:42 +02:00
Johannes Zellner 65c2ee1760 filemanager: Add logs and terminal links for apps 2023-05-16 17:48:53 +02:00
Johannes Zellner dfb0a7fee1 filemanager: update dependencies 2023-05-16 15:34:16 +02:00
Girish Ramakrishnan 7511339656 bump timeout when waiting for container
some server disks are very slow
2023-05-16 09:51:42 +02:00
Girish Ramakrishnan cb106f8a55 Fixup text when logs are missing 2023-05-16 09:36:30 +02:00
Girish Ramakrishnan 39d45b71d7 installer: remove user creation, already in init-ubuntu script 2023-05-15 21:10:29 +02:00
Girish Ramakrishnan db1fa84936 update: log history 2023-05-15 21:08:20 +02:00
Girish Ramakrishnan f83295372b updater: combine installer logs into the task file 2023-05-15 19:09:40 +02:00
Girish Ramakrishnan e6506d9458 updater: use log 2023-05-15 19:05:39 +02:00
Johannes Zellner af63dbb31d Show error when logs are gone 2023-05-15 17:49:34 +02:00
Johannes Zellner b5641cc445 Show at least basic error if task or app not found in logviewer 2023-05-15 17:20:43 +02:00
Johannes Zellner 576fb392bb Show dashboard domain change tasks like in other sections 2023-05-15 12:02:59 +02:00
Girish Ramakrishnan ff539e2669 remove crashnotifier
it's not really used
2023-05-15 11:08:00 +02:00
Girish Ramakrishnan 506d3adf70 Fix crash when querying backup mount status 2023-05-15 10:40:39 +02:00
Girish Ramakrishnan 94eb7849fe tasks: return 404 if task not found
part of #826
2023-05-15 10:16:00 +02:00
Johannes Zellner 9036b272a8 filemanager: update pankow module 2023-05-15 10:10:47 +02:00
Johannes Zellner c81467da7c filemanager: add refresh button 2023-05-15 09:57:58 +02:00
Johannes Zellner 6db3a20021 filemanager: support fallbackIcon 2023-05-15 09:26:37 +02:00
Johannes Zellner a428d6c553 filemanager: update dependencies 2023-05-15 09:02:31 +02:00
Girish Ramakrishnan b7b01d5605 domains: show current task in renewCert, syncDns 2023-05-14 11:47:21 +02:00
Girish Ramakrishnan 500d2361ec replace delay.js with timers/promises 2023-05-14 10:53:50 +02:00
Girish Ramakrishnan 75ba20201e Update modules 2023-05-14 07:23:04 +02:00
Girish Ramakrishnan b26c8d20cd network: add trusted ips
This allows the user to set trusted ips to Cloudflare or some other CDN
and have the logs have the correct IPs.

fixes #801
2023-05-13 16:15:47 +02:00
Girish Ramakrishnan 951ed4bf33 Update translations 2023-05-13 15:46:08 +02:00
Johannes Zellner 2a05ec3866 Move password-reveal.js to correct folder 2023-05-12 18:53:42 +02:00
Johannes Zellner 04f2bd1ec3 Add password-reveal feature to oidc login 2023-05-12 18:47:48 +02:00
Johannes Zellner e08116c9ad be more consistent in oidc login screen with dashboard login 2023-05-12 18:24:54 +02:00
Johannes Zellner da7fbeee3d oidc: Give proper login error feedback 2023-05-12 17:14:40 +02:00
Johannes Zellner 61aa32d8c5 App icon route is no open to public 2023-05-12 15:14:47 +02:00
Johannes Zellner 74ff5e8de4 Fix authorize for text in oidc consent screen 2023-05-12 14:01:20 +02:00
Johannes Zellner aad70a49b7 Remove dashboard button on oidc logout 2023-05-12 13:54:35 +02:00
Johannes Zellner d332bb05fa Show app name during oidc login 2023-05-12 13:51:50 +02:00
Johannes Zellner 6b6781eabb filemanager: vue is picky about the type 2023-05-12 13:32:51 +02:00
Girish Ramakrishnan 4a1cdd4ef1 Update aws-sdk and suppress maintenance mode message
https://github.com/aws/aws-sdk-js/issues/4354
2023-05-11 22:18:00 +02:00
Johannes Zellner 764a8f6a85 filemanager: Show non-dismissable dialog on fatal error 2023-05-11 18:36:09 +02:00
Johannes Zellner 22a0b84c2a filemanager: update dependencies 2023-05-11 16:45:13 +02:00
Johannes Zellner bba911165b Remove noisy openid debugs 2023-05-11 16:22:58 +02:00
Johannes Zellner 8656bea4f2 Update oidc-provider 2023-05-11 16:16:19 +02:00
Johannes Zellner 9024844449 Set favicon for OpenId views 2023-05-11 13:48:36 +02:00
Johannes Zellner 89c5b81eb0 Add very basic initial cloudron-logs helper 2023-05-11 12:30:00 +02:00
Johannes Zellner 18a7b0e615 dashboard: use sass instead of deprecated node-sass 2023-05-11 11:29:08 +02:00
Johannes Zellner 1407fbeb8c Fix syntax error in gulpfile 2023-05-11 10:57:52 +02:00
Johannes Zellner b5fc377dab Set app's fqdn as fallback logout redirect URI for oidc 2023-05-11 10:57:52 +02:00
Girish Ramakrishnan 71af16beb9 Update packages 2023-05-11 10:33:18 +02:00
Girish Ramakrishnan 96d3eda02b dashboard: update packages 2023-05-11 08:50:18 +02:00
Girish Ramakrishnan ba2a6bab68 dashboard: remove rimraf 2023-05-11 08:48:42 +02:00
Girish Ramakrishnan 092cc40da6 Fix test 2023-05-11 08:32:31 +02:00
Girish Ramakrishnan c55152c0e1 node: update to 18.16.0 2023-05-11 08:32:31 +02:00
Girish Ramakrishnan e83bb0c639 docker: update to 23.0.6 2023-05-11 08:32:31 +02:00
Johannes Zellner 318285cb07 Support pageSize customization via localStorage 2023-05-10 13:52:41 +02:00
Girish Ramakrishnan 5274e1c454 docker: registry finally has ipv6 support
https://github.com/docker/roadmap/issues/89
2023-05-10 10:14:25 +02:00
Girish Ramakrishnan 294a535c1b cloudron-support: better formatting of log link 2023-05-10 09:11:04 +02:00
Girish Ramakrishnan eaeb80e3c0 cloudron-support: add uname and lsb_release info 2023-05-10 09:08:04 +02:00
Johannes Zellner 6eb8047686 filemanager: open unsupported types in browser itself 2023-05-09 18:53:23 +02:00
Johannes Zellner db040bf293 There is no mail for filemanager 2023-05-09 10:58:29 +02:00
Girish Ramakrishnan acfc1ede6e add to changes 2023-05-09 10:55:22 +02:00
Girish Ramakrishnan 8910c76bcf Update redis to 7.0.11 2023-05-09 10:54:17 +02:00
Johannes Zellner 342093f661 filemanager: improve resource (app/volume/mail) handling 2023-05-08 18:08:11 +02:00
Johannes Zellner 9e26db3cd2 Only show disks with the correct fs type for volumes 2023-05-08 18:07:42 +02:00
Johannes Zellner a71b39ddee Start using the new filemanager 2023-05-08 16:09:33 +02:00
Johannes Zellner 0626354844 Fixup custom disk setup for volumes 2023-05-08 15:23:25 +02:00
Johannes Zellner e9d2a53aaf Add new ionos profitbricks regions 2023-05-08 14:04:46 +02:00
Girish Ramakrishnan ca59bbe1aa remove try/catch 2023-05-08 11:30:21 +02:00
Girish Ramakrishnan f505b1a553 remove log line which ends up in log file 2023-05-07 20:53:04 +02:00
Girish Ramakrishnan a237b11ff7 timezone: set default tz to UTC 2023-05-07 20:51:02 +02:00
Johannes Zellner 9a77f012d8 filemanager: Add path breadcrumbs and update dependencies 2023-05-07 17:04:07 +02:00
Johannes Zellner 36c7f779f3 filemanager: a symlink can't be opened 2023-05-07 13:50:41 +02:00
Girish Ramakrishnan b970e90178 cloudron-support: provider not needed 2023-05-05 17:18:38 +02:00
Johannes Zellner a7ea34914d Also put new task log style for backups view 2023-05-03 16:50:07 +02:00
Johannes Zellner 19e1e5861b provide more task logs for synDNS section 2023-05-03 16:33:19 +02:00
Girish Ramakrishnan e23777a642 kill a warning from npm 2023-05-03 09:15:16 +02:00
Girish Ramakrishnan a2f47f3ee2 7.5.0 changes 2023-05-02 23:08:42 +02:00
Girish Ramakrishnan 15e0f11bb9 acme: handle LE validation type cache logic
LE stores the validation type for 60 days. So, if we authorized via http previously,
we won't get a DNS challenge for that duration.

There are two ways to fix this:
* Deactivate the challenges - https://community.letsencrypt.org/t/authorization-deactivation/19860 and https://community.letsencrypt.org/t/deactivate-authorization/189526
* Just be able to handle dns or http challenge, whatever is asked. This is what this commit does. It prefers DNS challenge when possible

Other relevant threads:

https://community.letsencrypt.org/t/flush-of-authorization-cache/188043
https://community.letsencrypt.org/t/let-s-encrypt-s-vulnerability-as-a-feature-authz-reuse-and-eternal-account-key/21687
https://community.letsencrypt.org/t/http-01-validation-cache/22529
2023-05-02 23:07:32 +02:00
Johannes Zellner 1a32ea511e Use circle icons for task log status 2023-05-02 22:16:16 +02:00
Johannes Zellner ac602dc2a9 Give option to display last 10 cert renewal task logs 2023-05-02 16:55:57 +02:00
Johannes Zellner cf3fc940d2 Put all log viewer buttons in the section headers for the backup view 2023-05-02 15:02:41 +02:00
Johannes Zellner e09cac4ea1 Apply consisten section spacing to all views 2023-05-02 14:29:52 +02:00
Johannes Zellner 7c96115ea9 set constent section spacing in domains view 2023-05-02 14:12:27 +02:00
Johannes Zellner 12de353427 Make domains view also use uib-tooltips for consistency 2023-05-02 13:58:25 +02:00
Girish Ramakrishnan 057e4db6c1 use debug instead of console.error 2023-04-30 21:49:34 +02:00
Girish Ramakrishnan 883915c9d3 backups: move mount status to separate route 2023-04-30 17:21:18 +02:00
Girish Ramakrishnan 898413bfd4 convert console.log to debug 2023-04-30 10:18:48 +02:00
Girish Ramakrishnan aa02d839a7 remove console.log 2023-04-30 10:18:48 +02:00
Girish Ramakrishnan a4ba3a4dd0 import: backupConfig cannot be null 2023-04-30 10:18:48 +02:00
897 changed files with 32443 additions and 31971 deletions
+1 -1
View File
@@ -5,7 +5,7 @@
},
"extends": "eslint:recommended",
"parserOptions": {
"ecmaVersion": 2020
"ecmaVersion": 13
},
"rules": {
"linebreak-style": [
+186
View File
@@ -2591,6 +2591,7 @@
* support: fix crash when opening tickets with 0 length files
[7.4.0]
* **IMPORTANT**: This is the last release of Cloudron to support Ubuntu 18.04. Please [upgrade](https://docs.cloudron.io/guides/upgrade-ubuntu-20/) to Ubuntu 20.04 (Focal Fossa) at the earliest.
* Update base image to jammy
* backups: Add idrive e2
* Support proxyAuth for proxy app
@@ -2628,3 +2629,188 @@
* Fix ipv4 vs ipv6 detection
* Fix misleading pending security updates message
[7.4.3]
* **IMPORTANT**: This is the last release of Cloudron to support Ubuntu 18.04. Please [upgrade](https://docs.cloudron.io/guides/upgrade-ubuntu-20/) to Ubuntu 20.04 (Focal Fossa) at the earliest.
* postgresql: fix for supporting Taiga with postgres 14
[7.5.0]
* **IMPORTANT**: This is the last release of Cloudron to support CPUs without AVX support. AVX support is required for MongoDB 5.0. See https://forum.cloudron.io/topic/8785/avx-support-in-your-vps-server for more information.
* acme: handle LE validation type cache logic
* improve viewing of logs
* redis: update to 7.0.11
* ionos profitbricks: add new regions Berlin and Logrono
* docker: update to 23.0.6
* network: trusted IPs
* mail: fix crash when editing quota of new mailboxes
* mail: update haraka to 3.0.2
* mail: fix issue where client IP was leaked in headers
* mail: skip SPF check of authenticated senders
* filemanager: new UI, support for large folders and lazy loading
* oidc: make UI translatable
* oidc: dashboard login uses oidc
* web terminal: Copy selected terminal text with ctrl shift c
* Expose alias domains as `CLOUDRON_ALIAS_DOMAINS`
[7.5.1]
* **IMPORTANT**: This is the last release of Cloudron to support CPUs without AVX support. AVX support is required for MongoDB 5.0. See https://forum.cloudron.io/topic/8785/avx-support-in-your-vps-server for more information.
* mail: Fix issue where mail usage sizes where reported incorrectly
* filemanager: Only init vue app after we fetch language files to avoid UI shaking
* mail: Clear the correct mail status notification
* filemanager: allow pasting on non-folders to cwd
* mail: give resolver more time
* dashboard: backup logs links are grayed out because of z-index
* branding: make oidc login does not use cloudron name
* translation: fix crash when translated text has single quote (french)
* dyndns: show logs
* mail: server location get it's own section
* optional services: redis & turn . joins sendmail, recvmail
* backups: encrypted backups must have .enc extension
* mail: add virtual all mail mailbox
* redirections: use 301 (permanent) instead of 302 (temporary) for redirections. this is better for SEO links
* graphs: show old backup size if > 1GB
* docker: fix image pruning
* Major overhaul of the REST API
* Fix import via SSHFS and CIFS
[7.5.2]
* mail: Fix default max mail size to 25MB (and not 25MiB)
* dashboard: disable 2fa setup for external users
* filemanager: Always show app or volume name
* filemanager: fix logs button link
* backups: add Contabo object storage
* Fix incorrect migration of directory server setting
* support: Add explicit billing issue ticket type
* Fix broken directory server config migration
* system: fix crash updating disk usage
* Fix crash in renew certs call from cron
[7.6.0]
* Update MongoDB to 5.0. Important: this release requires AVX support in CPU
* turn: add ddos mitigation settings
* api: return json when route not found
* oidc: loginRedirectUri can be empty string
* New base image 4.2.0. `cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4`
* mail: add option to enable/disable Virtual All mailbox
* volumes: edit options for network mounts
* oidc: fix issue with redirects not working on iOS apps
* app proxy: Host header is set to match the proxy domain instead of the target domain
* notifications: Add color coded indicator to notifications
* oidc: add oidc logo as login indicator for apps
* dyndns: update dns every 10 mins
[7.6.1]
* Cleanup backup validation mount point
* dashboard: remove nginx config of old domain when domain changed
* Show disk consumption of docker volumes for /run and /tmp of apps separately
* dns: add dnsimple automation
* roles: admin role can access branding and networking
* dns: add ovh backend
[7.6.2]
* mail: fix issue with redis emitting warnings non-stop
* mail: fix issue where doublle header was sent
* ovh: fix nameserver matching
* logviewer: preserve horizontal scroll position
* redis: use default instead of redisuser
* dockerproxy: allow child containers to access volumes
* dashboard: Show system information
* Fix linode object storage
* postgres: enable cube, vector and earthdistance extensions
* Add ability to register a Cloudron with a setupToken only
* support: replace ticket section with help section
* firewall: increase blocklist size to 262144
[7.6.3]
* postgres: do not clear search_path for restore
* route53: retry on rate limit errors
* update: continue with app update if box update does not start
[7.6.4]
* mail: update limit plugin
* ldap: fix error messages to show proper error messages in the external LDAP connector
* dashboard: fix various UI elements hidden for admin user
* directoryserver: fix totp validation
* email: improve loading of the mail usage to not block other views from loading
* eventlog: add events for directory server and exernal directory configuration
* externalldap: available regardless of subscription
* externalldap: show syncer log history
* externalldap: sync is now run periodically (every 4 hours)
* profile: changing email now requires password
[7.7.0]
* OIDC avatar support via picture claim
* backupcleaner: fix bug where preserved backups were removed incorrectly
* directoryserver: cloudflare warning
* oidc/ldap: fix display name parsing to send anything after first name as the last name
* mail: Update haraka to 3.0.3
* mongodb: Update mongodb to 6.0
* acme: use secp256r1 curve for max compatibility
* add port range support
* docker: disable userland proxy
* oidc: always re-setup oidc client record
* mail: update solr to 8.11.3
* mail: spam acl should allow underscore and question mark
* Fix streaming of logs with `logPaths`
* profile: store user language setting in the database
[7.7.1]
* postgresql: fix bug in loading of contrib extensions
* dashboard: use native slider element for app memory and cpu
[7.7.2]
* docker: use unix domain socket based logging instead of udp
* dashboard: use native slider element for app memory and cpu
* filemanager: fix empty folder content layout
* dashboard: preserve app link paths
* backups: deleted apps must also be displayed in contents
* filemanager: make uploads cancellable
* Fix crash on systemds with no swap
[8.0.0]
* mongodb: optionally start mongodb based on AVX support
* dashboard: font and color improvements
* docker: prune volumes on infra change
* oidc: initial login of admin and normal user now gets an OIDC session
* branding: default background image for the dashboard
* dashboard: list view for apps
* import: fix crash when using mountpoint provider
* dashboard: set '/' as keyboard shortcut
* app: memory limit is redefined to be just RAM and unlimited swap
* dashboard: rework filter UI
* cpu: rework cpu shares into cpu quota
* cifs: enable seal encryption by default
* updatechecker: fix bug where release info was not refreshed
* ovh: storage location domain has changed. add rbx region
* domains: add deSEC integration
* notfound: better message when navigating by IP address
* IPv6 only server installation
* Initial Ubuntu 24.04 (Noble Numbat) support
* syslog: handle potential multiline syslog input
* user directory: fixes to mandatory 2fa setting when cloudron connector is used
* notification: do not send login notification for external users
* dashboard: pending checklist indicator
* cloudron-support: add --recreate-docker and --recreate-container
* filemanager: add dark mode
* proxyauth: now uses oidc instead of ldap auth
* dashboard: add admin notes
* Use systemd-resolved as the system resolver. unbound is now only for mail server and recursive DNS lookups
[8.0.1]
* nfs: disable rpcbind service. we only support nfsv4 mounting
* dashboard: only show postinstall if notes are not just empty
* ami: disable route53
* mailer: add html version of test mail
* sshfs: server side copying
* backups: rewrite tgz backups using tar-stream
* backups: fix issue with s3 backend where files missing in remote was not detected correctly
* provision: redirect to correct task (setup/restore/activation)
[8.0.2]
* tgz: fix unhandled promise error handler
* tgz: add underflow/overflow proxy stream to ensure size of a changing file
* backups: give task a low oomScoreAdjust to not get killed
* Fix issue with uploads via File Manager where temp files were not cleaned up
* addons: fix crash when importing database of an app with no addons
* sshfs: if remote copy fails, fallback to sshfs based copy
* frontend: reduce DOM node creation on very fast logstreams and cap to 1k loglines
-11
View File
@@ -50,17 +50,6 @@ the dashboard, database addons, graph container, base image etc. Cloudron also r
on external services such as the App Store for apps to be installed. As such, don't
clone this repo and npm install and expect something to work.
## Development
This is the backend code of Cloudron. The frontend code is [here](https://git.cloudron.io/cloudron/dashboard).
The way to develop is to first install a full instance of Cloudron in a VM. Then you can use the [hotfix](https://git.cloudron.io/cloudron/cloudron-machine)
tool to patch the VM with the latest code.
```
SSH_PASSPHRASE=sshkeypassword cloudron-machine hotfix --cloudron my.example.com --release 6.0.0 --ssh-key keyname
```
## License
Please note that the Cloudron code is under a source-available license. This is not the same as an
+9 -11
View File
@@ -2,20 +2,20 @@
'use strict';
const fs = require('fs'),
ldap = require('./src/ldap.js'),
const constants = require('./src/constants.js'),
fs = require('fs'),
ldapServer = require('./src/ldapserver.js'),
oidc = require('./src/oidc.js'),
paths = require('./src/paths.js'),
proxyAuth = require('./src/proxyauth.js'),
safe = require('safetydance'),
server = require('./src/server.js'),
settings = require('./src/settings.js'),
directoryServer = require('./src/directoryserver.js');
let logFd;
async function setupLogging() {
if (process.env.BOX_ENV === 'test') return;
if (constants.TEST) return;
logFd = fs.openSync(paths.BOX_LOG_FILE, 'a');
// we used to write using a stream before but it caches internally and there is no way to flush it when things crash
@@ -37,9 +37,9 @@ async function startServers() {
await setupLogging();
await server.start(); // do this first since it also inits the database
await proxyAuth.start();
await ldap.start();
await ldapServer.start();
const conf = await settings.getDirectoryServerConfig();
const conf = await directoryServer.getConfig();
if (conf.enabled) await directoryServer.start();
}
@@ -52,7 +52,7 @@ async function main() {
process.on('SIGHUP', async function () {
debug('Received SIGHUP. Re-reading configs.');
const conf = await settings.getDirectoryServerConfig();
const conf = await directoryServer.getConfig();
if (conf.enabled) await directoryServer.checkCertificate();
});
@@ -62,7 +62,7 @@ async function main() {
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldap.stop();
await ldapServer.stop();
await oidc.stop();
setTimeout(process.exit.bind(process), 3000);
});
@@ -73,14 +73,12 @@ async function main() {
await proxyAuth.stop();
await server.stop();
await directoryServer.stop();
await ldap.stop();
await ldapServer.stop();
await oidc.stop();
setTimeout(process.exit.bind(process), 3000);
});
process.on('uncaughtException', (error) => exitSync({ error, code: 1 }));
console.log(`Cloudron is up and running. Logs are at ${paths.BOX_LOG_FILE}`); // this goes to journalctl
}
main();
-22
View File
@@ -1,22 +0,0 @@
#!/usr/bin/env node
'use strict';
const database = require('./src/database.js');
const crashNotifier = require('./src/crashnotifier.js');
// This is triggered by systemd with the crashed unit name as argument
async function main() {
if (process.argv.length !== 3) return console.error('Usage: crashnotifier.js <unitName>');
const unitName = process.argv[2];
console.log('Started crash notifier for', unitName);
// eventlog api needs the db
await database.initialize();
await crashNotifier.sendFailureLogs(unitName);
}
main();
+31 -93
View File
@@ -2,15 +2,13 @@
'use strict';
var argv = require('yargs').argv,
autoprefixer = require('gulp-autoprefixer'),
const argv = require('yargs').argv,
concat = require('gulp-concat'),
cssnano = require('gulp-cssnano'),
ejs = require('gulp-ejs'),
execSync = require('child_process').execSync,
fs = require('fs'),
gulp = require('gulp'),
rimraf = require('rimraf'),
sass = require('gulp-sass')(require('node-sass')),
sass = require('gulp-sass')(require('sass')),
serve = require('gulp-serve'),
sourcemaps = require('gulp-sourcemaps');
@@ -48,38 +46,21 @@ gulp.task('fontawesome', function () {
.pipe(gulp.dest('dist/3rdparty/fontawesome/'));
});
gulp.task('noto-sans', function () {
return gulp.src('node_modules/@fontsource/noto-sans/**/*')
.pipe(gulp.dest('dist/3rdparty/noto-sans/'));
});
gulp.task('bootstrap', function () {
return gulp.src('node_modules/bootstrap-sass/assets/javascripts/bootstrap.min.js')
.pipe(gulp.dest('dist/3rdparty/js'));
});
gulp.task('monaco', function () {
return gulp.src('node_modules/monaco-editor/min/**/*')
.pipe(gulp.dest('dist/3rdparty/'));
});
gulp.task('moment', function () {
return gulp.src('node_modules/moment/min/*')
.pipe(gulp.dest('dist/3rdparty/js'));
});
gulp.task('xterm-core', function () {
return gulp.src('node_modules/xterm/**/*')
.pipe(gulp.dest('dist/3rdparty/xterm'));
});
gulp.task('xterm-addon-attach', function () {
return gulp.src('node_modules/xterm-addon-attach/**/*')
.pipe(gulp.dest('dist/3rdparty/xterm-addon-attach'));
});
gulp.task('xterm-addon-fit', function () {
return gulp.src('node_modules/xterm-addon-fit/**/*')
.pipe(gulp.dest('dist/3rdparty/xterm-addon-fit'));
});
gulp.task('xterm', gulp.series(['xterm-core', 'xterm-addon-attach', 'xterm-addon-fit']));
gulp.task('3rdparty-copy', function () {
return gulp.src([
'src/3rdparty/**/*.js',
@@ -94,7 +75,7 @@ gulp.task('3rdparty-copy', function () {
]).pipe(gulp.dest('dist/3rdparty/'));
});
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'moment', 'monaco', 'xterm', 'bootstrap', 'fontawesome']));
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'moment', 'bootstrap', 'fontawesome', 'noto-sans']));
// --------------
// JavaScript
@@ -104,7 +85,6 @@ gulp.task('js-index', function () {
return gulp.src([
'src/js/index.js',
'src/js/client.js',
'src/js/main.js',
'src/js/utils.js',
'src/views/*.js'
])
@@ -115,38 +95,11 @@ gulp.task('js-index', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-logs', function () {
return gulp.src(['src/js/logs.js', 'src/js/client.js', 'src/js/utils.js'])
gulp.task('js-passwordreset', function () {
return gulp.src(['src/js/passwordreset.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('logs.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-filemanager', function () {
return gulp.src(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js', 'src/components/*.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('filemanager.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-terminal', function () {
return gulp.src(['src/js/terminal.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('terminal.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-login', function () {
return gulp.src(['src/js/login.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('login.js', { newLine: ';' }))
.pipe(concat('passwordreset.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
@@ -160,6 +113,15 @@ gulp.task('js-setupaccount', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-activation', function () {
return gulp.src(['src/js/activation.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('activation.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-setup', function () {
return gulp.src(['src/js/setup.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
@@ -169,15 +131,6 @@ gulp.task('js-setup', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-setupdns', function () {
return gulp.src(['src/js/setupdns.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('setupdns.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js-restore', function () {
return gulp.src(['src/js/restore.js', 'src/js/client.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
@@ -187,7 +140,7 @@ gulp.task('js-restore', function () {
.pipe(gulp.dest('dist/js'));
});
gulp.task('js', gulp.series([ 'js-index', 'js-logs', 'js-filemanager', 'js-terminal', 'js-login', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ]));
gulp.task('js', gulp.series([ 'js-index', 'js-passwordreset', 'js-setupaccount', 'js-activation', 'js-setup', 'js-restore' ]));
// --------------
// HTML
@@ -197,19 +150,11 @@ gulp.task('html-views', function () {
return gulp.src('src/views/**/*.html').pipe(gulp.dest('dist/views'));
});
gulp.task('html-components', function () {
return gulp.src('src/components/**/*.html').pipe(gulp.dest('dist/components'));
});
gulp.task('html-templates', function () {
return gulp.src('src/templates/**/*').pipe(gulp.dest('dist/templates'));
});
gulp.task('html-raw', function () {
return gulp.src('src/*.html').pipe(ejs({ apiOrigin: apiOrigin, revision: revision }, {}, { ext: '.html' })).pipe(gulp.dest('dist'));
});
gulp.task('html', gulp.series(['html-views', 'html-components', 'html-templates', 'html-raw']));
gulp.task('html', gulp.series(['html-views', 'html-raw']));
// --------------
// CSS
@@ -217,11 +162,10 @@ gulp.task('html', gulp.series(['html-views', 'html-components', 'html-templates'
gulp.task('css', function () {
return gulp.src('src/*.scss')
.pipe(sourcemaps.init())
.pipe(sass({ includePaths: ['node_modules/bootstrap-sass/assets/stylesheets/'] }).on('error', sass.logError))
.pipe(autoprefixer())
.pipe(cssnano())
.pipe(sourcemaps.write())
.pipe(sass({ includePaths: [
'node_modules/bootstrap-sass/assets/stylesheets/',
'node_modules/@fontsource/'
]}).on('error', sass.logError))
.pipe(gulp.dest('dist'));
});
@@ -245,8 +189,7 @@ gulp.task('timezones', function (done) {
// --------------
gulp.task('clean', function (done) {
rimraf.sync('dist');
done();
fs.rm('dist', { recursive: true, force: true }, done);
});
gulp.task('default', gulp.series(['clean', 'html', 'js', 'timezones', '3rdparty', 'translation', 'images', 'css']));
@@ -257,18 +200,13 @@ gulp.task('watch', function (done) {
gulp.watch(['src/translation/*'], gulp.series(['translation']));
gulp.watch(['src/**/*.html'], gulp.series(['html']));
gulp.watch(['src/views/*.html'], gulp.series(['html-views']));
gulp.watch(['src/components/*.html'], gulp.series(['html-components']));
gulp.watch(['src/templates/*.html'], gulp.series(['html-templates']));
gulp.watch(['scripts/createTimezones.js', 'src/js/utils.js'], gulp.series(['timezones']));
gulp.watch(['src/js/activation.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-activation']));
gulp.watch(['src/js/setup.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setup']));
gulp.watch(['src/js/setupdns.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-setupdns']));
gulp.watch(['src/js/restore.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-restore']));
gulp.watch(['src/js/logs.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-logs']));
gulp.watch(['src/js/filemanager.js', 'src/js/client.js', 'src/js/utils.js', 'src/components/*.js'], gulp.series(['js-filemanager']));
gulp.watch(['src/js/terminal.js', 'src/js/client.js', 'src/js/utils.js'], gulp.series(['js-terminal']));
gulp.watch(['src/js/login.js', 'src/js/utils.js'], gulp.series(['js-login']));
gulp.watch(['src/js/passwordreset.js', 'src/js/utils.js'], gulp.series(['js-passwordreset']));
gulp.watch(['src/js/setupaccount.js', 'src/js/utils.js'], gulp.series(['js-setupaccount']));
gulp.watch(['src/js/index.js', 'src/js/client.js', 'src/js/main.js', 'src/views/*.js', 'src/js/utils.js'], gulp.series(['js-index']));
gulp.watch(['src/js/index.js', 'src/js/client.js', 'src/views/*.js', 'src/js/utils.js'], gulp.series(['js-index']));
gulp.watch(['src/3rdparty/**/*'], gulp.series(['3rdparty']));
done();
});
+337 -7193
View File
File diff suppressed because it is too large Load Diff
+7 -13
View File
@@ -13,25 +13,19 @@
"author": "",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.4",
"bootstrap-sass": "^3.4.1",
"chart.js": "^4.1.1",
"@fontsource/noto-sans": "^5.0.21",
"@fortawesome/fontawesome-free": "^6.5.2",
"bootstrap-sass": "^3.4.3",
"chart.js": "^4.4.2",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-cssnano": "^2.1.3",
"gulp-ejs": "^5.1.0",
"gulp-sass": "^5.1.0",
"gulp-serve": "^1.4.0",
"gulp-sourcemaps": "^3.0.0",
"moment": "^2.29.4",
"monaco-editor": "^0.34.0",
"node-sass": "^7.0.3",
"rimraf": "^3.0.2",
"xterm": "^5.1.0",
"xterm-addon-attach": "^0.8.0",
"xterm-addon-fit": "^0.7.0",
"yargs": "^17.5.1"
"moment": "^2.30.1",
"sass": "^1.75.0",
"yargs": "^17.7.2"
},
"eslintConfig": {
"env": {
+2 -2
View File
@@ -24,7 +24,7 @@ function getAccessToken(callback) {
let username = readlineSync.question('Username: ', {});
let password = readlineSync.question('Password: ', { noEchoBack: true });
superagent.post(`https://${cloudronDomain}/api/v1/cloudron/login`, { username: username, password: password }).end(function (error, result) {
superagent.post(`https://${cloudronDomain}/api/v1/auth/login`, { username: username, password: password }).end(function (error, result) {
if (error || result.statusCode !== 200) {
console.log('Login failed');
return getAccessToken(callback);
@@ -63,4 +63,4 @@ getAccessToken(function (accessToken) {
console.log('Done');
});
});
});
+2 -2
View File
@@ -20,7 +20,7 @@ function getAccessToken(callback) {
let username = readlineSync.question('Username: ', {});
let password = readlineSync.question('Password: ', { noEchoBack: true });
superagent.post(`https://${cloudronDomain}/api/v1/cloudron/login`, { username: username, password: password }).end(function (error, result) {
superagent.post(`https://${cloudronDomain}/api/v1/auth/login`, { username: username, password: password }).end(function (error, result) {
if (error || result.statusCode !== 200) {
console.log('Login failed');
return getAccessToken(callback);
@@ -66,4 +66,4 @@ getAccessToken(function (accessToken) {
});
});
});
});
Binary file not shown.
Binary file not shown.
@@ -1,255 +0,0 @@
/*! =======================================================
VERSION 6.0.12
========================================================= */
/*! =========================================================
* bootstrap-slider.js
*
* Maintainers:
* Kyle Kemp
* - Twitter: @seiyria
* - Github: seiyria
* Rohit Kalkur
* - Twitter: @Rovolutionary
* - Github: rovolution
*
* =========================================================
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
* ========================================================= */
.slider {
display: inline-block;
vertical-align: middle;
position: relative;
}
.slider.slider-horizontal {
width: 210px;
height: 20px;
}
.slider.slider-horizontal .slider-track {
height: 10px;
width: 100%;
margin-top: -5px;
top: 50%;
left: 0;
}
.slider.slider-horizontal .slider-selection,
.slider.slider-horizontal .slider-track-low,
.slider.slider-horizontal .slider-track-high {
height: 100%;
top: 0;
bottom: 0;
}
.slider.slider-horizontal .slider-tick,
.slider.slider-horizontal .slider-handle {
margin-left: -10px;
margin-top: -5px;
}
.slider.slider-horizontal .slider-tick.triangle,
.slider.slider-horizontal .slider-handle.triangle {
border-width: 0 10px 10px 10px;
width: 0;
height: 0;
border-bottom-color: #0480be;
margin-top: 0;
}
.slider.slider-horizontal .slider-tick-label-container {
white-space: nowrap;
margin-top: 20px;
}
.slider.slider-horizontal .slider-tick-label-container .slider-tick-label {
padding-top: 4px;
display: inline-block;
text-align: center;
}
.slider.slider-vertical {
height: 210px;
width: 20px;
}
.slider.slider-vertical .slider-track {
width: 10px;
height: 100%;
margin-left: -5px;
left: 50%;
top: 0;
}
.slider.slider-vertical .slider-selection {
width: 100%;
left: 0;
top: 0;
bottom: 0;
}
.slider.slider-vertical .slider-track-low,
.slider.slider-vertical .slider-track-high {
width: 100%;
left: 0;
right: 0;
}
.slider.slider-vertical .slider-tick,
.slider.slider-vertical .slider-handle {
margin-left: -5px;
margin-top: -10px;
}
.slider.slider-vertical .slider-tick.triangle,
.slider.slider-vertical .slider-handle.triangle {
border-width: 10px 0 10px 10px;
width: 1px;
height: 1px;
border-left-color: #0480be;
margin-left: 0;
}
.slider.slider-vertical .slider-tick-label-container {
white-space: nowrap;
}
.slider.slider-vertical .slider-tick-label-container .slider-tick-label {
padding-left: 4px;
}
.slider.slider-disabled .slider-handle {
background-image: -webkit-linear-gradient(top, #dfdfdf 0%, #bebebe 100%);
background-image: -o-linear-gradient(top, #dfdfdf 0%, #bebebe 100%);
background-image: linear-gradient(to bottom, #dfdfdf 0%, #bebebe 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffdfdfdf', endColorstr='#ffbebebe', GradientType=0);
}
.slider.slider-disabled .slider-track {
background-image: -webkit-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%);
background-image: -o-linear-gradient(top, #e5e5e5 0%, #e9e9e9 100%);
background-image: linear-gradient(to bottom, #e5e5e5 0%, #e9e9e9 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ffe5e5e5', endColorstr='#ffe9e9e9', GradientType=0);
cursor: not-allowed;
}
.slider input {
display: none;
}
.slider .tooltip.top {
margin-top: -36px;
}
.slider .tooltip-inner {
white-space: nowrap;
}
.slider .hide {
display: none;
}
.slider-track {
position: absolute;
cursor: pointer;
background-image: -webkit-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%);
background-image: -o-linear-gradient(top, #f5f5f5 0%, #f9f9f9 100%);
background-image: linear-gradient(to bottom, #f5f5f5 0%, #f9f9f9 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff5f5f5', endColorstr='#fff9f9f9', GradientType=0);
-webkit-box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
box-shadow: inset 0 1px 2px rgba(0, 0, 0, 0.1);
border-radius: 4px;
}
.slider-selection {
position: absolute;
background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0);
-webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-radius: 4px;
}
.slider-selection.tick-slider-selection {
background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0);
}
.slider-track-low,
.slider-track-high {
position: absolute;
background: transparent;
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
border-radius: 4px;
}
.slider-handle {
position: absolute;
width: 20px;
height: 20px;
background-color: #337ab7;
background-image: -webkit-linear-gradient(top, #149bdf 0%, #0480be 100%);
background-image: -o-linear-gradient(top, #149bdf 0%, #0480be 100%);
background-image: linear-gradient(to bottom, #149bdf 0%, #0480be 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff149bdf', endColorstr='#ff0480be', GradientType=0);
filter: none;
-webkit-box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05);
box-shadow: inset 0 1px 0 rgba(255,255,255,.2), 0 1px 2px rgba(0,0,0,.05);
border: 0px solid transparent;
}
.slider-handle.round {
border-radius: 50%;
}
.slider-handle.triangle {
background: transparent none;
}
.slider-handle.custom {
background: transparent none;
}
.slider-handle.custom::before {
line-height: 20px;
font-size: 20px;
content: '\2605';
color: #726204;
}
.slider-tick {
position: absolute;
width: 20px;
height: 20px;
background-image: -webkit-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: -o-linear-gradient(top, #f9f9f9 0%, #f5f5f5 100%);
background-image: linear-gradient(to bottom, #f9f9f9 0%, #f5f5f5 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#fff9f9f9', endColorstr='#fff5f5f5', GradientType=0);
-webkit-box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
box-shadow: inset 0 -1px 0 rgba(0, 0, 0, 0.15);
-webkit-box-sizing: border-box;
-moz-box-sizing: border-box;
box-sizing: border-box;
filter: none;
opacity: 0.8;
border: 0px solid transparent;
}
.slider-tick.round {
border-radius: 50%;
}
.slider-tick.triangle {
background: transparent none;
}
.slider-tick.custom {
background: transparent none;
}
.slider-tick.custom::before {
line-height: 20px;
font-size: 20px;
content: '\2605';
color: #726204;
}
.slider-tick.in-selection {
background-image: -webkit-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: -o-linear-gradient(top, #89cdef 0%, #81bfde 100%);
background-image: linear-gradient(to bottom, #89cdef 0%, #81bfde 100%);
background-repeat: repeat-x;
filter: progid:DXImageTransform.Microsoft.gradient(startColorstr='#ff89cdef', endColorstr='#ff81bfde', GradientType=0);
opacity: 1;
}
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-232
View File
@@ -1,232 +0,0 @@
(function(factory) {
if (typeof define === 'function' && define.amd) {
define(['angular', 'bootstrap-slider'], factory);
} else if (typeof module === 'object' && module.exports) {
module.exports = factory(require('angular'), require('bootstrap-slider'));
} else if (window) {
factory(window.angular, window.Slider);
}
})(function (angular, Slider) {
angular.module('ui.bootstrap-slider', [])
.directive('slider', ['$parse', '$timeout', '$rootScope', function ($parse, $timeout, $rootScope) {
return {
restrict: 'AE',
replace: true,
template: '<div><input class="slider-input" type="text" style="width:100%" /></div>',
require: 'ngModel',
scope: {
max: "=",
min: "=",
step: "=",
value: "=",
ngModel: '=',
ngDisabled: '=',
range: '=',
sliderid: '=',
ticks: '=',
ticksLabels: '=',
ticksSnapBounds: '=',
ticksPositions: '=',
scale: '=',
focus: '=',
formatter: '&',
onStartSlide: '&',
onStopSlide: '&',
onSlide: '&'
},
link: function ($scope, element, attrs, ngModelCtrl, $compile) {
var ngModelDeregisterFn, ngDisabledDeregisterFn;
var slider = initSlider();
function initSlider() {
var options = {};
function setOption(key, value, defaultValue) {
options[key] = value || defaultValue;
}
function setFloatOption(key, value, defaultValue) {
options[key] = value || value === 0 ? parseFloat(value) : defaultValue;
}
function setBooleanOption(key, value, defaultValue) {
options[key] = value ? value + '' === 'true' : defaultValue;
}
function getArrayOrValue(value) {
return (angular.isString(value) && value.indexOf("[") === 0) ? angular.fromJson(value) : value;
}
setOption('id', $scope.sliderid);
setOption('orientation', attrs.orientation, 'horizontal');
setOption('selection', attrs.selection, 'before');
setOption('handle', attrs.handle, 'round');
setOption('tooltip', attrs.sliderTooltip || attrs.tooltip, 'show');
setOption('tooltip_position', attrs.sliderTooltipPosition, 'top');
setOption('tooltipseparator', attrs.tooltipseparator, ':');
setOption('ticks', $scope.ticks);
setOption('ticks_labels', $scope.ticksLabels);
setOption('ticks_snap_bounds', $scope.ticksSnapBounds);
setOption('ticks_positions', $scope.ticksPositions);
setOption('scale', $scope.scale, 'linear');
setOption('focus', $scope.focus);
setFloatOption('min', $scope.min, 0);
setFloatOption('max', $scope.max, 10);
setFloatOption('step', $scope.step, 1);
var strNbr = options.step + '';
var dotPos = strNbr.search(/[^.,]*$/);
var decimals = strNbr.substring(dotPos);
setFloatOption('precision', attrs.precision, decimals.length);
setBooleanOption('tooltip_split', attrs.tooltipsplit, false);
setBooleanOption('enabled', attrs.enabled, true);
setBooleanOption('naturalarrowkeys', attrs.naturalarrowkeys, false);
setBooleanOption('reversed', attrs.reversed, false);
setBooleanOption('range', $scope.range, false);
if (options.range) {
if (angular.isArray($scope.value)) {
options.value = $scope.value;
}
else if (angular.isString($scope.value)) {
options.value = getArrayOrValue($scope.value);
if (!angular.isArray(options.value)) {
var value = parseFloat($scope.value);
if (isNaN(value)) value = 5;
if (value < $scope.min) {
value = $scope.min;
options.value = [value, options.max];
}
else if (value > $scope.max) {
value = $scope.max;
options.value = [options.min, value];
}
else {
options.value = [options.min, options.max];
}
}
}
else {
options.value = [options.min, options.max]; // This is needed, because of value defined at $.fn.slider.defaults - default value 5 prevents creating range slider
}
$scope.ngModel = options.value; // needed, otherwise turns value into [null, ##]
}
else {
setFloatOption('value', $scope.value, 5);
}
if (attrs.formatter) {
options.formatter = function(value) {
return $scope.formatter({value: value});
}
}
// check if slider jQuery plugin exists
if ('$' in window && $.fn.slider) {
// adding methods to jQuery slider plugin prototype
$.fn.slider.constructor.prototype.disable = function () {
this.picker.off();
};
$.fn.slider.constructor.prototype.enable = function () {
this.picker.on();
};
}
// destroy previous slider to reset all options
if (element[0].__slider)
element[0].__slider.destroy();
var slider = new Slider(element[0].getElementsByClassName('slider-input')[0], options);
element[0].__slider = slider;
// everything that needs slider element
var updateEvent = getArrayOrValue(attrs.updateevent);
if (angular.isString(updateEvent)) {
// if only single event name in string
updateEvent = [updateEvent];
}
else {
// default to slide event
updateEvent = ['slide'];
}
angular.forEach(updateEvent, function (sliderEvent) {
slider.on(sliderEvent, function (ev) {
ngModelCtrl.$setViewValue(ev);
});
});
slider.on('change', function (ev) {
ngModelCtrl.$setViewValue(ev.newValue);
});
// Event listeners
var sliderEvents = {
slideStart: 'onStartSlide',
slide: 'onSlide',
slideStop: 'onStopSlide'
};
angular.forEach(sliderEvents, function (sliderEventAttr, sliderEvent) {
var fn = $parse(attrs[sliderEventAttr]);
slider.on(sliderEvent, function (ev) {
if ($scope[sliderEventAttr]) {
$scope.$apply(function () {
fn($scope.$parent, { $event: ev, value: ev });
});
}
});
});
// deregister ngDisabled watcher to prevent memory leaks
if (angular.isFunction(ngDisabledDeregisterFn)) {
ngDisabledDeregisterFn();
ngDisabledDeregisterFn = null;
}
ngDisabledDeregisterFn = $scope.$watch('ngDisabled', function (value) {
if (value) {
slider.disable();
}
else {
slider.enable();
}
});
// deregister ngModel watcher to prevent memory leaks
if (angular.isFunction(ngModelDeregisterFn)) ngModelDeregisterFn();
ngModelDeregisterFn = $scope.$watch('ngModel', function (value) {
if($scope.range){
slider.setValue(value);
}else{
slider.setValue(parseFloat(value));
}
slider.relayout();
}, true);
return slider;
}
var watchers = ['min', 'max', 'step', 'range', 'scale', 'ticksLabels', 'ticks'];
angular.forEach(watchers, function (prop) {
$scope.$watch(prop, function () {
slider = initSlider();
});
});
var globalEvents = ['relayout', 'refresh', 'resize'];
angular.forEach(globalEvents, function(event) {
if(angular.isFunction(slider[event])) {
$scope.$on('slider:' + event, function () {
slider[event]();
});
}
});
}
};
}])
;
});
-50
View File
@@ -1,50 +0,0 @@
// !!!
// This module is manually patched by us to not only report valid domains, but verify that subdomains are not accepted
// !!!
'use strict';
angular.module('ngTld', [])
.factory('ngTld', ngTld)
.directive('checkTld', checkTld);
function ngTld() {
function isValid(path) {
// https://github.com/oncletom/tld.js/issues/58
return (path.slice(-1) !== '.') && tld.isValid(path);
}
function tldExists(path) {
return (path.slice(-1) !== '.') && path === tld.getDomain(path);
}
function isSubdomain(path) {
return (path.slice(-1) !== '.') && !!tld.getDomain(path) && path !== tld.getDomain(path);
}
function isNakedDomain(path) {
return (path.slice(-1) !== '.') && !!tld.getDomain(path) && path === tld.getDomain(path);
}
return {
isValid: isValid,
tldExists: tldExists,
isSubdomain: isSubdomain,
isNakedDomain: isNakedDomain
};
}
function checkTld(ngTld) {
return {
restrict: 'A',
require: 'ngModel',
link: function(scope, element, attr, ngModel) {
ngModel.$validators.invalidTld = function(modelValue, viewValue) {
return ngTld.tldExists(ngModel.$viewValue.toLowerCase());
};
ngModel.$validators.invalidSubdomain = function(modelValue, viewValue) {
return !ngTld.isSubdomain(ngModel.$viewValue.toLowerCase());
};
}
};
}
File diff suppressed because one or more lines are too long
-639
View File
@@ -1,639 +0,0 @@
(function($, angular) {
// eslint-disable-next-line angular/file-name, angular/no-service-method
angular.module('ui.bootstrap.contextMenu', [])
.service('CustomService', function () {
'use strict';
return {
initialize: function (item) {
console.log('got here', item);
}
};
})
.constant('ContextMenuEvents', {
// Triggers when all the context menus have been closed
ContextMenuAllClosed: 'context-menu-all-closed',
// Triggers when any single conext menu is called.
// Closing all context menus triggers this for each level open
ContextMenuClosed: 'context-menu-closed',
// Triggers right before the very first context menu is opened
ContextMenuOpening: 'context-menu-opening',
// Triggers right after any context menu is opened
ContextMenuOpened: 'context-menu-opened'
})
.directive('contextMenu', ['$rootScope', 'ContextMenuEvents', '$parse', '$q', 'CustomService', '$sce', '$document', '$window', '$compile',
function ($rootScope, ContextMenuEvents, $parse, $q, custom, $sce, $document, $window, $compile) {
var _contextMenus = [];
// Contains the element that was clicked to show the context menu
var _clickedElement = null;
var DEFAULT_ITEM_TEXT = '"New Item';
var _emptyText = 'empty';
function createAndAddOptionText(params) {
// Destructuring:
var $scope = params.$scope;
var item = params.item;
var event = params.event;
var modelValue = params.modelValue;
var $promises = params.$promises;
var nestedMenu = params.nestedMenu;
var $li = params.$li;
var leftOriented = String(params.orientation).toLowerCase() === 'left';
var optionText = null;
if (item.html) {
if (angular.isFunction(item.html)) {
// runs the function that expects a jQuery/jqLite element
optionText = item.html($scope);
} else {
// Incase we want to compile html string to initialize their custom directive in html string
if (item.compile) {
optionText = $compile(item.html)($scope);
} else {
// Assumes that the developer already placed a valid jQuery/jqLite element
optionText = item.html;
}
}
} else {
var $a = $('<a>');
var $anchorStyle = {};
if (leftOriented) {
$anchorStyle.textAlign = 'right';
$anchorStyle.paddingLeft = '8px';
} else {
$anchorStyle.textAlign = 'left';
$anchorStyle.paddingRight = '8px';
}
$a.css($anchorStyle);
$a.addClass('dropdown-item');
$a.attr({ tabindex: '-1', href: '#' });
var textParam = item.text || item[0];
var text = DEFAULT_ITEM_TEXT;
if (typeof textParam === 'string') {
text = textParam;
} else if (typeof textParam === 'function') {
text = textParam.call($scope, $scope, event, modelValue);
}
var $promise = $q.when(text);
$promises.push($promise);
$promise.then(function (pText) {
if (nestedMenu) {
var $arrow;
var $boldStyle = {
fontFamily: 'monospace',
fontWeight: 'bold'
};
if (leftOriented) {
$arrow = '&lt;';
$boldStyle.float = 'left';
} else {
$arrow = '&gt;';
$boldStyle.float = 'right';
}
var $bold = $('<strong style="font-family:monospace;font-weight:bold;float:right;">' + $arrow + '</strong>');
$bold.css($boldStyle);
$a.css('cursor', 'default');
$a.append($bold);
}
$a.append(pText);
});
optionText = $a;
}
$li.append(optionText);
return optionText;
};
/**
* Process each individual item
*
* Properties of params:
* - $scope
* - event
* - modelValue
* - level
* - item
* - $ul
* - $li
* - $promises
*/
function processItem(params) {
var nestedMenu = extractNestedMenu(params);
// if html property is not defined, fallback to text, otherwise use default text
// if first item in the item array is a function then invoke .call()
// if first item is a string, then text should be the string.
var text = DEFAULT_ITEM_TEXT;
var currItemParam = angular.extend({}, params);
var item = params.item;
var enabled = item.enabled === undefined ? item[2] : item.enabled;
currItemParam.nestedMenu = nestedMenu;
currItemParam.enabled = resolveBoolOrFunc(enabled, params);
currItemParam.text = createAndAddOptionText(currItemParam);
registerCurrentItemEvents(currItemParam);
};
/*
* Registers the appropriate mouse events for options if the item is enabled.
* Otherwise, it ensures that clicks to the item do not propagate.
*/
function registerCurrentItemEvents (params) {
// Destructuring:
var item = params.item;
var $ul = params.$ul;
var $li = params.$li;
var $scope = params.$scope;
var modelValue = params.modelValue;
var level = params.level;
var event = params.event;
var text = params.text;
var nestedMenu = params.nestedMenu;
var enabled = params.enabled;
var orientation = String(params.orientation).toLowerCase();
var customClass = params.customClass;
if (enabled) {
var openNestedMenu = function ($event) {
removeContextMenus(level + 1);
/*
* The object here needs to be constructed and filled with data
* on an "as needed" basis. Copying the data from event directly
* or cloning the event results in unpredictable behavior.
*/
/// adding the original event in the object to use the attributes of the mouse over event in the promises
var ev = {
pageX: orientation === 'left' ? event.pageX - $ul[0].offsetWidth + 1 : event.pageX + $ul[0].offsetWidth - 1,
pageY: $ul[0].offsetTop + $li[0].offsetTop - 3,
// eslint-disable-next-line angular/window-service
view: event.view || window,
target: event.target,
event: $event
};
/*
* At this point, nestedMenu can only either be an Array or a promise.
* Regardless, passing them to `when` makes the implementation singular.
*/
$q.when(nestedMenu).then(function(promisedNestedMenu) {
if (angular.isFunction(promisedNestedMenu)) {
// support for dynamic subitems
promisedNestedMenu = promisedNestedMenu.call($scope, $scope, event, modelValue, text, $li);
}
var nestedParam = {
$scope : $scope,
event : ev,
options : promisedNestedMenu,
modelValue : modelValue,
level : level + 1,
orientation: orientation,
customClass: customClass
};
renderContextMenu(nestedParam);
});
};
$li.on('click', function ($event) {
if($event.which == 1) {
$event.preventDefault();
$scope.$apply(function () {
var cleanupFunction = function () {
$(event.currentTarget).removeClass('context');
removeAllContextMenus();
};
var clickFunction = angular.isFunction(item.click)
? item.click
: (angular.isFunction(item[1])
? item[1]
: null);
if (clickFunction) {
var res = clickFunction.call($scope, $scope, event, modelValue, text, $li);
if(res === undefined || res) {
cleanupFunction();
}
} else {
cleanupFunction();
}
});
}
});
$li.on('mouseover', function ($event) {
$scope.$apply(function () {
if (nestedMenu) {
openNestedMenu($event);
} else {
removeContextMenus(level + 1);
}
});
});
} else {
setElementDisabled($li);
}
};
/**
* @param params - an object containing the `item` parameter
* @returns an Array or a Promise containing the children,
* or null if the option has no submenu
*/
function extractNestedMenu(params) {
// Destructuring:
var item = params.item;
// New implementation:
if (item.children) {
if (angular.isFunction(item.children)) {
// Expects a function that returns a Promise or an Array
return item.children();
} else if (angular.isFunction(item.children.then) || angular.isArray(item.children)) {
// Returns the promise
// OR, returns the actual array
return item.children;
}
return null;
} else {
// nestedMenu is either an Array or a Promise that will return that array.
// NOTE: This might be changed soon as it's a hangover from an old implementation
return angular.isArray(item[1]) ||
(item[1] && angular.isFunction(item[1].then)) ? item[1] : angular.isArray(item[2]) ||
(item[2] && angular.isFunction(item[2].then)) ? item[2] : angular.isArray(item[3]) ||
(item[3] && angular.isFunction(item[3].then)) ? item[3] : null;
}
}
/**
* Responsible for the actual rendering of the context menu.
*
* The parameters in params are:
* - $scope = the scope of this context menu
* - event = the event that triggered this context menu
* - options = the options for this context menu
* - modelValue = the value of the model attached to this context menu
* - level = the current context menu level (defauts to 0)
* - customClass = the custom class to be used for the context menu
*/
function renderContextMenu (params) {
/// <summary>Render context menu recursively.</summary>
// Destructuring:
var $scope = params.$scope;
var event = params.event;
var options = params.options;
var modelValue = params.modelValue;
var level = params.level;
var customClass = params.customClass;
// Initialize the container. This will be passed around
var $ul = initContextMenuContainer(params);
params.$ul = $ul;
// Register this level of the context menu
_contextMenus.push($ul);
/*
* This object will contain any promises that we have
* to wait for before trying to adjust the context menu.
*/
var $promises = [];
params.$promises = $promises;
angular.forEach(options, function (item) {
if (item === null) {
appendDivider($ul);
} else {
// If displayed is anything other than a function or a boolean
var displayed = resolveBoolOrFunc(item.displayed, params);
// Only add the <li> if the item is displayed
if (displayed) {
var $li = $('<li>');
var itemParams = angular.extend({}, params);
itemParams.item = item;
itemParams.$li = $li;
if (typeof item[0] === 'object') {
custom.initialize($li, item);
} else {
processItem(itemParams);
}
if (resolveBoolOrFunc(item.hasTopDivider, itemParams, false)) {
appendDivider($ul);
}
$ul.append($li);
if (resolveBoolOrFunc(item.hasBottomDivider, itemParams, false)) {
appendDivider($ul);
}
}
}
});
if ($ul.children().length === 0) {
var $emptyLi = angular.element('<li>');
setElementDisabled($emptyLi);
$emptyLi.html('<a>' + _emptyText + '</a>');
$ul.append($emptyLi);
}
$document.find('body').append($ul);
doAfterAllPromises(params);
$rootScope.$broadcast(ContextMenuEvents.ContextMenuOpened, {
context: _clickedElement,
contextMenu: $ul,
params: params
});
};
/**
* calculate if drop down menu would go out of screen at left or bottom
* calculation need to be done after element has been added (and all texts are set; thus the promises)
* to the DOM the get the actual height
*/
function doAfterAllPromises (params) {
// Desctructuring:
var $ul = params.$ul;
var $promises = params.$promises;
var level = params.level;
var event = params.event;
var leftOriented = String(params.orientation).toLowerCase() === 'left';
$q.all($promises).then(function () {
var topCoordinate = event.pageY;
var menuHeight = angular.element($ul[0]).prop('offsetHeight');
var winHeight = $window.pageYOffset + event.view.innerHeight;
/// the 20 pixels in second condition are considering the browser status bar that sometimes overrides the element
if (topCoordinate > menuHeight && winHeight - topCoordinate < menuHeight + 20) {
topCoordinate = event.pageY - menuHeight;
/// If the element is a nested menu, adds the height of the parent li to the topCoordinate to align with the parent
if(level && level > 0) {
topCoordinate += event.event.currentTarget.offsetHeight;
}
} else if(winHeight <= menuHeight) {
// If it really can't fit, reset the height of the menu to one that will fit
angular.element($ul[0]).css({ 'height': winHeight - 5, 'overflow-y': 'scroll' });
// ...then set the topCoordinate height to 0 so the menu starts from the top
topCoordinate = 0;
} else if(winHeight - topCoordinate < menuHeight) {
var reduceThresholdY = 5;
if(topCoordinate < reduceThresholdY) {
reduceThresholdY = topCoordinate;
}
topCoordinate = winHeight - menuHeight - reduceThresholdY;
}
var leftCoordinate = event.pageX;
var menuWidth = angular.element($ul[0]).prop('offsetWidth');
var winWidth = event.view.innerWidth + window.pageXOffset;
var padding = 5;
if (leftOriented) {
if (winWidth - leftCoordinate > menuWidth && leftCoordinate < menuWidth + padding) {
leftCoordinate = padding;
} else if (leftCoordinate < menuWidth) {
var reduceThresholdX = 5;
if (winWidth - leftCoordinate < reduceThresholdX + padding) {
reduceThresholdX = winWidth - leftCoordinate + padding;
}
leftCoordinate = menuWidth + reduceThresholdX + padding;
} else {
leftCoordinate = leftCoordinate - menuWidth;
}
} else {
if (leftCoordinate > menuWidth && winWidth - leftCoordinate - padding < menuWidth) {
leftCoordinate = winWidth - menuWidth - padding;
} else if(winWidth - leftCoordinate < menuWidth) {
var reduceThresholdX = 5;
if(leftCoordinate < reduceThresholdX + padding) {
reduceThresholdX = leftCoordinate + padding;
}
leftCoordinate = winWidth - menuWidth - reduceThresholdX - padding;
}
}
$ul.css({
display: 'block',
position: 'absolute',
left: leftCoordinate + 'px',
top: topCoordinate + 'px'
});
});
};
/**
* Creates the container of the context menu (a <ul> element),
* applies the appropriate styles and then returns that container
*
* @return a <ul> jqLite/jQuery element
*/
function initContextMenuContainer(params) {
// Destructuring
var customClass = params.customClass;
var $ul = $('<ul>');
$ul.addClass('dropdown-menu');
$ul.attr({ 'role': 'menu' });
$ul.css({
display: 'block',
position: 'absolute',
left: params.event.pageX + 'px',
top: params.event.pageY + 'px',
'z-index': 10000
});
if(customClass) { $ul.addClass(customClass); }
return $ul;
}
function isTouchDevice() {
return 'ontouchstart' in window || navigator.maxTouchPoints; // works on most browsers | works on IE10/11 and Surface
}
/**
* Removes the context menus with level greater than or equal
* to the value passed. If undefined, null or 0, all context menus
* are removed.
*/
function removeContextMenus (level) {
while (_contextMenus.length && (!level || _contextMenus.length > level)) {
var cm = _contextMenus.pop();
$rootScope.$broadcast(ContextMenuEvents.ContextMenuClosed, { context: _clickedElement, contextMenu: cm });
cm.remove();
}
if(!level) {
$rootScope.$broadcast(ContextMenuEvents.ContextMenuAllClosed, { context: _clickedElement });
}
}
function removeOnScrollEvent(e) {
removeAllContextMenus(e);
}
function removeOnOutsideClickEvent(e) {
var $curr = $(e.target);
var shouldRemove = true;
while($curr.length) {
if ($curr.hasClass('dropdown-menu')) {
shouldRemove = false;
break;
} else {
$curr = $curr.parent();
}
}
if (shouldRemove) {
removeAllContextMenus(e);
}
}
function removeAllContextMenus(e) {
$document.find('body').off('mousedown touchstart', removeOnOutsideClickEvent);
$document.off('scroll', removeOnScrollEvent);
$(_clickedElement).removeClass('context');
removeContextMenus();
$rootScope.$broadcast('');
}
function isBoolean(a) {
return a === false || a === true;
}
/** Resolves a boolean or a function that returns a boolean
* Returns true by default if the param is null or undefined
* @param a - the parameter to be checked
* @param params - the object for the item's parameters
* @param defaultValue - the default boolean value to use if the parameter is
* neither a boolean nor function. True by default.
*/
function resolveBoolOrFunc(a, params, defaultValue) {
var item = params.item;
var $scope = params.$scope;
var event = params.event;
var modelValue = params.modelValue;
defaultValue = isBoolean(defaultValue) ? defaultValue : true;
if (isBoolean(a)) {
return a;
} else if (angular.isFunction(a)) {
return a.call($scope, $scope, event, modelValue);
} else {
return defaultValue;
}
}
function appendDivider($ul) {
var $li = angular.element('<li>');
$li.addClass('divider');
$ul.append($li);
}
function setElementDisabled($li) {
$li.on('click', function ($event) {
$event.preventDefault();
});
$li.addClass('disabled');
}
return function ($scope, element, attrs) {
var openMenuEvents = ['contextmenu'];
_emptyText = $scope.$eval(attrs.contextMenuEmptyText) || 'empty';
if(attrs.contextMenuOn && typeof(attrs.contextMenuOn) === 'string'){
openMenuEvents = attrs.contextMenuOn.split(',');
}
angular.forEach(openMenuEvents, function (openMenuEvent) {
element.on(openMenuEvent.trim(), function (event) {
// Cleanup any leftover contextmenus(there are cases with longpress on touch where we
// still see multiple contextmenus)
removeAllContextMenus();
if(!attrs.allowEventPropagation) {
event.stopPropagation();
event.preventDefault();
}
// Don't show context menu if on touch device and element is draggable
if(isTouchDevice() && element.attr('draggable') === 'true') {
return false;
}
// Remove if the user clicks outside
$document.find('body').on('mousedown touchstart', removeOnOutsideClickEvent);
// Remove the menu when the scroll moves
$document.on('scroll', removeOnScrollEvent);
_clickedElement = event.currentTarget;
$(_clickedElement).addClass('context');
$scope.$apply(function () {
var options = $scope.$eval(attrs.contextMenu);
var customClass = attrs.contextMenuClass;
var modelValue = $scope.$eval(attrs.model);
var orientation = attrs.contextMenuOrientation;
$q.when(options).then(function(promisedMenu) {
if (angular.isFunction(promisedMenu)) {
// support for dynamic items
promisedMenu = promisedMenu.call($scope, $scope, event, modelValue);
}
var params = {
'$scope' : $scope,
'event' : event,
'options' : promisedMenu,
'modelValue' : modelValue,
'level' : 0,
'customClass' : customClass,
'orientation': orientation
};
$rootScope.$broadcast(ContextMenuEvents.ContextMenuOpening, { context: _clickedElement });
renderContextMenu(params);
});
});
// Remove all context menus if the scope is destroyed
$scope.$on('$destroy', function () {
removeAllContextMenus();
});
});
});
if (attrs.closeMenuOn) {
$scope.$on(attrs.closeMenuOn, function () {
removeAllContextMenus();
});
}
};
}]);
// eslint-disable-next-line angular/window-service
})(window.angular.element, window.angular);
+32
View File
@@ -0,0 +1,32 @@
// Custom library to add password show/hide icons to input element with `password-reveal` attribute
// util.js has the angular version, this is for plain js
window.addEventListener('load', function () {
var svgEye = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye" class="svg-inline--fa fa-eye fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z"></path></svg>';
var svgEyeSlash = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye-slash" class="svg-inline--fa fa-eye-slash fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z"></path></svg>';
document.querySelectorAll('[password-reveal]').forEach(function (element) {
var eye = document.createElement('i');
eye.innerHTML = svgEyeSlash;
eye.style.width = '18px';
eye.style.height = '18px';
eye.style.position = 'relative';
eye.style.float = 'right';
eye.style.marginTop = '-24px';
eye.style.marginRight = '10px';
eye.style.cursor = 'pointer';
eye.addEventListener('click', function () {
if (element.type === 'password') {
element.type = 'text';
eye.innerHTML = svgEye;
} else {
element.type = 'password';
eye.innerHTML = svgEyeSlash;
}
});
element.parentNode.style.position = 'relative';
element.parentNode.insertBefore(eye, element.nextSibling);
});
});
-1913
View File
File diff suppressed because one or more lines are too long
+155
View File
@@ -0,0 +1,155 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Setup</title>
<meta name="description" content="Cloudron Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/activation.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container" ng-show="initialized">
<div class="row" ng-show="view === 'owner'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form role="form" name="ownerForm" ng-submit="owner.submit()" novalidate>
<div class="row">
<div class="col-md-12 text-center">
<h1>Welcome to Cloudron</h1>
<h3>Set up Admin Account</h3>
<p class="has-error text-center" ng-show="owner.error.generic">{{ owner.error.generic }}</p>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="form-group" ng-class="{ 'has-error': ownerForm.displayName.$dirty && ownerForm.displayName.$invalid }">
<label class="control-label" for="inputDisplayName">Full Name</label>
<input type="text" class="form-control" ng-model="owner.displayName" id="inputDisplayName" name="displayName" placeholder="Full Name" required autocomplete="off" ng-disabled="owner.busy" autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.email.$dirty && ownerForm.email.$invalid) || (!ownerForm.email.$dirty && owner.error.email) }">
<label class="control-label" for="inputEmail">Email <sup><a ng-href="https://docs.cloudron.io/installation/#admin-account" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="email" class="form-control" ng-model="owner.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" ng-disabled="owner.busy">
<small>A valid email is required for Let's Encrypt certificates. This email is local to your Cloudron. </small>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.username.$dirty && ownerForm.username.$invalid) || (!ownerForm.username.$dirty && owner.error.username) }">
<label class="control-label" for="inputUsername">Username</label>
<input type="text" class="form-control" ng-model="owner.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="1" required autocomplete="off" ng-disabled="owner.busy">
<small>{{ owner.error.username }}</small>
</div>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': ownerForm.password.$dirty && ownerForm.password.$invalid }">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" ng-model="owner.password" id="inputPassword" name="password" placeholder="Password" ng-pattern="/^.{8,}$/" required autocomplete="off" ng-disabled="owner.busy" password-reveal>
<small><span ng-show="ownerForm.password.$dirty && ownerForm.password.$invalid">Password must be at least 8 characters</span> &nbsp;</small>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-success" ng-disabled="ownerForm.$invalid || owner.busy"><i class="fa fa-circle-notch fa-spin" ng-show="owner.busy"></i> Create Admin</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="row" ng-show="view === 'finished'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px 40px;">
<div class="row">
<div class="col-md-12 text-center">
<h1>Cloudron is ready to use</h1>
</div>
</div>
<p>
&nbsp; &nbsp; Before you start:
<ul class="fa-ul">
<li><i class="fa-li fa fa-users"></i>
<b>User management</b>: Cloudron has a central user directory. When installing an app,
you can set it up to authenticate against this directory.
</li>
<br/>
<li><i class="fa-li fa fa-envelope-open"></i>
<b>Email Configuration</b>: Apps are configured to send email based on the settings in the Email view.
This saves you the trouble of having to configure mail settings inside each app.
</li>
<br/>
<li><i class="fa-li fa fa-archive"></i>
<b>Backups</b>: Store your backups on storage services completely independent from your server.
You can use backups to seamlessly migrate your setup to another server.
</li>
<br/>
<li><i class="fa-li fa fa-birthday-cake"></i>
<b>Updates</b>: The Cloudron team tracks upstream releases and publishes app updates after testing.
Your apps are kept fresh &amp; secure.
</li>
</ul>
</p>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<a class="btn btn-success" ng-href="firstTimeLoginUrl">Proceed to Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
+1 -1
View File
@@ -23,7 +23,7 @@
height: 100%;
width: 100%;
text-align: center;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
font-family: "Noto Sans", Helvetica, Arial, sans-serif;
line-height: 1.846;
}
+16
View File
@@ -0,0 +1,16 @@
<script>
var tmp = window.location.hash.slice(1).split('&');
tmp.forEach(function (pair) {
if (pair.indexOf('access_token=') === 0) localStorage.token = pair.split('=')[1];
});
var redirectTo = '/';
if (localStorage.getItem('redirectToHash')) {
redirectTo += localStorage.getItem('redirectToHash');
localStorage.removeItem('redirectToHash');
}
window.location.href = redirectTo;
</script>
-80
View File
@@ -1,80 +0,0 @@
<!-- Modal image/video viewer -->
<div class="modal fade" id="{{ 'mediaViewerModal-' + $id }}" tabindex="-1" role="dialog">
<div class="modal-dialog" style="max-width: 1280px; max-height: calc(100% - 60px);">
<div class="modal-content" style="height: 100%; height: 100%; display: flex; background-color: #000; background-clip: border-box;">
<img ng-show="mediaViewer.type === 'image'" ng-src="{{ mediaViewer.src }}" style="display: block; margin: auto; max-width: 100%; max-height: 100%;" />
<video ng-show="mediaViewer.type === 'video'" controls preload="auto" autoplay ng-src="{{ mediaViewer.src | trustUrl}}" style="display: block; margin: auto; max-width: 100%; max-height: 100%;"></video>
</div>
</div>
</div>
<!-- main content -->
<div class="toolbar">
<div class="btn-group" role="group" style="display: block;">
<!-- TODO figure out why a line break in code between the two buttons results in a gap visually without any margin/padding set -->
<button class="btn btn-primary" ng-click="goDirectoryUp()" ng-disabled="cwd === ''"><i class="fas fa-arrow-left"></i></button><button class="btn btn-primary" ng-disabled="busyRefresh" ng-click="refresh()"><i class="fas fa-redo" ng-class="{ 'fa-spin': busyRefresh }"></i></button>
</div>
<div class="btn-group path-parts" role="group">
<button class="btn btn-default" ng-disabled="cwd === ''" ng-click="changeDirectory('/')" ng-drop="drop($event, '/')" ng-dragleave="dragExit($event, '/')" ng-dragover="dragEnter($event, '/')"><i class="fas fa-home"></i> {{ rootDirLabel }} </button><button class="btn btn-default" ng-disabled="part.path === cwd" ng-click="changeDirectory(part.path)" ng-drop="drop($event, part.path)" ng-dragleave="dragExit($event, part.path)" ng-dragover="dragEnter($event, part.path)" ng-repeat="part in cwdParts">{{ part.name }}</button>
</div>
<div style="display: block;">
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i class="fas fa-plus"></i> {{ 'filemanager.toolbar.new' | tr }}</button>
<ul class="dropdown-menu">
<li><a class="hand" ng-click="onNewFile()">{{ 'filemanager.toolbar.newFile' | tr }}</a></li>
<li><a class="hand" ng-click="onNewFolder()">{{ 'filemanager.toolbar.newFolder' | tr }}</a></li>
</ul>
</div>
<div class="btn-group">
<button type="button" class="btn btn-primary dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i class="fas fa-upload"></i> {{ 'filemanager.toolbar.upload' | tr }}</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a class="hand" ng-click="onUploadFile()">{{ 'filemanager.toolbar.uploadFile' | tr }}</a></li>
<li><a class="hand" ng-click="onUploadFolder()">{{ 'filemanager.toolbar.uploadFolder' | tr }}</a></li>
</ul>
</div>
</div>
</div>
<div class="file-list-header">
<table class="table" style="margin: 0;">
<thead>
<tr>
<th style="width: 42px">&nbsp;</th>
<th style="">{{ 'filemanager.list.name' | tr }}</th>
<th style="width:100px">{{ 'filemanager.list.owner' | tr }}</th>
<th style="width: 80px">{{ 'filemanager.list.size' | tr }}</th>
<th style="width:100px">{{ 'filemanager.list.mtime' | tr }}</th>
<th style="width: 45px;">&nbsp;</th>
</tr>
</thead>
</table>
</div>
<div class="file-list" ng-class="{ 'entry-hovered': dropToBody, 'busy': busy }" context-menu="menuOptionsBlank" ng-mousedown="onClearSelection($event)" ng-drop="drop($event, null)" ng-dragleave="dragExit($event, null)" ng-dragover="dragEnter($event, null)">
<table class="table table-hover" style="margin: 0;">
<tbody>
<tr ng-show="busy && !busyRefresh">
<td colspan="6"><center><h2><i class="fa fa-circle-notch fa-spin"></i></h2></center></td>
</tr>
<tr ng-show="!(busy && !busyRefresh) && entries.length === 0">
<td colspan="" class="text-center">{{ 'filemanager.list.empty' | tr }}</td>
</tr>
<tr style="cursor: default" ng-hide="busy && !busyRefresh" entry-hashkey="{{ entry['$$hashKey'] }}" ng-repeat="entry in entries" ng-mouseup="onMouseup($event, entry)" draggable="true" ng-dragstart="dragStart($event, entry)" ng-drop="drop($event, entry)" context-menu="menuOptions" ng-mousedown="onMousedown($event, entry)" model="entry" ng-dragleave="dragExit($event, entry)" ng-dragover="dragEnter($event, entry)" ng-class="{ 'entry-hovered': entry.hovered, 'entry-selected': isSelected(entry) }">
<td style="width: 42px; height: 42px" ng-dblclick="open(entry)" class="text-center">
<i ng-show="!entry.previewUrl" class="fas fa-lg {{ entry.icon }}" ng-class="{ 'text-primary': entry.isDirectory && !isSelected(entry) }"></i>
<img ng-show="entry.previewUrl" ng-src="{{ entry.previewUrl }}" height="42" width="42" style="object-fit: cover;"/>
</td>
<td class="elide-table-cell" ng-dblclick="open(entry)" style="padding-left: 5px;">{{ entry.fileName }}<span ng-show="entry.isSymbolicLink" class="text-muted" style="margin-left: 20px;">{{ 'filemanager.list.symlink' | tr:{ target: entry.target } }}</span></td>
<td style="width:100px" class="elide-table-cell" ng-dblclick="open(entry)">{{ entry.uid | prettyOwner }}</td>
<td style="width: 80px" class="elide-table-cell" ng-dblclick="open(entry)">{{ entry.size | prettyDecimalSize }}</td>
<td style="width:100px" class="elide-table-cell" ng-dblclick="open(entry)" uib-tooltip="{{ entry.mtime | prettyLongDate }}" tooltip-append-to-body="true">{{ entry.mtime | prettyDate }}</td>
<td style="width: 45px">
<button type="button" class="btn btn-xs btn-default context-menu-action" context-menu="menuOptions" model="entry" context-menu-on="click" ng-click="onEntryContextMenu($event, entry)"><i class="fas fa-ellipsis-h"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
-513
View File
@@ -1,513 +0,0 @@
'use strict';
/* global angular */
/* global sanitize, isModalVisible */
angular.module('Application').component('filetree', {
bindings: {
backendId: '<',
backendType: '<',
view: '<',
clipboard: '<',
onUploadFile: '&',
onUploadFolder: '&',
onNewFile: '&',
onNewFolder: '&',
onRenameEntry: '&',
onExtractEntry: '&',
onChownEntries: '&',
onDeleteEntries: '&',
onCopyEntries: '&',
onCutEntries: '&',
onPasteEntries: '&'
},
templateUrl: 'components/filetree.html?<%= revision %>',
controller: [ '$scope', '$translate', '$timeout', 'Client', FileTreeController ]
});
function FileTreeController($scope, $translate, $timeout, Client) {
var ctrl = this;
$scope.backendId = this.backendId;
$scope.backendType = this.backendType;
$scope.view = this.view;
$scope.busy = true;
$scope.busyRefresh = false;
$scope.client = Client;
$scope.cwd = null;
$scope.cwdParts = [];
$scope.rootDirLabel = '';
$scope.entries = [];
$scope.selected = []; // holds selected entries
$scope.dropToBody = false;
$scope.applicationLink = '';
// register so parent can call child
$scope.$parent.registerChild($scope);
function isArchive(f) {
return f.match(/\.tgz$/) ||
f.match(/\.tar$/) ||
f.match(/\.7z$/) ||
f.match(/\.zip$/) ||
f.match(/\.tar\.gz$/) ||
f.match(/\.tar\.xz$/) ||
f.match(/\.tar\.bz2$/);
}
$scope.menuOptions = []; // shown for entries
$scope.menuOptionsBlank = []; // shown for empty space in folder
function sort() {
return $scope.entries.sort(function (a, b) {
if (a.fileName.toLowerCase() < b.fileName.toLowerCase()) return -1;
return 1;
}).sort(function (a, b) {
if ((a.isDirectory && b.isDirectory) || (!a.isDirectory && !b.isDirectory)) return 0;
if (a.isDirectory && !b.isDirectory) return -1;
return 1;
});
}
$scope.isSelected = function (entry) {
return $scope.selected.indexOf(entry) !== -1;
};
function download(entry) {
var filePath = sanitize($scope.cwd + '/' + entry.fileName);
Client.filesGet($scope.backendId, $scope.backendType, filePath, 'download', function (error) {
if (error) return Client.error(error);
});
}
$scope.dragStart = function ($event, entry) {
var filePaths = $scope.selected.map(function (entry) { return sanitize($scope.cwd + '/' + entry.fileName); });
$event.originalEvent.dataTransfer.setData('application/cloudron-filemanager', JSON.stringify(filePaths));
};
$scope.dragEnter = function ($event, entry) {
$event.originalEvent.stopPropagation();
$event.originalEvent.preventDefault();
// if entry is string, we come from breadcrumb
if (entry && typeof entry === 'string') $event.currentTarget.classList.add('entry-hovered');
else if (entry && entry.isDirectory) entry.hovered = true;
else $scope.dropToBody = true;
$event.originalEvent.dataTransfer.dropEffect = 'move';
};
$scope.dragExit = function ($event, entry) {
$event.originalEvent.stopPropagation();
$event.originalEvent.preventDefault();
// if entry is string, we come from breadcrumb
if (entry && typeof entry === 'string') $event.currentTarget.classList.remove('entry-hovered');
else if (entry && entry.isDirectory) entry.hovered = false;
$scope.dropToBody = false;
$event.originalEvent.dataTransfer.dropEffect = 'move';
};
$scope.drop = function (event, entry) {
event.originalEvent.stopPropagation();
event.originalEvent.preventDefault();
$scope.dropToBody = false;
if (!event.originalEvent.dataTransfer.items[0]) return;
var targetFolder;
if (entry === null) targetFolder = $scope.cwd + '/';
else if (typeof entry === 'string') targetFolder = sanitize(entry);
else targetFolder = sanitize($scope.cwd + '/' + (entry && entry.isDirectory ? entry.fileName : ''));
var dataTransfer = event.originalEvent.dataTransfer;
var dragContent = dataTransfer.getData('application/cloudron-filemanager');
// check if we have internal drag'n'drop
if (dragContent) {
var moved = 0;
// we expect a JSON.stringified Array here
try {
dragContent = JSON.parse(dragContent);
} catch (e) {
console.error('Wrong drag content.', e);
return;
}
// move files
async.eachLimit(dragContent, 5, function (oldFilePath, callback) {
var fileName = oldFilePath.split('/').slice(-1);
var newFilePath = sanitize(targetFolder + '/' + fileName);
// if we drop the item on itself
if (oldFilePath === targetFolder) return callback();
// if nothing changes
if (newFilePath === oldFilePath) return callback();
moved++;
// TODO this will overwrite files in destination!
Client.filesRename($scope.backendId, $scope.backendType, oldFilePath, newFilePath, callback);
}, function (error) {
if (error) return Client.error(error);
// only refresh if anything has changed
if (moved) $scope.refresh();
});
return;
}
// figure if a folder was dropped on a modern browser, in this case the first would have to be a directory
var folderItem;
try {
folderItem = dataTransfer.items[0].webkitGetAsEntry();
if (folderItem.isFile) return $scope.$parent.uploadFiles(event.originalEvent.dataTransfer.files, targetFolder, false);
} catch (e) {
return $scope.$parent.uploadFiles(event.originalEvent.dataTransfer.files, targetFolder, false);
}
// if we got here we have a folder drop and a modern browser
// now traverse the folder tree and create a file list
var fileList = [];
function traverseFileTree(item, path, callback) {
if (item.isFile) {
// Get file
item.file(function (file) {
fileList.push(file);
callback();
});
} else if (item.isDirectory) {
// Get folder contents
var dirReader = item.createReader();
dirReader.readEntries(function (entries) {
async.each(entries, function (entry, callback) {
traverseFileTree(entry, path + item.name + '/', callback);
}, callback);
});
}
}
traverseFileTree(folderItem, '', function (error) {
if (error) return console.error(error);
$scope.$parent.uploadFiles(fileList, targetFolder, false);
});
};
$scope.refresh = function () {
$scope.$parent.refresh();
};
function amendIcons() {
$scope.entries.forEach(function (e) {
e.icon = 'fa-file';
e.previewUrl = null;
if (e.isDirectory) e.icon = 'fa-folder';
if (e.isSymbolicLink) e.icon = 'fa-link';
if (e.isFile) {
var mimeType = Mimer().get(e.fileName.toLowerCase());
var mimeGroup = mimeType.split('/')[0];
if (mimeGroup === 'text') e.icon = 'fa-file-alt';
// if (mimeGroup === 'image') e.icon = 'fa-file-image';
if (mimeGroup === 'image') {
e.icon = 'fa-file-image';
e.previewUrl = Client.filesGetLink($scope.backendId, $scope.backendType, sanitize($scope.cwd + '/' + e.fileName));
}
if (mimeGroup === 'video') e.icon = 'fa-file-video';
if (mimeGroup === 'audio') e.icon = 'fa-file-audio';
if (mimeType === 'text/csv') e.icon = 'fa-file-csv';
if (mimeType === 'application/pdf') e.icon = 'fa-file-pdf';
}
});
}
// called from the parent
$scope.onRefresh = function () {
$scope.selected = [];
$scope.busy = true;
$scope.busyRefresh = true;
Client.filesGet($scope.backendId, $scope.backendType, $scope.cwd, 'data', function (error, result) {
if (error && error.statusCode !== 404) return Client.error(error);
$scope.entries = result ? result.entries : [];
amendIcons();
sort();
$scope.busyRefresh = false;
$scope.busy = false;
});
};
function openDirectory(path) {
$scope.cwd = path;
$scope.selected = [];
$scope.cwdParts = path.split('/').filter(function (p) { return !!p; }).map(function (p, i) { return { name: decodeURIComponent(p), path: path.split('/').slice(0, i+1).join('/') }; });
// refresh will set busy to false once done
$scope.refresh();
}
function openFile(entry) {
var mimeType = Mimer().get(entry.fileName);
var mimeGroup = mimeType.split('/')[0];
var path = sanitize($scope.cwd + '/' + entry.fileName);
if (mimeGroup === 'video' || mimeGroup === 'image') {
$scope.mediaViewer.show(entry);
} else if (mimeType === 'application/pdf') {
Client.filesGet($scope.backendId, $scope.backendType, path, 'open', function (error) { if (error) return Client.error(error); });
} else if (mimeGroup === 'text' || mimeGroup === 'application') {
$scope.$parent.textEditor.show($scope.cwd, entry);
} else {
Client.filesGet($scope.backendId, $scope.backendType, path, 'open', function (error) { if (error) return Client.error(error); });
}
$scope.busy = false;
}
$scope.open = function (entry) {
if (entry.isDirectory) openDirectory(sanitize($scope.cwd + '/' + entry.fileName));
else if (entry.isFile) openFile(entry);
};
$scope.goDirectoryUp = function () {
openDirectory(sanitize($scope.cwd + '/..'));
};
$scope.changeDirectory = function (path) {
openDirectory(sanitize(path));
};
$scope.onClearSelection = function ($event) {
// we don't stop propagation if targets don't match we got the whole list click event
if ($event.currentTarget !== $event.target) return;
$scope.selected = [];
};
$scope.onMousedown = function ($event, entry) {
if ($event.button === 2) {
$scope.onMouseup($event, entry);
}
};
$scope.onMouseup = function ($event, entry) {
var i = $scope.selected.indexOf(entry);
var multi = ($event.ctrlKey || $event.metaKey);
var shift = $event.shiftKey;
if (shift) {
if ($scope.selected.length === 0) {
$scope.selected = [ entry ];
} else {
var pos = $scope.entries.indexOf(entry);
var selectedPositions = $scope.selected.map(function (s) { return $scope.entries.indexOf(s); }).sort();
if (pos < selectedPositions[0]) {
$scope.selected = $scope.entries.slice(pos, selectedPositions[0]+1);
} else if (selectedPositions[1] && pos > selectedPositions[1]) {
$scope.selected = $scope.entries.slice(selectedPositions[1], pos+1);
} else {
$scope.selected = $scope.entries.slice(selectedPositions[0], pos+1);
}
}
} else if (multi) {
if (i === -1) {
$scope.selected.push(entry);
} else if ($event.button === 0) { // only do this on left click
$scope.selected.splice(i, 1);
}
} else {
$scope.selected = [ entry ];
}
};
$scope.onEntryContextMenu = function ($event, entry) {
if ($scope.selected.indexOf(entry) !== -1) return;
$scope.selected.push(entry);
};
$scope.actionSelectAll = function () {
$scope.selected = $scope.entries.slice();
};
// just events to the parent controller
$scope.onUploadFile = function () { ctrl.onUploadFile({ cwd: $scope.cwd }); };
$scope.onUploadFolder = function () { ctrl.onUploadFolder({ cwd: $scope.cwd }); };
$scope.onNewFile = function () { ctrl.onNewFile({ cwd: $scope.cwd }); };
$scope.onNewFolder = function () { ctrl.onNewFolder({ cwd: $scope.cwd }); };
$scope.mediaViewer = {
type: '',
src: '',
entry: null,
show: function (entry) {
var filePath = sanitize($scope.cwd + '/' + entry.fileName);
$scope.mediaViewer.entry = entry;
$scope.mediaViewer.type = Mimer().get(entry.fileName).split('/')[0];
$scope.mediaViewer.src = Client.filesGetLink($scope.backendId, $scope.backendType, filePath);
$('#mediaViewerModal-' + $scope.$id).modal('show');
},
close: function () {
// set an empty pixel image to bust the cached img to avoid flickering on slow load
$scope.mediaViewer.src = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z/C/HgAGgwJ/lK3Q6wAAAABJRU5ErkJggg==';
$('#mediaViewerModal-' + $scope.$id).modal('hide');
}
};
$translate(['filemanager.list.menu.edit', 'filemanager.list.menu.cut', 'filemanager.list.menu.copy', 'filemanager.list.menu.paste', 'filemanager.list.menu.rename', 'filemanager.list.menu.chown', 'filemanager.list.menu.extract', 'filemanager.list.menu.download', 'filemanager.list.menu.delete' ]).then(function (tr) {
$scope.menuOptions = [
{
text: tr['filemanager.list.menu.edit'],
displayed: function ($itemScope, $event, entry) { return !entry.isDirectory && !entry.isSymbolicLink; },
enabled: function () { return $scope.selected.length === 1; },
hasBottomDivider: true,
click: function ($itemScope, $event, entry) { $scope.open(entry); }
}, {
text: tr['filemanager.list.menu.cut'],
click: function ($itemScope, $event, entry) { ctrl.onCutEntries({ cwd: $scope.cwd, entries: $scope.selected.slice() }); }
}, {
text: tr['filemanager.list.menu.copy'],
click: function ($itemScope, $event, entry) { ctrl.onCopyEntries({ cwd: $scope.cwd, entries: $scope.selected.slice() }); }
}, {
text: tr['filemanager.list.menu.paste'],
hasBottomDivider: true,
enabled: function () { return ctrl.clipboard.length; },
click: function ($itemScope, $event, entry) { ctrl.onPasteEntries({ cwd: $scope.cwd, entry: entry }); }
}, {
text: tr['filemanager.list.menu.rename'],
enabled: function () { return $scope.selected.length === 1; },
click: function ($itemScope, $event, entry) { ctrl.onRenameEntry({ cwd: $scope.cwd, entry: entry }); }
}, {
text: tr['filemanager.list.menu.chown'],
click: function ($itemScope, $event, entry) { ctrl.onChownEntries({ cwd: $scope.cwd, entries: $scope.selected }); }
}, {
text: tr['filemanager.list.menu.extract'],
displayed: function ($itemScope, $event, entry) { return !entry.isDirectory && isArchive(entry.fileName); },
click: function ($itemScope, $event, entry) { ctrl.onExtractEntry({ cwd: $scope.cwd, entry: entry }); }
}, {
text: tr['filemanager.list.menu.download'],
enabled: function () { return $scope.selected.length === 1; },
click: function ($itemScope, $event, entry) { download(entry); }
}, {
text: tr['filemanager.list.menu.delete'],
hasTopDivider: true,
click: function ($itemScope, $event, entry) { ctrl.onDeleteEntries({ cwd: $scope.cwd, entries: $scope.selected }); }
}
];
});
$translate(['filemanager.toolbar.newFile', 'filemanager.toolbar.newFolder', 'filemanager.list.menu.paste', 'filemanager.list.menu.selectAll' ]).then(function (tr) {
$scope.menuOptionsBlank = [
{
text: tr['filemanager.toolbar.newFile'],
click: function ($itemScope, $event) { ctrl.onNewFile({ cwd: $scope.cwd }); }
}, {
text: tr['filemanager.toolbar.newFolder'],
click: function ($itemScope, $event) { ctrl.onNewFolder({ cwd: $scope.cwd }); }
}, {
text: tr['filemanager.list.menu.paste'],
hasTopDivider: true,
hasBottomDivider: true,
enabled: function () { return ctrl.clipboard.length; },
click: function ($itemScope, $event) { ctrl.onPasteEntries({ cwd: $scope.cwd, entry: null }); }
}, {
text: tr['filemanager.list.menu.selectAll'],
click: function ($itemScope, $event) { $scope.actionSelectAll(); }
}
];
});
function scrollInView(element) {
if (!element) return;
// This assumes the DOM tree being that rigid
function isVisible(ele) {
var container = ele.parentElement.parentElement.parentElement;
var eleTop = ele.offsetTop;
var eleBottom = eleTop + ele.clientHeight;
var containerTop = container.scrollTop;
var containerBottom = containerTop + container.clientHeight;
// The element is fully visible in the container
return (
(eleTop >= containerTop && eleBottom <= containerBottom) ||
// Some part of the element is visible in the container
(eleTop < containerTop && containerTop < eleBottom) ||
(eleTop < containerBottom && containerBottom < eleBottom)
);
}
if (!isVisible(element)) element.scrollIntoView();
}
function openSelected() {
if (!$scope.selected.length) return;
$scope.open($scope.selected[0]);
}
function selectNext() {
var entries = sort();
if (!$scope.selected.length) return $scope.selected = [ entries[0] ];
var curIndex = $scope.entries.indexOf($scope.selected[0]);
if (curIndex !== -1 && curIndex < $scope.entries.length-1) {
var entry = entries[++curIndex];
$scope.selected = [ entry ];
scrollInView(document.querySelector('[entry-hashkey="' + entry['$$hashKey'] + '"]'));
}
}
function selectPrev() {
var entries = sort();
if (!$scope.selected.length) return $scope.selected = [ entries.slice(-1) ];
var curIndex = $scope.entries.indexOf($scope.selected[0]);
if (curIndex !== -1 && curIndex !== 0) {
var entry = entries[--curIndex];
$scope.selected = [ entry ];
scrollInView(document.querySelector('[entry-hashkey="' + entry['$$hashKey'] + '"]'));
}
}
openDirectory('.');
$('.file-list').on('scroll', function (event) {
if (event.target.scrollTop > 10) event.target.classList.add('top-scroll-indicator');
else event.target.classList.remove('top-scroll-indicator');
});
// handle shortcuts
window.addEventListener('keydown', function (event) {
if ($scope.$parent.activeView !== $scope.view || $scope.$parent.viewerOpen || isModalVisible()) return;
if (event.key === 'ArrowDown') {
$scope.$apply(selectNext);
} else if (event.key === 'ArrowUp') {
$scope.$apply(selectPrev);
} else if (event.key === 'Enter') {
$scope.$apply(openSelected);
} else if (event.key === 'Backspace') {
if ($scope.view === 'fileTree') $scope.goDirectoryUp();
}
});
}
-354
View File
@@ -1,354 +0,0 @@
<!DOCTYPE html>
<html ng-app="Application">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Filemanager</title>
<meta name="description" content="Cloudron Filemanager">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js?<%= revision %>"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-animate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-base64.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-sanitize.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- colors -->
<script type="text/javascript" src="/3rdparty/js/colors.js?<%= revision %>"></script>
<!-- moment -->
<script type="text/javascript" src="/3rdparty/js/moment-with-locales.min.js?<%= revision %>"></script>
<!-- https://github.com/data-uri/mimer -->
<script type="text/javascript" src="/3rdparty/js/mimer.min.js?<%= revision %>"></script>
<!-- https://github.com/Templarian/ui.bootstrap.contextMenu -->
<script type="text/javascript" src="/3rdparty/js/contextMenu.js?<%= revision %>"></script>
<!-- WARNING this adds an AMD loader! Make sure script tag includes like mimer are above -->
<!-- monaco-editor -->
<script type="text/javascript" src="/3rdparty/vs/loader.js?<%= revision %>"></script>
<!-- Main Application -->
<script type="text/javascript" src="/js/filemanager.js?<%= revision %>"></script>
</head>
<body class="filemanager" ng-drop="drop($event)" ng-dragover="dragEnter($event)" ng-dragleave="dragExit($event)" ng-controller="FileManagerController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> {{ 'main.offline' | tr }}</a>
<div class="restart-banner animateMe" ng-show="restartBusy" ng-cloak><i class="fa fa-circle-notch fa-spin"></i> {{ 'filemanager.status.restartingApp' | tr}}</div>
<!-- Modal delete entries -->
<div class="modal fade" id="entriesDeleteModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
<p class="text-bold text-danger" ng-show="deleteEntries.error">{{ deleteEntries.error }}</p>
<h4 ng-hide="deleteEntries.error">{{ 'filemanager.removeDialog.reallyDelete' | tr }}</h4>
<ul>
<li ng-repeat="entry in deleteEntries.entries">{{ entry.fileName }}</li>
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.no' | tr }}</button>
<button type="button" class="btn btn-danger" ng-click="deleteEntries.submit()" ng-hide="deleteEntries.error" ng-disabled="deleteEntries.busy"><i class="fa fa-circle-notch fa-spin" ng-show="deleteEntries.busy"></i> {{ 'main.dialog.yes' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal rename entry -->
<div class="modal fade" id="renameEntryModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'filemanager.renameDialog.title' | tr:{ fileName: renameEntry.entry.fileName } }}</h4>
</div>
<div class="modal-body">
<form role="form" name="renameEntryForm" ng-submit="renameEntry.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (renameEntryForm.newName.$dirty && renameEntryForm.newName.$invalid) }">
<label class="control-label">{{ 'filemanager.renameDialog.newName' | tr }}</label>
<div class="control-label" ng-show="renameEntry.error">{{ renameEntry.error }}</div>
<input type="text" class="form-control" id="inputNewName" name="newName" ng-model="renameEntry.newName" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="renameEntryForm.$invalid || renameEntry.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="renameEntry.submit()" ng-hide="renameEntry.error" ng-disabled="renameEntry.busy"><i class="fa fa-circle-notch fa-spin" ng-show="renameEntry.busy"></i> {{ 'filemanager.renameDialog.rename' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal extract -->
<div class="modal fade" id="extractModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'filemanager.extractDialog.title' | tr:{ fileName: extractStatus.fileName } }}</h4>
</div>
<div class="modal-body">
<div ng-show="extractStatus.error">
<p class="text-danger">{{ extractStatus.error }}</p>
</div>
<div class="progress progress-striped active" ng-hide="extractStatus.error">
<div class="progress-bar" role="progressbar" style="width: 100%">
</div>
</div>
<p class="no-wrap" ng-hide="extractStatus.error">{{ extractStatus.fileName }}</p>
</div>
<div class="modal-footer" style="text-align: left;">
<small ng-hide="extractStatus.error">{{ 'filemanager.extractDialog.closeWarning' | tr }}</small>
<button class="btn btn-primary pull-right" ng-show="extractStatus.error" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal chown entry -->
<div class="modal fade" id="chownEntriesModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'filemanager.chownDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<form role="form" name="chownEntryForm" ng-submit="chownEntries.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (chownEntryForm.newOwner.$dirty && chownEntries.error) }">
<label class="control-label">{{ 'filemanager.chownDialog.newOwner' | tr }}</label>
<div class="control-label" for="inputNewOwner" ng-show="chownEntries.error">{{ chownEntries.error }}</div>
<select class="form-control" id="inputNewOwner" name="newOwner" ng-model="chownEntries.newOwner" ng-options="a.value as a.name for a in OWNERS" ng-disabled="chownEntries.busy"></select>
</div>
<div class="form-group" ng-show="chownEntries.showRecursiveOption">
<input type="checkbox" id="inputNewOwnerRecursive" ng-model="chownEntries.recursive">
<label class="control-label" for="inputNewOwnerRecursive">{{ 'filemanager.chownDialog.recursiveCheckbox' | tr }}</label>
</div>
<input class="ng-hide" type="submit" ng-disabled="chownEntryForm.$invalid || chownEntries.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="chownEntries.submit()" ng-hide="chownEntries.error" ng-disabled="chownEntries.busy"><i class="fa fa-circle-notch fa-spin" ng-show="chownEntries.busy"></i> {{ 'filemanager.chownDialog.change' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal new file -->
<div class="modal fade" id="newFileModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'filemanager.newFileDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<form role="form" name="newFileForm" ng-submit="newFile.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': newFile.error || (newFileForm.fileName.$dirty && newFileForm.fileName.$invalid) }">
<input type="text" class="form-control" id="inputFileName" name="fileName" ng-model="newFile.name" required autofocus>
<div class="control-label" ng-show="newFile.error === 'exists'">{{ 'filemanager.newFile.errorAlreadyExists' | tr }}</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="newFileForm.$invalid || newFile.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="newFile.submit()" ng-disabled="newFile.busy"><i class="fa fa-circle-notch fa-spin" ng-show="newFile.busy"></i> {{ 'filemanager.newFileDialog.create' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal new directory -->
<div class="modal fade" id="newFolderModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'filemanager.newDirectoryDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<form role="form" name="newFolderForm" ng-submit="newFolder.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': newFolder.error || (newFolderForm.directoryName.$dirty && newFolderForm.directoryName.$invalid) }">
<input type="text" class="form-control" id="inputDirectoryName" name="directoryName" ng-model="newFolder.name" required autofocus>
<div class="control-label" ng-show="newFolder.error === 'exists'">{{ 'filemanager.newDirectory.errorAlreadyExists' | tr }}</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="newDirectoryForm.$invalid || newFolder.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="newFolder.submit()" ng-disabled="newFolder.busy"><i class="fa fa-circle-notch fa-spin" ng-show="newFolder.busy"></i> {{ 'filemanager.newDirectoryDialog.create' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal editor close -->
<div class="modal fade" id="textEditorCloseModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'filemanager.textEditorCloseDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<p class="text-bold text-danger">{{ 'filemanager.textEditorCloseDialog.details' | tr }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-click="textEditor.onClose()">{{ 'filemanager.textEditorCloseDialog.dontSave' | tr }}</button>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="textEditor.saveAndClose()"><i class="fa fa-circle-notch fa-spin" ng-show="textEditor.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal upload -->
<div class="modal fade" id="uploadModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'filemanager.uploadingDialog.title' | tr:{ countDone: uploadStatus.countDone, count: uploadStatus.count } }}</h4>
</div>
<div class="modal-body">
<div ng-show="uploadStatus.error">
<p class="text-danger" ng-show="uploadStatus.error === 'exists'">{{ 'filemanager.uploadingDialog.errorAlreadyExists' | tr }}</p>
<p class="text-danger" ng-show="uploadStatus.error === 'generic'">{{ 'filemanager.uploadingDialog.errorFailed' | tr }}</p>
</div>
<span><b>{{ uploadStatus.sizeDone | prettyDecimalSize }}</b> (total {{ uploadStatus.size | prettyDecimalSize }})</span>
<div class="progress progress-striped active" ng-hide="uploadStatus.error">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ uploadStatus.percentDone || 0 }}%"></div>
</div>
<p class="no-wrap" ng-hide="uploadStatus.error">{{ uploadStatus.fileName }}</p>
</div>
<div class="modal-footer" style="text-align: left;">
<small ng-hide="uploadStatus.error">{{ 'filemanager.uploadingDialog.closeWarning' | tr }}</small>
<button class="btn btn-default pull-right" ng-show="uploadStatus.error" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button class="btn btn-primary pull-right" ng-show="uploadStatus.error === 'generic'" ng-click="retryUpload(false)">{{ 'filemanager.uploadingDialog.retry' | tr }}</button>
<button class="btn btn-danger pull-right" ng-show="uploadStatus.error === 'exists'" ng-click="retryUpload(true)">{{ 'filemanager.uploadingDialog.overwrite' | tr }}</button>
</div>
</div>
</div>
</div>
<div class="animateMe ng-hide layout-root" ng-show="initialized">
<div class="row" ng-hide="title">
<div class="col-md-12 text-center">
<h3>{{ 'filemanager.notFound' | tr }}</h3>
</div>
</div>
<input type="file" id="uploadFileInput" style="display: none" multiple/>
<input type="file" id="uploadFolderInput" style="display: none" multiple webkitdirectory directory/>
<div class="container card" ng-show="title" style="max-width: unset;">
<h4 class="text-left">
{{ title }}
<div class="pull-right">
<div class="btn-group" ng-show="volumes.length">
<button type="button" class="btn btn-sm btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i class="fas fa-folder"></i> <span class="caret"></span></button>
<ul class="dropdown-menu dropdown-menu-right">
<li ng-repeat="volume in volumes"><a class="hand" ng-href="{{ '/filemanager.html?type=volume&id=' + volume.id }}" target="_blank"><i class="fas fa-folder fa-fw"></i> {{ volume.name }}</a></li>
</ul>
</div>
<button type="button" class="btn btn-sm btn-default" ng-class="{ 'active': splitView }" ng-click="toggleSplitView()"><i class="fas fa-columns"></i></button>
<div class="btn-group">
<button type="button" class="btn btn-sm btn-default" ng-show="backendType === 'app'" ng-click="onRestartApp()" uib-tooltip="{{ 'filemanager.toolbar.restartApp' | tr }}" tooltip-placement="bottom" tooltip-append-to-body="true"><i class="fas fa-sync-alt"></i></button>
<button type="button" class="btn btn-sm btn-default" ng-show="backendType === 'mail'" ng-click="onRestartMail()" uib-tooltip="{{ 'filemanager.toolbar.restartApp' | tr }}" tooltip-placement="bottom" tooltip-append-to-body="true"><i class="fas fa-sync-alt"></i></button>
<a type="button" class="btn btn-sm btn-default" ng-show="backendType === 'app'" ng-href="/logs.html?{{ backendType === 'app' ? 'appId=' + backendId : 'id=mail' }}" target="_blank" uib-tooltip="{{ 'filemanager.toolbar.openLogs' | tr }}" tooltip-placement="bottom" tooltip-append-to-body="true"><i class="fas fa-align-left"></i></a>
<a type="button" class="btn btn-sm btn-default" ng-show="backendType === 'app'" ng-href="{{ '/terminal.html?id=' + backendId }}" target="_blank" uib-tooltip="{{ 'filemanager.toolbar.openTerminal' | tr }}" tooltip-placement="bottom" tooltip-append-to-body="true"><i class="fa fa-terminal"></i></a>
</div>
</div>
</h4>
<div class="file-trees">
<filetree ng-class="{ 'two-pane': splitView }"
on-upload-folder="onUploadFolder(cwd)"
on-upload-file="onUploadFile(cwd)"
on-new-file="newFile.show(cwd)"
on-new-folder="newFolder.show(cwd)"
on-copy-entries="actionCopy(cwd, entries)"
on-cut-entries="actionCut(cwd, entries)"
on-paste-entries="actionPaste(cwd, entries)"
on-delete-entries="deleteEntries.show(cwd, entries)"
on-rename-entry="renameEntry.show(cwd, entry)"
on-extract-entry="extractEntry(cwd, entry)"
on-chown-entries="chownEntries.show(cwd, entries)"
backend-type="backendType" backend-id="backendId" view="VIEW.LEFT" clipboard="clipboard"
ng-click="setActiveView(VIEW.LEFT)"></filetree>
<filetree ng-show="splitView" class="two-pane"
on-upload-folder="onUploadFolder(cwd)"
on-upload-file="onUploadFile(cwd)"
on-new-file="newFile.show(cwd)"
on-new-folder="newFolder.show(cwd)"
on-copy-entries="actionCopy(cwd, entries)"
on-cut-entries="actionCut(cwd, entries)"
on-paste-entries="actionPaste(cwd, entries)"
on-delete-entries="deleteEntries.show(cwd, entries)"
on-rename-entry="renameEntry.show(cwd, entry)"
on-extract-entry="extractEntry(cwd, entry)"
on-chown-entries="chownEntries.show(cwd, entries)"
backend-type="backendType" backend-id="backendId" view="VIEW.RIGHT" clipboard="clipboard"
ng-click="setActiveView(VIEW.RIGHT)"></filetree>
</div>
</div>
</div>
<div ng-show="textEditor.visible" class="text-editor">
<div>
<div class="toolbar">
<div><span>{{ textEditor.entry.fileName }}</span></div>
<button type="button" class="btn btn-primary" ng-click="textEditor.maybeClose()">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="textEditor.save()" ng-disabled="textEditor.busy"><i class="fa fa-circle-notch fa-spin" ng-show="textEditor.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
<div id="textEditorContainer" style="flex-grow: 2; border: 0px solid black"></div>
</div>
<footer class="text-center ng-cloak">
<span class="text-muted" ng-bind-html="status.footer | markdown2html"></span>
</footer>
</div>
</body>
</html>
+4 -15
View File
@@ -15,11 +15,10 @@
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/slick.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/bootstrap-slider/bootstrap-slider.min.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
@@ -50,10 +49,6 @@
<script type="text/javascript" src="/3rdparty/js/angular-fittext.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js?<%= revision %>"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-tld.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js?<%= revision %>"></script>
@@ -73,16 +68,9 @@
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Bootstrap slider -->
<script type="text/javascript" src="/3rdparty/bootstrap-slider/bootstrap-slider.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/bootstrap-slider/slider.js?<%= revision %>"></script>
<!-- Anugular Multiselect https://github.com/sebastianha/angular-bootstrap-multiselect -->
<script type="text/javascript" src="/3rdparty/js/angular-bootstrap-multiselect.js?<%= revision %>"></script>
<!-- colors -->
<script type="text/javascript" src="/3rdparty/js/colors.js?<%= revision %>"></script>
<!-- moment -->
<script type="text/javascript" src="/3rdparty/js/moment-with-locales.min.js?<%= revision %>"></script>
@@ -159,7 +147,7 @@
</a>
</li>
<li>
<a ng-class="{ active: isActive('/apps')}" href="#/apps"><i class="fa fa-th fa-fw"></i> {{ 'apps.title' | tr }}</a>
<a ng-class="{ active: isActive('/apps')}" href="#/apps"><i class="fa fa-grip fa-fw"></i> {{ 'apps.title' | tr }}</a>
</li>
<li ng-show="user.isAtLeastAdmin">
<a ng-class="{ active: isActive('/appstore')}" href="#/appstore"><i class="fa fa-cloud-download-alt fa-fw"></i> {{ 'appstore.title' | tr }}</a>
@@ -180,13 +168,14 @@
<li><a href="#/profile"><i class="fa fa-user fa-fw"></i> {{ 'profile.title' | tr }}</a></li>
<li ng-show="user.isAtLeastMailManager" class="divider"></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/backups"><i class="fa fa-archive fa-fw"></i> {{ 'backups.title' | tr }}</a></li>
<li ng-show="user.isAtLeastOwner"><a href="#/branding"><i class="fa fa-passport fa-fw"></i> {{ 'branding.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/branding"><i class="fa fa-passport fa-fw"></i> {{ 'branding.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/domains"><i class="fa fa-globe fa-fw"></i> {{ 'domains.title' | tr }}</a></li>
<li ng-show="user.isAtLeastMailManager"><a href="#/email"><i class="fa fa-envelope fa-fw"></i> {{ 'emails.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/eventlog"><i class="fa fa-list-alt fa-fw"></i> {{ 'eventlog.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/network"><i class="fas fa-network-wired fa-fw"></i> {{ 'network.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/services"><i class="fa fa-cogs fa-fw"></i> {{ 'services.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/settings"><i class="fa fa-wrench fa-fw"></i> {{ 'settings.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/usersettings"><i class="fa fa-users-gear fa-fw"></i> {{ 'users.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/volumes"><i class="fa fa-hdd fa-fw"></i> {{ 'volumes.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin" class="divider"></li>
<li ng-show="user.isAtLeastOwner"><a href="#/support"><i class="fa fa-comment fa-fw"></i> {{ 'support.title' | tr }}</a></li>
+100
View File
@@ -0,0 +1,100 @@
'use strict';
/* global angular, window, document, localStorage, redirectIfNeeded */
/* global $ */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupController', ['$scope', 'Client', function ($scope, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.client = Client;
$scope.view = '';
$scope.initialized = false;
$scope.setupToken = '';
$scope.firstTimeLoginUrl = '';
$scope.owner = {
error: null,
busy: false,
email: '',
displayName: '',
username: '',
password: '',
submit: function () {
$scope.owner.busy = true;
$scope.owner.error = null;
var data = {
username: $scope.owner.username,
password: $scope.owner.password,
email: $scope.owner.email,
displayName: $scope.owner.displayName,
setupToken: $scope.setupToken
};
Client.createAdmin(data, function (error, autoLoginToken) {
if (error && error.statusCode === 400) {
$scope.owner.busy = false;
if (error.message === 'Invalid email') {
$scope.owner.error = { email: error.message };
$scope.owner.email = '';
$scope.ownerForm.email.$setPristine();
setTimeout(function () { $('#inputEmail').focus(); }, 200);
} else {
$scope.owner.error = { username: error.message };
$scope.owner.username = '';
$scope.ownerForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
}
return;
} else if (error) {
$scope.owner.busy = false;
console.error('Internal error', error);
$scope.owner.error = { generic: error.message };
return;
}
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = autoLoginToken;
$scope.firstTimeLoginUrl = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
setView('finished');
});
}
};
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
} else {
$scope.view = 'owner';
}
}
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'activation')) return; // redirected to some other view...
setView(search.view);
$scope.setupToken = search.setupToken;
$scope.initialized = true;
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
});
}
init();
}]);
+515 -497
View File
File diff suppressed because it is too large Load Diff
-890
View File
@@ -1,890 +0,0 @@
'use strict';
require.config({ paths: { 'vs': '3rdparty/vs' }});
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ngDrag', 'ui.bootstrap', 'ui.bootstrap.contextMenu']);
angular.module('Application').filter('prettyOwner', function () {
return function (uid) {
if (uid === 0) return 'root';
if (uid === 33) return 'www-data';
if (uid === 1000) return 'cloudron';
if (uid === 1001) return 'git';
return uid;
};
});
// disable sce for footer https://code.angularjs.org/1.5.8/docs/api/ng/service/$sce
app.config(function ($sceProvider) {
$sceProvider.enabled(false);
});
app.filter('trustUrl', ['$sce', function ($sce) {
return function (recordingUrl) {
return $sce.trustAsResourceUrl(recordingUrl);
};
}]);
// https://stackoverflow.com/questions/25621321/angularjs-ng-drag
var ngDragEventDirectives = {};
angular.forEach(
'drag dragend dragenter dragexit dragleave dragover dragstart drop'.split(' '),
function(eventName) {
var directiveName = 'ng' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
ngDragEventDirectives[directiveName] = ['$parse', '$rootScope', function($parse/*, $rootScope */) {
return {
restrict: 'A',
compile: function($element, attr) {
var fn = $parse(attr[directiveName], null, true);
return function ngDragEventHandler(scope, element) {
element.on(eventName, function(event) {
var callback = function() {
fn(scope, {$event: event});
};
scope.$apply(callback);
});
};
}
};
}];
}
);
angular.module('ngDrag', []).directive(ngDragEventDirectives);
function sanitize(filePath) {
filePath = filePath.split('/').filter(function (a) { return !!a; }).reduce(function (a, v) {
if (v === '.'); // do nothing
else if (v === '..') a.pop();
else a.push(v);
return a;
}, []).map(function (p) {
// small detour to safely handle special characters and whitespace
return encodeURIComponent(decodeURIComponent(p));
}).join('/');
return filePath;
}
function isModalVisible() {
return !!document.getElementsByClassName('modal in').length;
}
var VIEW = {
LEFT: 'left',
RIGHT: 'right'
};
var OWNERS = [
{ name: 'cloudron', value: 1000 },
{ name: 'www-data', value: 33 },
{ name: 'git', value: 1001 },
{ name: 'root', value: 0 }
];
app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Client', function ($scope, $translate, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
// expose enums
$scope.VIEW = VIEW;
$scope.OWNERS = OWNERS;
$scope.initialized = false;
$scope.status = null;
$scope.client = Client;
$scope.title = '';
$scope.backendId = search.id;
$scope.backendType = search.type;
$scope.volumes = [];
$scope.splitView = !!window.localStorage.splitView;
$scope.activeView = VIEW.LEFT;
$scope.viewerOpen = false;
$scope.clipboard = []; // holds cut or copied entries
$scope.clipboardCut = false; // if action is cut or copy
// add a hook for children to refresh both tree views
$scope.children = [];
$scope.registerChild = function (child) { $scope.children.push(child); };
$scope.refresh = function () {
$scope.children.forEach(function (child) {
child.onRefresh();
});
};
function collectFiles(entry, callback) {
var pathFrom = entry.pathFrom;
if (entry.isDirectory) {
Client.filesGet($scope.backendId, $scope.backendType, entry.fullFilePath, 'data', function (error, result) {
if (error) return callback(error);
if (!result.entries) return callback(new Error('not a folder'));
// amend fullFilePath
result.entries.forEach(function (e) {
e.fullFilePath = sanitize(entry.fullFilePath + '/' + e.fileName);
e.pathFrom = pathFrom; // we stash the original path for pasting
});
var collectedFiles = [];
async.eachLimit(result.entries, 5, function (entry, callback) {
collectFiles(entry, function (error, result) {
if (error) return callback(error);
collectedFiles = collectedFiles.concat(result);
callback();
});
}, function (error) {
if (error) return callback(error);
callback(null, collectedFiles);
});
});
return;
}
callback(null, [ entry ]);
}
// entries need to be an actual copy
$scope.actionCut = function (cwd, entries) {
$scope.clipboard = entries; //$scope.selected.slice();
$scope.clipboard.forEach(function (entry) {
entry.fullFilePath = sanitize(cwd + '/' + entry.fileName);
});
$scope.clipboardCut = true;
};
// entries need to be an actual copy
$scope.actionCopy = function (cwd, entries) {
$scope.clipboard = entries; //$scope.selected.slice();
$scope.clipboard.forEach(function (entry) {
entry.fullFilePath = sanitize(cwd + '/' + entry.fileName);
entry.pathFrom = cwd; // we stash the original path for pasting
});
$scope.clipboardCut = false;
};
$scope.actionPaste = function (cwd, destinationEntry) {
if ($scope.clipboardCut) {
// move files
async.eachLimit($scope.clipboard, 5, function (entry, callback) {
var newFilePath = sanitize(cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fileName);
// TODO this will overwrite files in destination!
Client.filesRename($scope.backendId, $scope.backendType, entry.fullFilePath, newFilePath, callback);
}, function (error) {
if (error) return Client.error(error);
// clear clipboard
$scope.clipboard = [];
$scope.refresh();
});
} else {
// copy files
// first collect all files recursively
var collectedFiles = [];
async.eachLimit($scope.clipboard, 5, function (entry, callback) {
collectFiles(entry, function (error, result) {
if (error) return callback(error);
collectedFiles = collectedFiles.concat(result);
callback();
});
}, function (error) {
if (error) return Client.error(error);
async.eachLimit(collectedFiles, 5, function (entry, callback) {
var newFilePath = sanitize(cwd + '/' + ((destinationEntry && destinationEntry.isDirectory) ? destinationEntry.fileName : '') + '/' + entry.fullFilePath.slice(entry.pathFrom.length));
// This will NOT overwrite but finds a unique new name to copy to
// we prefix with a / to ensure we don't do relative target paths
Client.filesCopy($scope.backendId, $scope.backendType, entry.fullFilePath, '/' + newFilePath, callback);
}, function (error) {
if (error) return Client.error(error);
// clear clipboard
$scope.clipboard = [];
$scope.refresh();
});
});
}
};
// handle uploads
$scope.uploadStatus = {
error: null,
busy: false,
fileName: '',
count: 0,
countDone: 0,
size: 0,
done: 0,
percentDone: 0,
files: [],
targetFolder: ''
};
$scope.uploadFiles = function (files, targetFolder, overwrite) {
if (!files || !files.length) return;
overwrite = !!overwrite;
// prevent it from getting closed
$('#uploadModal').modal({
backdrop: 'static',
keyboard: false
});
$scope.uploadStatus.files = files;
$scope.uploadStatus.targetFolder = targetFolder;
$scope.uploadStatus.error = null;
$scope.uploadStatus.busy = true;
$scope.uploadStatus.count = files.length;
$scope.uploadStatus.countDone = 0;
$scope.uploadStatus.size = 0;
$scope.uploadStatus.sizeDone = 0;
$scope.uploadStatus.done = 0;
$scope.uploadStatus.percentDone = 0;
for (var i = 0; i < files.length; ++i) {
$scope.uploadStatus.size += files[i].size;
}
async.eachSeries(files, function (file, callback) {
var filePath = sanitize(targetFolder + '/' + (file.webkitRelativePath || file.name));
$scope.uploadStatus.fileName = file.name;
Client.filesUpload($scope.backendId, $scope.backendType, filePath, file, overwrite, function (loaded) {
$scope.uploadStatus.percentDone = ($scope.uploadStatus.done+loaded) * 100 / $scope.uploadStatus.size;
$scope.uploadStatus.sizeDone = loaded;
}, function (error) {
if (error) return callback(error);
$scope.uploadStatus.done += file.size;
$scope.uploadStatus.percentDone = $scope.uploadStatus.done * 100 / $scope.uploadStatus.size;
$scope.uploadStatus.countDone++;
callback();
});
}, function (error) {
$scope.uploadStatus.busy = false;
if (error && error.statusCode === 409) {
$scope.uploadStatus.error = 'exists';
return;
} else if (error) {
console.error(error);
$scope.uploadStatus.error = 'generic';
return;
}
$('#uploadModal').modal('hide');
$scope.uploadStatus.fileName = '';
$scope.uploadStatus.count = 0;
$scope.uploadStatus.size = 0;
$scope.uploadStatus.sizeDone = 0;
$scope.uploadStatus.done = 0;
$scope.uploadStatus.percentDone = 100;
$scope.uploadStatus.files = [];
$scope.uploadStatus.targetFolder = '';
$scope.refresh();
});
};
$scope.retryUpload = function (overwrite) {
$scope.uploadFiles($scope.uploadStatus.files, $scope.uploadStatus.targetFolder, !!overwrite);
};
// file and folder upload hooks, stashing $scope.uploadCwd for now
$scope.uploadCwd = '';
$('#uploadFileInput').on('change', function (e ) {
$scope.uploadFiles(e.target.files || [], $scope.uploadCwd, false);
});
$scope.onUploadFile = function (cwd) {
$scope.uploadCwd = cwd;
$('#uploadFileInput').click();
};
$('#uploadFolderInput').on('change', function (e ) {
$scope.uploadFiles(e.target.files || [], $scope.uploadCwd, false);
});
$scope.onUploadFolder = function (cwd) {
$scope.uploadCwd = cwd;
$('#uploadFolderInput').click();
};
// handle delete
$scope.deleteEntries = {
busy: false,
error: null,
cwd: '',
entries: [],
show: function (cwd, entries) {
$scope.deleteEntries.error = null;
$scope.deleteEntries.cwd = cwd;
$scope.deleteEntries.entries = entries;
$('#entriesDeleteModal').modal('show');
},
submit: function () {
$scope.deleteEntries.busy = true;
async.eachLimit($scope.deleteEntries.entries, 5, function (entry, callback) {
var filePath = sanitize($scope.deleteEntries.cwd + '/' + entry.fileName);
Client.filesRemove($scope.backendId, $scope.backendType, filePath, callback);
}, function (error) {
$scope.deleteEntries.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#entriesDeleteModal').modal('hide');
});
}
};
// rename entry
$scope.renameEntry = {
busy: false,
error: null,
entry: null,
cwd: '',
newName: '',
show: function (cwd, entry) {
$scope.renameEntry.error = null;
$scope.renameEntry.cwd = cwd;
$scope.renameEntry.entry = entry;
$scope.renameEntry.newName = entry.fileName;
$scope.renameEntry.busy = false;
$('#renameEntryModal').modal('show');
},
submit: function () {
$scope.renameEntry.busy = true;
var oldFilePath = sanitize($scope.renameEntry.cwd + '/' + $scope.renameEntry.entry.fileName);
var newFilePath = sanitize(($scope.renameEntry.newName[0] === '/' ? '' : ($scope.renameEntry.cwd + '/')) + $scope.renameEntry.newName);
Client.filesRename($scope.backendId, $scope.backendType, oldFilePath, newFilePath, function (error) {
$scope.renameEntry.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#renameEntryModal').modal('hide');
});
}
};
// chown entries
$scope.chownEntries = {
busy: false,
error: null,
entries: [],
newOwner: 0,
recursive: false,
showRecursiveOption: false,
show: function (cwd, entries) {
$scope.chownEntries.error = null;
$scope.chownEntries.cwd = cwd;
$scope.chownEntries.entries = entries;
// set default uid from first file
$scope.chownEntries.newOwner = entries[0].uid;
$scope.chownEntries.busy = false;
// default for directories is recursive
$scope.chownEntries.recursive = !!entries.find(function (entry) { return entry.isDirectory; });
$scope.chownEntries.showRecursiveOption = false;
$('#chownEntriesModal').modal('show');
},
submit: function () {
$scope.chownEntries.busy = true;
async.eachLimit($scope.chownEntries.entries, 5, function (entry, callback) {
var filePath = sanitize($scope.chownEntries.cwd + '/' + entry.fileName);
Client.filesChown($scope.backendId, $scope.backendType, filePath, $scope.chownEntries.newOwner, $scope.chownEntries.recursive, callback);
}, function (error) {
$scope.chownEntries.busy = false;
if (error) return Client.error(error);
$scope.refresh();
$('#chownEntriesModal').modal('hide');
});
}
};
// new file
$scope.newFile = {
busy: false,
error: null,
cwd: '',
name: '',
show: function (cwd) {
$scope.newFile.error = null;
$scope.newFile.name = '';
$scope.newFile.busy = false;
$scope.newFile.cwd = cwd;
$scope.newFileForm.$setUntouched();
$scope.newFileForm.$setPristine();
$('#newFileModal').modal('show');
},
submit: function () {
$scope.newFile.busy = true;
$scope.newFile.error = null;
var filePath = sanitize($scope.newFile.cwd + '/' + $scope.newFile.name);
Client.filesUpload($scope.backendId, $scope.backendType, filePath, new File([], $scope.newFile.name), false, function () {}, function (error) {
$scope.newFile.busy = false;
if (error && error.statusCode === 409) return $scope.newFile.error = 'exists';
if (error) return Client.error(error);
$scope.refresh();
$('#newFileModal').modal('hide');
});
}
};
// new folder
$scope.newFolder = {
busy: false,
error: null,
cwd: '',
name: '',
show: function (cwd) {
$scope.newFolder.error = null;
$scope.newFolder.name = '';
$scope.newFolder.busy = false;
$scope.newFolder.cwd = cwd;
$scope.newFolderForm.$setUntouched();
$scope.newFolderForm.$setPristine();
$('#newFolderModal').modal('show');
},
submit: function () {
$scope.newFolder.busy = true;
$scope.newFolder.error = null;
var filePath = sanitize($scope.newFolder.cwd + '/' + $scope.newFolder.name);
Client.filesCreateDirectory($scope.backendId, $scope.backendType, filePath, function (error) {
$scope.newFolder.busy = false;
if (error && error.statusCode === 409) return $scope.newFolder.error = 'exists';
if (error) return Client.error(error);
$scope.refresh();
$('#newFolderModal').modal('hide');
});
}
};
// extract archives
$scope.extractStatus = {
error: null,
busy: false,
fileName: ''
};
$scope.extractEntry = function (cwd, entry) {
var filePath = sanitize(cwd + '/' + entry.fileName);
if (entry.isDirectory) return;
// prevent it from getting closed
$('#extractModal').modal({
backdrop: 'static',
keyboard: false
});
$scope.extractStatus.fileName = entry.fileName;
$scope.extractStatus.error = null;
$scope.extractStatus.busy = true;
Client.filesExtract($scope.backendId, $scope.backendType, filePath, function (error) {
$scope.extractStatus.busy = false;
if (error) {
console.error(error);
$scope.extractStatus.error = $translate.instant('filemanager.extract.error', error.message);
return;
}
$('#extractModal').modal('hide');
$scope.refresh();
});
};
// split view handling
$scope.toggleSplitView = function () {
$scope.splitView = !$scope.splitView;
if (!$scope.splitView) {
$scope.activeView = VIEW.LEFT;
delete window.localStorage.splitView;
} else {
window.localStorage.splitView = true;
}
};
$scope.setActiveView = function (view) {
$scope.activeView = view;
};
// monaco text editor
var LANGUAGES = [];
require(['vs/editor/editor.main'], function() { LANGUAGES = monaco.languages.getLanguages(); });
function getLanguage(filename) {
var ext = '.' + filename.split('.').pop();
var language = LANGUAGES.find(function (l) {
if (!l.extensions) return false;
return !!l.extensions.find(function (e) { return e === ext; });
}) || '';
return language ? language.id : '';
}
$scope.textEditor = {
busy: false,
cwd: null,
entry: null,
editor: null,
unsaved: false,
visible: false,
show: function (cwd, entry) {
$scope.textEditor.cwd = cwd;
$scope.textEditor.entry = entry;
$scope.textEditor.busy = false;
$scope.textEditor.unsaved = false;
$scope.textEditor.visible = true;
// clear model if any
if ($scope.textEditor.editor && $scope.textEditor.editor.getModel()) $scope.textEditor.editor.setModel(null);
$scope.viewerOpen = true;
// document.getElementById('textEditorModal').style['display'] = 'flex';
var filePath = sanitize($scope.textEditor.cwd + '/' + entry.fileName);
var language = getLanguage(entry.fileName);
Client.filesGet($scope.backendId, $scope.backendType, filePath, 'data', function (error, result) {
if (error) return Client.error(error);
if (!$scope.textEditor.editor) {
$timeout(function () {
$scope.textEditor.editor = monaco.editor.create(document.getElementById('textEditorContainer'), {
value: result,
language: language,
theme: 'vs-dark'
});
$scope.textEditor.editor.getModel().onDidChangeContent(function () { $scope.textEditor.unsaved = true; });
}, 200);
} else {
$scope.textEditor.editor.setModel(monaco.editor.createModel(result, language));
$scope.textEditor.editor.getModel().onDidChangeContent(function () { $scope.textEditor.unsaved = true; }); // have to re-attach whenever model changes
}
});
},
save: function (callback) {
$scope.textEditor.busy = true;
var newContent = $scope.textEditor.editor.getValue();
var filePath = sanitize($scope.textEditor.cwd + '/' + $scope.textEditor.entry.fileName);
var file = new File([newContent], 'file');
Client.filesUpload($scope.backendId, $scope.backendType, filePath, file, true, function () {}, function (error) {
if (error) return Client.error(error);
$scope.refresh();
$timeout(function () {
$scope.textEditor.unsaved = false;
$scope.textEditor.busy = false;
if (typeof callback === 'function') return callback();
}, 1000);
});
},
close: function () {
$scope.textEditor.visible = false;
$scope.viewerOpen = false;
$('#textEditorCloseModal').modal('hide');
},
onClose: function () {
$scope.textEditor.visible = false;
$scope.viewerOpen = false;
$('#textEditorCloseModal').modal('hide');
},
saveAndClose: function () {
$scope.textEditor.save(function () {
$scope.textEditor.onClose();
});
},
maybeClose: function () {
if (!$scope.textEditor.unsaved) return $scope.textEditor.onClose();
$('#textEditorCloseModal').modal('show');
},
};
// restart app or mail logic
$scope.restartBusy = false;
$scope.onRestartApp = function () {
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getApp($scope.backendId, function (error, result) {
if (error) return callback(error);
if (result.installationState === ISTATES.INSTALLED) return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartApp($scope.backendId, function (error) {
if (error) console.error('Failed to restart app.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartBusy = false;
});
});
};
$scope.onRestartMail = function () {
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getService('mail', function (error, result) {
if (error) return callback(error);
if (result.status === 'active') return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartService('mail', function (error) {
if (error) console.error('Failed to restart mail.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartBusy = false;
});
});
};
// init code
function fetchVolumesInfo(mounts) {
$scope.volumes = [];
async.each(mounts, function (mount, callback) {
Client.getVolume(mount.volumeId, function (error, result) {
if (error) return callback(error);
$scope.volumes.push(result);
callback();
});
}, function (error) {
if (error) console.error('Failed to fetch volumes info.', error);
});
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
window.location.href = '/';
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
$scope.status = status;
console.log('Running filemanager version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
var getter;
if ($scope.backendType === 'app') {
getter = Client.getApp.bind(Client, $scope.backendId);
} else if ($scope.backendType === 'volume') {
getter = Client.getVolume.bind(Client, $scope.backendId);
} else if ($scope.backendType === 'mail') {
getter = function (next) { next(null, null); };
}
getter(function (error, result) {
if (error) {
$scope.initialized = true;
return;
}
// fine to do async
if ($scope.backendType === 'app') fetchVolumesInfo(result.mounts || []);
switch ($scope.backendType) {
case 'app':
$scope.title = result.label || result.fqdn;
$scope.rootDirLabel = '/app/data/';
$scope.applicationLink = 'https://' + result.fqdn;
break;
case 'volume':
$scope.title = result.name;
$scope.rootDirLabel = result.hostPath;
break;
case 'mail':
$scope.title = 'mail';
$scope.rootDirLabel = 'mail';
break;
}
window.document.title = $scope.title + ' - ' + $translate.instant('filemanager.title');
// now mark the Client to be ready
Client.setReady();
// openPath('');
$scope.initialized = true;
});
});
});
}
init();
// toplevel key input handling
window.addEventListener('keydown', function (event) {
if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 's') {
if (!$scope.textEditor.visible) return;
event.preventDefault();
$scope.$apply($scope.textEditor.save);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'c') {
if ($scope.textEditor.visible) return;
if ($scope.selected.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionCopy);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'x') {
if ($scope.textEditor.visible) return;
if ($scope.selected.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionCut);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'v') {
if ($scope.textEditor.visible) return;
if ($scope.clipboard.length === 0) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionPaste);
} else if((navigator.platform.match('Mac') ? event.metaKey : event.ctrlKey) && event.key === 'a') {
if ($scope.textEditor.visible) return;
if (isModalVisible()) return;
event.preventDefault();
$scope.$apply($scope.actionSelectAll);
} else if(event.key === 'Escape') {
if ($scope.textEditor.visible) return $scope.$apply($scope.textEditor.maybeClose);
else $scope.$apply(function () { $scope.selected = []; });
}
});
// setup all the dialog focus handling
['newFileModal', 'newFolderModal', 'renameEntryModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
// selects filename (without extension)
['renameEntryModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
var elem = $(this).find('[autofocus]:first');
var text = elem.val();
elem[0].setSelectionRange(0, text.indexOf('.'));
});
});
}]);
+249 -6
View File
@@ -1,8 +1,9 @@
'use strict';
/* global angular:false */
/* global angular:false, window, document, localStorage, redirectIfNeeded */
/* global $:false */
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES */
/* global async */
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES,NOTIFICATION_TYPES */
// deal with accessToken in the query, this is passed for example on password reset and account setup upon invite
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
@@ -18,7 +19,7 @@ if (search.accessToken) {
}
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.bootstrap-slider', 'ngTld', 'ui.multiselect']);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'ngFitText', 'ngRoute', 'ngAnimate', 'ngSanitize', 'angular-md5', 'base64', 'slick', 'ui-notification', 'ui.bootstrap', 'ui.multiselect']);
app.config(['NotificationProvider', function (NotificationProvider) {
NotificationProvider.setOptions({
@@ -50,6 +51,9 @@ app.config(['$routeProvider', function ($routeProvider) {
}).when('/users', {
controller: 'UsersController',
templateUrl: 'views/users.html?<%= revision %>'
}).when('/usersettings', {
controller: 'UserSettingsController',
templateUrl: 'views/user-settings.html?<%= revision %>'
}).when('/app/:appId/:view?', {
controller: 'AppController',
templateUrl: 'views/app.html?<%= revision %>'
@@ -93,8 +97,7 @@ app.config(['$routeProvider', function ($routeProvider) {
controller: 'NotificationsController',
templateUrl: 'views/notifications.html?<%= revision %>'
}).when('/oidc', {
controller: 'OidcController',
templateUrl: 'views/oidc.html?<%= revision %>'
redirectTo: '/usersettings'
}).when('/settings', {
controller: 'SettingsController',
templateUrl: 'views/settings.html?<%= revision %>'
@@ -116,6 +119,26 @@ app.config(['$routeProvider', function ($routeProvider) {
}).otherwise({ redirectTo: '/'});
}]);
app.filter('notificationTypeToColor', function () {
return function (n) {
switch (n.type) {
case NOTIFICATION_TYPES.ALERT_REBOOT:
case NOTIFICATION_TYPES.ALERT_APP_OOM:
case NOTIFICATION_TYPES.ALERT_MAIL_STATUS:
case NOTIFICATION_TYPES.ALERT_CERTIFICATE_RENEWAL_FAILED:
case NOTIFICATION_TYPES.ALERT_DISK_SPACE:
case NOTIFICATION_TYPES.ALERT_BACKUP_CONFIG:
case NOTIFICATION_TYPES.ALERT_BACKUP_FAILED:
return '#ff4c4c';
case NOTIFICATION_TYPES.ALERT_BOX_UPDATE:
case NOTIFICATION_TYPES.ALERT_MANUAL_APP_UPDATE:
return '#f0ad4e';
default:
return '#2196f3';
}
};
});
app.filter('capitalize', function () {
return function (s) {
return s.charAt(0).toUpperCase() + s.slice(1);
@@ -282,6 +305,36 @@ app.filter('installationActive', function () {
};
});
// color indicator in app list
app.filter('installationStateClass', function () {
const ERROR_CLASS = 'status-error';
const BUSY_CLASS = 'status-starting fa-beat-fade';
const INACTIVE_CLASS = 'status-inactive';
const ACTIVE_CLASS = 'status-active';
return function(app) {
if (!app) return '';
switch (app.installationState) {
case ISTATES.ERROR: return ERROR_CLASS;
case ISTATES.INSTALLED: {
if (app.debugMode) {
return INACTIVE_CLASS;
} else {
if (app.runState === RSTATES.RUNNING) {
if (!app.health) return BUSY_CLASS; // no data yet
if (app.type === APP_TYPES.LINK || app.health === HSTATES.HEALTHY) return ACTIVE_CLASS;
return ERROR_CLASS; // dead/exit/unhealthy
} else {
return INACTIVE_CLASS;
}
}
}
default: return BUSY_CLASS;
}
};
});
// this appears in the app grid
app.filter('installationStateLabel', function () {
return function(app) {
@@ -297,6 +350,7 @@ app.filter('installationStateLabel', function () {
case ISTATES.PENDING_LOCATION_CHANGE:
case ISTATES.PENDING_CONFIGURE:
case ISTATES.PENDING_RECREATE_CONTAINER:
case ISTATES.PENDING_SERVICES_CHANGE:
case ISTATES.PENDING_DEBUG:
return 'Configuring' + waiting;
case ISTATES.PENDING_RESIZE:
@@ -375,7 +429,7 @@ app.filter('errorSuggestion', function () {
};
});
app.filter('readyToUpdate', function () {
app.filter('canUpdate', function () {
return function (apps) {
return apps.every(function (app) {
return (app.installationState === ISTATES.ERROR) || (app.installationState === ISTATES.INSTALLED);
@@ -607,3 +661,192 @@ app.config(['fitTextConfigProvider', function (fitTextConfigProvider) {
max: 24
};
}]);
app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '$interval', 'Notification', 'Client', function ($scope, $route, $timeout, $location, $interval, Notification, Client) {
$scope.initialized = false; // used to animate the UI
$scope.user = Client.getUserInfo();
$scope.installedApps = Client.getInstalledApps();
$scope.config = {};
$scope.client = Client;
$scope.subscription = {};
$scope.notificationCount = 0;
$scope.hideNavBarActions = $location.path() === '/logs';
$scope.backgroundImageUrl = '';
$scope.reboot = {
busy: false,
show: function () {
$scope.reboot.busy = false;
$('#rebootModal').modal('show');
},
submit: function () {
$scope.reboot.busy = true;
Client.reboot(function (error) {
if (error) return Client.error(error);
$('#rebootModal').modal('hide');
// trigger refetch to show offline banner
$timeout(function () { Client.getStatus(function () {}); }, 5000);
});
}
};
$scope.isActive = function (url) {
if (!$route.current) return false;
return $route.current.$$route.originalPath.indexOf(url) === 0;
};
$scope.logout = function (event) {
event.stopPropagation();
$scope.initialized = false;
Client.logout();
};
$scope.openSubscriptionSetup = function () {
Client.openSubscriptionSetup($scope.subscription);
};
// NOTE: this function is exported and called from the appstore.js
$scope.updateSubscriptionStatus = function () {
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
$scope.subscription = subscription;
});
};
function refreshNotifications() {
if (!Client.getUserInfo().isAtLeastAdmin) return;
Client.getNotifications({ acknowledged: false }, 1, 100, function (error, results) { // counter maxes out at 100
if (error) console.error(error);
else $scope.notificationCount = results.length;
});
}
// update state of acknowledged notification
$scope.notificationAcknowledged = function () {
refreshNotifications();
};
function redirectOnMandatory2FA() {
if (Client.getConfig().mandatory2FA) {
if (Client.getUserInfo().twoFactorAuthenticationEnabled) return; // user already has 2fa
if (Client.getUserInfo().source && $scope.config.external2FA) return; // 2fa is external
$location.path('/profile').search({ setup2fa: true });
}
}
// Make it redirect if the browser URL is changed directly - https://forum.cloudron.io/topic/7510/bug-in-2fa-force
$scope.$on('$routeChangeStart', function (/* event */) {
if ($scope.initialized) redirectOnMandatory2FA();
});
var gPlatformStatusNotification = null;
function trackPlatformStatus() {
Client.getPlatformStatus(function (error, result) {
if (error) return console.error('Failed to get platform status.', error);
// see box/src/platform.js
if (result.message === 'Ready') {
if (gPlatformStatusNotification) {
gPlatformStatusNotification.kill();
gPlatformStatusNotification = null;
}
return;
}
if (!gPlatformStatusNotification) {
var options = { title: 'Platform status', message: result.message, delay: 'notimeout', replaceMessage: true, closeOnClick: false };
Notification.primary(options).then(function (result) {
gPlatformStatusNotification = result;
$timeout(trackPlatformStatus, 5000);
});
} else {
gPlatformStatusNotification.message = result.message;
$timeout(trackPlatformStatus, 5000);
}
});
}
// this loads the very first thing when accessing via IP or domain
function init() {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'dashboard')) return; // we got redirected...
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
console.log('Running dashboard version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
async.series([
Client.refreshProfile.bind(Client),
Client.refreshConfig.bind(Client),
Client.refreshAvailableLanguages.bind(Client),
Client.refreshInstalledApps.bind(Client)
], function (error) {
if (error) return Client.initError(error, init);
// now mark the Client to be ready
Client.setReady();
$scope.config = Client.getConfig();
if (Client.getUserInfo().hasBackgroundImage) {
document.getElementById('mainContentContainer').style.backgroundImage = 'url("' + Client.getBackgroundImageUrl() + '")';
document.getElementById('mainContentContainer').classList.add('has-background');
}
$scope.initialized = true;
redirectOnMandatory2FA();
$interval(refreshNotifications, 60 * 1000);
refreshNotifications();
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
$scope.subscription = subscription;
// only track platform status if we are registered
trackPlatformStatus();
});
});
});
}
Client.onConfig(function (config) {
if (config.cloudronName) {
document.title = config.cloudronName;
}
});
init();
// setup all the dialog focus handling
['updateModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
}]);
-202
View File
@@ -1,202 +0,0 @@
'use strict';
/* global angular */
/* global moment */
/* global $ */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification']);
app.controller('LogsController', ['$scope', '$translate', 'Client', function ($scope, $translate, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.initialized = false;
$scope.client = Client;
$scope.selected = '';
$scope.activeEventSource = null;
$scope.lines = 100;
$scope.selectedAppInfo = null;
$scope.selectedTaskInfo = null;
function ab2str(buf) {
return String.fromCharCode.apply(null, new Uint16Array(buf));
}
$scope.clear = function () {
var logViewer = $('.logs-container');
logViewer.empty();
};
// https://github.com/janl/mustache.js/blob/master/mustache.js#L60
var entityMap = {
'&': '&amp;',
'<': '&lt;',
'>': '&gt;',
'"': '&quot;',
"'": '&#39;',
'/': '&#x2F;',
'`': '&#x60;',
'=': '&#x3D;'
};
function escapeHtml(string) {
return String(string).replace(/[&<>"'`=\/]/g, function fromEntityMap (s) {
return entityMap[s];
});
}
function showLogs() {
if (!$scope.selected) return;
var func;
if ($scope.selected.type === 'platform') func = Client.getPlatformLogs;
else if ($scope.selected.type === 'service') func = Client.getServiceLogs;
else if ($scope.selected.type === 'task') func = Client.getTaskLogs;
else if ($scope.selected.type === 'app') func = Client.getAppLogs;
func($scope.selected.value, true /* follow */, $scope.lines, function handleLogs(error, result) {
if (error) return console.error(error);
$scope.activeEventSource = result;
result.onmessage = function handleMessage(message) {
var data;
try {
data = JSON.parse(message.data);
} catch (e) {
return console.error(e);
}
// check if we want to auto scroll (this is before the appending, as that skews the check)
var tmp = $('.logs-container');
var autoScroll = tmp[0].scrollTop > (tmp[0].scrollHeight - tmp.innerHeight() - 24);
var logLine = $('<div class="log-line">');
// realtimeTimestamp is 0 if line is blank or some parse error
var timeString = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
logLine.html('<span class="time">' + timeString + ' </span>' + window.ansiToHTML(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message))));
tmp.append(logLine);
if (autoScroll) tmp[0].lastChild.scrollIntoView({ behavior: 'instant', block: 'end' });
};
});
}
function select(ids, callback) {
if (ids.id && ids.id.indexOf('redis:') === 0) {
$scope.selected = {
name: 'Redis',
type: 'service',
value: ids.id,
url: Client.makeURL('/api/v1/services/' + ids.id + '/logs')
};
callback();
} else if (ids.id) {
var BUILT_IN_LOGS = [
{ name: 'Box', type: 'platform', value: 'box', url: Client.makeURL('/api/v1/cloudron/logs/box') },
{ name: 'Graphite', type: 'service', value: 'graphite', url: Client.makeURL('/api/v1/services/graphite/logs') },
{ name: 'MongoDB', type: 'service', value: 'mongodb', url: Client.makeURL('/api/v1/services/mongodb/logs') },
{ name: 'MySQL', type: 'service', value: 'mysql', url: Client.makeURL('/api/v1/services/mysql/logs') },
{ name: 'PostgreSQL', type: 'service', value: 'postgresql', url: Client.makeURL('/api/v1/services/postgresql/logs') },
{ name: 'Mail', type: 'service', value: 'mail', url: Client.makeURL('/api/v1/services/mail/logs') },
{ name: 'Docker', type: 'service', value: 'docker', url: Client.makeURL('/api/v1/services/docker/logs') },
{ name: 'Nginx', type: 'service', value: 'nginx', url: Client.makeURL('/api/v1/services/nginx/logs') },
{ name: 'Unbound', type: 'service', value: 'unbound', url: Client.makeURL('/api/v1/services/unbound/logs') },
{ name: 'SFTP', type: 'service', value: 'sftp', url: Client.makeURL('/api/v1/services/sftp/logs') },
{ name: 'TURN/STUN', type: 'service', value: 'turn', url: Client.makeURL('/api/v1/services/turn/logs') },
];
$scope.selected = BUILT_IN_LOGS.find(function (e) { return e.value === ids.id; });
callback();
} else if (ids.crashId) {
$scope.selected = {
type: 'platform',
value: 'crash-' + ids.crashId,
name: 'Crash',
url: Client.makeURL('/api/v1/cloudron/logs/crash-' + ids.crashId)
};
callback();
} else if (ids.appId) {
Client.getApp(ids.appId, function (error, app) {
if (error) return callback(error);
$scope.selectedAppInfo = app;
$scope.selected = {
type: 'app',
value: app.id,
name: app.fqdn + ' (' + app.manifest.title + ')',
url: Client.makeURL('/api/v1/apps/' + app.id + '/logs'),
addons: app.manifest.addons
};
callback();
});
} else if (ids.taskId) {
Client.getTask(ids.taskId, function (error, task) {
if (error) return callback(error);
$scope.selectedTaskInfo = task;
$scope.selected = {
type: 'task',
value: task.id,
name: task.type,
url: Client.makeURL('/api/v1/tasks/' + task.id + '/logs')
};
callback();
});
}
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
window.location.href = '/';
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
console.log('Running log version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
Client.refreshConfig(function (error) {
if (error) return Client.initError(error, init);
select({ id: search.id, taskId: search.taskId, appId: search.appId, crashId: search.crashId }, function (error) {
if (error) return Client.initError(error, init);
// now mark the Client to be ready
Client.setReady();
$scope.initialized = true;
showLogs();
});
});
});
});
}
init();
$translate([ 'logs.title' ]).then(function (tr) {
if (tr['logs.title'] !== 'logs.title') window.document.title = tr['logs.title'];
});
}]);
-217
View File
@@ -1,217 +0,0 @@
'use strict';
/* global angular */
/* global $ */
angular.module('Application').controller('MainController', ['$scope', '$route', '$timeout', '$location', '$interval', 'Notification', 'Client', function ($scope, $route, $timeout, $location, $interval, Notification, Client) {
$scope.initialized = false; // used to animate the UI
$scope.user = Client.getUserInfo();
$scope.installedApps = Client.getInstalledApps();
$scope.config = {};
$scope.client = Client;
$scope.subscription = {};
$scope.notificationCount = 0;
$scope.hideNavBarActions = $location.path() === '/logs';
$scope.backgroundImageUrl = '';
$scope.reboot = {
busy: false,
show: function () {
$scope.reboot.busy = false;
$('#rebootModal').modal('show');
},
submit: function () {
$scope.reboot.busy = true;
Client.reboot(function (error) {
if (error) return Client.error(error);
$('#rebootModal').modal('hide');
// trigger refetch to show offline banner
$timeout(function () { Client.getStatus(function () {}); }, 5000);
});
}
};
$scope.isActive = function (url) {
if (!$route.current) return false;
return $route.current.$$route.originalPath.indexOf(url) === 0;
};
$scope.logout = function (event) {
event.stopPropagation();
$scope.initialized = false;
Client.logout();
};
$scope.openSubscriptionSetup = function () {
Client.openSubscriptionSetup($scope.subscription);
};
// NOTE: this function is exported and called from the appstore.js
$scope.updateSubscriptionStatus = function () {
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
$scope.subscription = subscription;
});
};
function refreshNotifications() {
if (!Client.getUserInfo().isAtLeastAdmin) return;
Client.getNotifications({ acknowledged: false }, 1, 100, function (error, results) { // counter maxes out at 100
if (error) console.error(error);
else $scope.notificationCount = results.length;
});
}
// update state of acknowledged notification
$scope.notificationAcknowledged = function () {
refreshNotifications();
};
function redirectOnMandatory2FA() {
if (Client.getConfig().mandatory2FA && !Client.getUserInfo().twoFactorAuthenticationEnabled) {
$location.path('/profile').search({ setup2fa: true });
}
}
// Make it redirect if the browser URL is changed directly - https://forum.cloudron.io/topic/7510/bug-in-2fa-force
$scope.$on('$routeChangeStart', function (/* event */) {
if ($scope.initialized) redirectOnMandatory2FA();
});
var gPlatformStatusNotification = null;
function trackPlatformStatus() {
Client.getPlatformStatus(function (error, result) {
if (error) return console.error('Failed to get platform status.', error);
// see box/src/platform.js
if (result.message === 'Ready') {
if (gPlatformStatusNotification) {
gPlatformStatusNotification.kill();
gPlatformStatusNotification = null;
}
return;
}
if (!gPlatformStatusNotification) {
var options = { title: 'Platform status', message: result.message, delay: 'notimeout', replaceMessage: true, closeOnClick: false };
Notification.primary(options).then(function (result) {
gPlatformStatusNotification = result;
$timeout(trackPlatformStatus, 5000);
});
} else {
gPlatformStatusNotification.message = result.message;
$timeout(trackPlatformStatus, 5000);
}
});
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
// WARNING if anything about the routing is changed here test these use-cases:
//
// 1. Caas
// 3. selfhosted restore
// 4. local development with gulp develop
if (!status.activated) {
console.log('Not activated yet, redirecting', status);
if (status.restore.active || status.restore.errorMessage) { // show the error message in restore page
window.location.href = '/restore.html' + window.location.search;
} else {
window.location.href = (status.adminFqdn ? '/setup.html' : '/setupdns.html') + window.location.search;
}
return;
}
// support local development with localhost check
if (window.location.hostname !== status.adminFqdn && window.location.hostname !== 'localhost' && !window.location.hostname.startsWith('192.')) {
// user is accessing by IP or by the old admin location (pre-migration)
window.location.href = '/setupdns.html' + window.location.search;
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
console.log('Running dashboard version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
Client.refreshConfig(function (error) {
if (error) return Client.initError(error, init);
Client.refreshAvailableLanguages(function (error) {
if (error) return Client.initError(error, init);
Client.refreshInstalledApps(function (error) {
if (error) return Client.initError(error, init);
// now mark the Client to be ready
Client.setReady();
$scope.config = Client.getConfig();
if (Client.getUserInfo().hasBackgroundImage) {
document.getElementById('mainContentContainer').style.backgroundImage = 'url("' + Client.getBackgroundImageUrl() + '")';
document.getElementById('mainContentContainer').classList.add('has-background');
}
$scope.initialized = true;
redirectOnMandatory2FA();
$interval(refreshNotifications, 60 * 1000);
refreshNotifications();
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) return; // invalid appstore token
if (error) return console.error(error);
$scope.subscription = subscription;
// only track platform status if we are registered
trackPlatformStatus();
});
});
});
});
});
});
}
Client.onConfig(function (config) {
if (config.cloudronName) {
document.title = config.cloudronName;
}
});
init();
// setup all the dialog focus handling
['updateModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
}]);
@@ -57,7 +57,7 @@ translateFilterFactory.displayName = 'translateFilterFactory';
app.filter('tr', translateFilterFactory);
app.controller('LoginController', ['$scope', '$translate', '$http', function ($scope, $translate, $http) {
app.controller('PasswordResetController', ['$scope', '$translate', '$http', function ($scope, $translate, $http) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.indexOf('=') === -1 ? [item, true] : [item.slice(0, item.indexOf('=')), item.slice(item.indexOf('=')+1)]; }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
@@ -65,7 +65,7 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
$scope.mode = '';
$scope.busy = false;
$scope.error = false;
$scope.status = null;
$scope.branding = null;
$scope.username = '';
$scope.password = '';
$scope.totpToken = '';
@@ -74,50 +74,6 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
$scope.newPasswordRepeat = '';
var API_ORIGIN = '<%= apiOrigin %>' || window.location.origin;
$scope.onLogin = function () {
$scope.busy = true;
$scope.error = false;
var data = {
username: $scope.username,
password: $scope.password,
totpToken: $scope.totpToken
};
function error(data, status) {
$scope.busy = false;
$scope.error = {};
if (!data || status !== 401) return $scope.error.internal = true;
if (data.message === 'Username and password does not match') {
$scope.error.password = true;
$scope.password = '';
setTimeout(function () { $('#inputPassword').focus(); }, 200);
} else if (data.message.indexOf('totpToken') !== -1) {
$scope.error.totpToken = true;
$scope.totpToken = '';
setTimeout(function () { $('#inputTotpToken').focus(); }, 200);
} else {
$scope.error.generic = true;
}
$scope.loginForm.$setPristine();
}
$http.post(API_ORIGIN + '/api/v1/cloudron/login', data).success(function (data, status) {
if (status !== 200) return error(data, status);
localStorage.token = data.accessToken;
// prevent redirecting to random domains
var returnTo = search.returnTo || '/';
if (returnTo.indexOf('/') !== 0) returnTo = '/';
window.location.href = returnTo;
}).error(error);
};
$scope.onPasswordReset = function () {
$scope.busy = true;
@@ -125,12 +81,13 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
identifier: $scope.passwordResetIdentifier
};
function done() {
function done(error) {
if (error) $scope.error = error.message;
$scope.busy = false;
$scope.mode = 'passwordResetDone';
}
$http.post(API_ORIGIN + '/api/v1/cloudron/password_reset_request', data).success(done).error(done);
$http.post(API_ORIGIN + '/api/v1/auth/password_reset_request', data).success(done).error(done);
};
$scope.onNewPassword = function () {
@@ -151,7 +108,7 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
else $scope.error = 'Unknown error';
}
$http.post(API_ORIGIN + '/api/v1/cloudron/password_reset', data).success(function (data, status) {
$http.post(API_ORIGIN + '/api/v1/auth/password_reset', data).success(function (data, status) {
if (status !== 202) return error(data, status);
// set token to autologin
@@ -170,28 +127,20 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
setTimeout(function () { $('#inputPasswordResetIdentifier').focus(); }, 200);
};
$scope.showLogin = function () {
if ($scope.status) window.document.title = $scope.status.cloudronName + ' Login';
$scope.mode = 'login';
$scope.error = false;
setTimeout(function () { $('#inputUsername').focus(); }, 200);
};
$scope.showNewPassword = function () {
window.document.title = 'Set New Password';
$scope.mode = 'newPassword';
setTimeout(function () { $('#inputNewPassword').focus(); }, 200);
};
$http.get(API_ORIGIN + '/api/v1/cloudron/status').success(function (data, status) {
$http.get(API_ORIGIN + '/api/v1/auth/branding').success(function (data, status) {
$scope.initialized = true;
if (status !== 200) return;
if (data.language) $translate.use(data.language);
if ($scope.mode === 'login') window.document.title = data.cloudronName + ' Login';
$scope.status = data;
$scope.branding = data;
}).error(function () {
$scope.initialized = false;
});
@@ -205,6 +154,6 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
localStorage.token = search.accessToken || search.access_token;
window.location.href = '/';
} else {
$scope.showLogin();
$scope.showPasswordReset();
}
}]);
+68 -44
View File
@@ -1,17 +1,11 @@
'use strict';
/* global $, angular, tld, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR */
/* global $, angular, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, window, FileReader, document, redirectIfNeeded */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR, REGIONS_CONTABO */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('RestoreController', ['$scope', 'Client', function ($scope, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
@@ -41,6 +35,8 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.encrypted = false; // only used if a backup config contains that flag
$scope.setupToken = '';
$scope.skipDnsSetup = false;
$scope.disk = null;
$scope.blockDevices = [];
$scope.mountOptions = {
host: '',
@@ -49,32 +45,35 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
password: '',
diskPath: '',
user: '',
seal: false,
seal: true,
port: 22,
privateKey: ''
};
$scope.sysinfo = {
$scope.$watch('disk', function (newValue) {
if (!newValue) return;
$scope.mountOptions.diskPath = '/dev/disk/by-uuid/' + newValue.uuid;
});
$scope.ipv4Config = {
provider: 'generic',
ipv4: '',
ip: '',
ifname: ''
};
$scope.sysinfoProvider = [
$scope.ipv6Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipProviders = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.s3Regions = REGIONS_S3;
$scope.wasabiRegions = REGIONS_WASABI;
$scope.doSpacesRegions = REGIONS_DIGITALOCEAN;
@@ -85,6 +84,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.ionosRegions = REGIONS_IONOS;
$scope.upcloudRegions = REGIONS_UPCLOUD;
$scope.vultrRegions = REGIONS_VULTR;
$scope.contaboRegions = REGIONS_CONTABO;
$scope.storageProviders = STORAGE_PROVIDERS;
@@ -94,11 +94,12 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos'
|| provider === 'digitalocean-spaces' || provider === 'wasabi' || provider === 'scaleway-objectstorage'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2'
|| provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2';
|| provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2'
|| provider === 'contabo-objectstorage';
};
$scope.mountlike = function (provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs';
return provider === 'disk' || provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs';
};
$scope.restore = function () {
@@ -151,6 +152,10 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
} else if (backupConfig.provider === 'vultr-objectstorage') {
backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'contabo-objectstorage') {
backupConfig.region = $scope.contaboRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
} else if (backupConfig.provider === 'upcloud-objectstorage') {
var m = /^.*\.(.*)\.upcloudobjects.com$/.exec(backupConfig.endpoint);
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
@@ -195,7 +200,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
backupConfig.mountOptions.port = $scope.mountOptions.port;
backupConfig.mountOptions.privateKey = $scope.mountOptions.privateKey;
}
} else if (backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') {
} else if (backupConfig.provider === 'disk' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') {
backupConfig.mountOptions.diskPath = $scope.mountOptions.diskPath;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountPoint = $scope.mountPoint;
@@ -226,16 +231,17 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
return;
}
var sysinfoConfig = {
provider: $scope.sysinfo.provider
var data = {
backupConfig: backupConfig,
remotePath: $scope.remotePath.replace(/\.tar\.gz(\.enc)?$/, ''),
version: version ? version[1] : '',
ipv4Config: $scope.ipv4Config,
ipv6Config: $scope.ipv6Config,
skipDnsSetup: $scope.skipDnsSetup,
setupToken: $scope.setupToken
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ipv4 = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
Client.restore(backupConfig, $scope.remotePath.replace(/\.tar\.gz(\.enc)?$/, ''), version ? version[1] : '', sysinfoConfig, $scope.skipDnsSetup, $scope.setupToken, function (error) {
Client.restore(data, function (error) {
$scope.busy = false;
if (error) {
@@ -284,13 +290,13 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
function waitForRestore() {
$scope.busy = true;
Client.getStatus(function (error, status) {
Client.getProvisionStatus(function (error, status) {
if (!error && !status.restore.active) { // restore finished
if (status.restore.errorMessage) {
$scope.busy = false;
$scope.error.generic = status.restore.errorMessage;
} else { // restore worked, redirect to admin page
window.location.href = '/';
window.location.href = 'https://' + status.adminFqdn + '/';
}
return;
}
@@ -346,22 +352,40 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
};
function init() {
Client.getStatus(function (error, status) {
Client.getProvisionStatus(function (error, status) {
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'restore')) return; // redirected to some other view...
if (status.restore.active) return waitForRestore();
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage;
if (status.restore.errorMessage) $scope.error.generic = status.restore.errorMessage; // any previous restore error
if (status.activated) {
window.location.href = '/';
return;
}
Client.getProvisionBlockDevices(function (error, result) {
if (error) {
console.error('Failed to list blockdevices:', error);
} else {
// only offer non /, /boot or /home disks
result = result.filter(function (d) { return d.mountpoint !== '/' && d.mountpoint !== '/home' && d.mountpoint !== '/boot'; });
// only offer xfs and ext4 disks
result = result.filter(function (d) { return d.type === 'xfs' || d.type === 'ext4'; });
$scope.status = status;
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.initialized = true;
// amend label for UI
result.forEach(function (d) { d.label = d.path; });
}
$scope.blockDevices = result;
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
Client.detectIp(function (error, ip) { // this is never supposed to error
if (!error) $scope.ipv4Config.provider = ip.ipv4 ? 'generic' : 'noop';
if (!error) $scope.ipv6Config.provider = ip.ipv6 ? 'generic' : 'noop';
$scope.initialized = true;
});
});
});
}
+309 -91
View File
@@ -1,118 +1,336 @@
'use strict';
/* global angular */
/* global $ */
/* global $, angular, Clipboard, ENDPOINTS_OVH, window, FileReader, document, redirectIfNeeded */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.controller('SetupController', ['$scope', 'Client', function ($scope, Client) {
// Stupid angular location provider either wants html5 location mode or not, do the query parsing on my own
app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', function ($scope, $http, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.client = Client;
$scope.view = '';
$scope.initialized = false;
$scope.state = null; // 'initialized', 'waitingForDnsSetup', 'waitingForBox'
$scope.error = {};
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.advancedVisible = false;
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.taskMinutesActive = null;
$scope.owner = {
error: null,
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
{ name: 'Let\'s Encrypt Prod - Wildcard', value: 'letsencrypt-prod-wildcard' },
{ name: 'Let\'s Encrypt Staging', value: 'letsencrypt-staging' },
{ name: 'Let\'s Encrypt Staging - Wildcard', value: 'letsencrypt-staging-wildcard' },
{ name: 'Self-Signed', value: 'fallback' }, // this is not 'Custom' because we don't allow user to upload certs during setup phase
];
$scope.ipv4Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipv6Config = {
provider: 'generic',
ip: '',
ifname: ''
};
$scope.ipProviders = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
};
// If we migrate the api origin we have to poll the new location
if (search.admin_fqdn) Client.apiOrigin = 'https://' + search.admin_fqdn;
// keep in sync with domains.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'deSEC', value: 'desec' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.dnsCredentials = {
busy: false,
domain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
gandiApiKey: '',
cloudflareEmail: '',
cloudflareToken: '',
cloudflareTokenType: 'GlobalApiKey',
cloudflareDefaultProxyStatus: false,
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
deSecToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
email: '',
displayName: '',
username: '',
password: '',
submit: function () {
$scope.owner.busy = true;
$scope.owner.error = null;
var data = {
username: $scope.owner.username,
password: $scope.owner.password,
email: $scope.owner.email,
displayName: $scope.owner.displayName,
setupToken: $scope.setupToken
};
Client.createAdmin(data, function (error) {
if (error && error.statusCode === 400) {
$scope.owner.busy = false;
if (error.message === 'Invalid email') {
$scope.owner.error = { email: error.message };
$scope.owner.email = '';
$scope.ownerForm.email.$setPristine();
setTimeout(function () { $('#inputEmail').focus(); }, 200);
} else {
$scope.owner.error = { username: error.message };
$scope.owner.username = '';
$scope.ownerForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
}
return;
} else if (error) {
$scope.owner.busy = false;
console.error('Internal error', error);
$scope.owner.error = { generic: error.message };
return;
}
setView('finished');
});
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
}
};
function redirectIfNeeded(status) {
if ('develop' in search || localStorage.getItem('develop')) {
console.warn('Cloudron develop mode on. To disable run localStorage.removeItem(\'develop\')');
localStorage.setItem('develop', true);
return;
}
// if we are here from the ip first go to the real domain if already setup
if (status.adminFqdn && status.adminFqdn !== window.location.hostname) {
window.location.href = 'https://' + status.adminFqdn + '/setup.html';
return;
}
// if we don't have a domain yet, first go to domain setup
if (!status.adminFqdn) {
window.location.href = '/setupdns.html';
return;
}
if (status.activated) {
window.location.href = '/';
return;
}
}
function setView(view) {
if (view === 'finished') {
$scope.view = 'finished';
$scope.setDefaultTlsProvider = function () {
var dnsProvider = $scope.dnsCredentials.provider;
// wildcard LE won't work without automated DNS
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod';
} else {
$scope.view = 'owner';
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod-wildcard';
}
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName');
redirectIfNeeded(status);
setView(search.view);
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.error = {};
$scope.setupToken = search.setupToken;
$scope.initialized = true;
var provider = $scope.dnsCredentials.provider;
// Ensure we have a good autofocus
setTimeout(function () {
$(document).find("[autofocus]:first").focus();
}, 250);
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
config.secretAccessKey = $scope.dnsCredentials.secretAccessKey;
} else if (provider === 'gcdns') {
try {
var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content);
config.projectId = serviceAccountKey.project_id;
config.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!config.projectId || !config.credentials || !config.credentials.client_email || !config.credentials.private_key) {
throw new Error('One or more fields are missing in the JSON');
}
} catch (e) {
$scope.error.dnsCredentials = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.dnsCredentials.busy = false;
return;
}
} else if (provider === 'digitalocean') {
config.token = $scope.dnsCredentials.digitalOceanToken;
} else if (provider === 'gandi') {
config.token = $scope.dnsCredentials.gandiApiKey;
} else if (provider === 'godaddy') {
config.apiKey = $scope.dnsCredentials.godaddyApiKey;
config.apiSecret = $scope.dnsCredentials.godaddyApiSecret;
} else if (provider === 'cloudflare') {
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.accessKey = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'dnsimple') {
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
config.token = $scope.dnsCredentials.vultrToken;
} else if (provider === 'desec') {
config.token = $scope.dnsCredentials.deSecToken;
} else if (provider === 'namecom') {
config.username = $scope.dnsCredentials.nameComUsername;
config.token = $scope.dnsCredentials.nameComToken;
} else if (provider === 'namecheap') {
config.token = $scope.dnsCredentials.namecheapApiKey;
config.username = $scope.dnsCredentials.namecheapUsername;
} else if (provider === 'netcup') {
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
} else if (provider === 'ovh') {
config.endpoint = $scope.dnsCredentials.ovhEndpoint;
config.consumerKey = $scope.dnsCredentials.ovhConsumerKey;
config.appKey = $scope.dnsCredentials.ovhAppKey;
config.appSecret = $scope.dnsCredentials.ovhAppSecret;
} else if (provider === 'porkbun') {
config.apikey = $scope.dnsCredentials.porkbunApikey;
config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey;
}
var tlsConfig = {
provider: $scope.dnsCredentials.tlsConfig.provider,
wildcard: false
};
if ($scope.dnsCredentials.tlsConfig.provider.indexOf('-wildcard') !== -1) {
tlsConfig.provider = tlsConfig.provider.replace('-wildcard', '');
tlsConfig.wildcard = true;
}
var data = {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
config: config,
tlsConfig: tlsConfig
},
ipv4Config: $scope.ipv4Config,
ipv6Config: $scope.ipv6Config,
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
return;
}
waitForDnsSetup();
});
};
function waitForDnsSetup() {
$scope.state = 'waitingForDnsSetup';
Client.getProvisionStatus(function (error, status) {
if (!error && !status.setup.active) {
if (!status.adminFqdn || status.setup.errorMessage) { // setup reset or errored. start over
$scope.error.setup = status.setup.errorMessage;
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/activation.html' + (window.location.search);
}
return;
}
if (!error) {
$scope.message = status.setup.message;
$scope.taskMinutesActive = (new Date() - new Date(status.setup.startTime)) / 60000;
}
setTimeout(waitForDnsSetup, 5000);
});
}
function init() {
Client.getProvisionStatus(function (error, status) {
$scope.state = 'waitingForBox';
if (error) return Client.initError(error, init);
if (redirectIfNeeded(status, 'setup')) return; // redirected to some other view...
if (status.setup.active) return waitForDnsSetup();
$scope.error.setup = status.setup.errorMessage; // show any previous error
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
} else if (status.provider === 'linode' || status.provider === 'linode-oneclick' || status.provider === 'linode-stackscript') {
$scope.dnsCredentials.provider = 'linode';
} else if (status.provider === 'vultr' || status.provider === 'vultr-mp') {
$scope.dnsCredentials.provider = 'vultr';
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
// aws marketplace made a policy change that they one cannot provide route53 IAM credentials
$scope.dnsCredentials.provider = 'wildcard';
}
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.provider = status.provider;
Client.detectIp(function (error, ip) { // this is never supposed to error
if (!error) $scope.ipv4Config.provider = ip.ipv4 ? 'generic' : 'noop';
if (!error) $scope.ipv6Config.provider = ip.ipv6 ? 'generic' : 'noop';
$scope.state = 'initialized';
setTimeout(function () { $("[autofocus]:first").focus(); }, 100);
});
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
init();
}]);
+9 -6
View File
@@ -70,7 +70,8 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
$scope.busy = false;
$scope.error = null;
$scope.view = 'setup';
$scope.status = null;
$scope.branding = null;
$scope.dashboardUrl = '';
$scope.profileLocked = !!search.profileLocked;
$scope.existingUsername = !!search.username;
@@ -119,11 +120,13 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
}
}
$http.post(API_ORIGIN + '/api/v1/cloudron/setup_account', data).success(function (data, status) {
$http.post(API_ORIGIN + '/api/v1/auth/setup_account', data).success(function (data, status) {
if (status !== 201) return error(data, status);
// set token to autologin
localStorage.token = data.accessToken;
// set token to autologin on first oidc flow
localStorage.cloudronFirstTimeToken = data.accessToken;
$scope.dashboardUrl = '/openid/auth?client_id=cid-webadmin&scope=openid email profile&response_type=code token&redirect_uri=' + window.location.origin + '/authcallback.html';
$scope.view = 'done';
}).error(error);
@@ -133,14 +136,14 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
$scope.view = 'noUsername';
$scope.initialized = true;
} else {
$http.get(API_ORIGIN + '/api/v1/cloudron/status').success(function (data, status) {
$http.get(API_ORIGIN + '/api/v1/auth/branding').success(function (data, status) {
$scope.initialized = true;
if (status !== 200) return;
if (data.language) $translate.use(data.language);
$scope.status = data;
$scope.branding = data;
}).error(function () {
$scope.initialized = false;
});
-337
View File
@@ -1,337 +0,0 @@
'use strict';
/* global $, tld, angular, Clipboard */
// create main application module
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
return tld.getDomain(domain);
};
});
app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', function ($scope, $http, $timeout, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.state = null; // 'initialized', 'waitingForDnsSetup', 'waitingForBox'
$scope.error = {};
$scope.provider = '';
$scope.showDNSSetup = false;
$scope.instanceId = '';
$scope.isDomain = false;
$scope.isSubdomain = false;
$scope.advancedVisible = false;
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
{ name: 'Let\'s Encrypt Prod - Wildcard', value: 'letsencrypt-prod-wildcard' },
{ name: 'Let\'s Encrypt Staging', value: 'letsencrypt-staging' },
{ name: 'Let\'s Encrypt Staging - Wildcard', value: 'letsencrypt-staging-wildcard' },
{ name: 'Self-Signed', value: 'fallback' }, // this is not 'Custom' because we don't allow user to upload certs during setup phase
];
$scope.sysinfo = {
provider: 'generic',
ipv4: '',
ifname: ''
};
$scope.sysinfoProvider = [
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
switch (provider) {
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
default: return 'Unknown';
}
};
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
};
// If we migrate the api origin we have to poll the new location
if (search.admin_fqdn) Client.apiOrigin = 'https://' + search.admin_fqdn;
$scope.$watch('dnsCredentials.domain', function (newVal) {
if (!newVal) {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else if (!tld.getDomain(newVal) || newVal[newVal.length-1] === '.') {
$scope.isDomain = false;
$scope.isSubdomain = false;
} else {
$scope.isDomain = true;
$scope.isSubdomain = tld.getDomain(newVal) !== newVal;
}
});
// keep in sync with domains.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Hetzner', value: 'hetzner' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.dnsCredentials = {
busy: false,
domain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
gandiApiKey: '',
cloudflareEmail: '',
cloudflareToken: '',
cloudflareTokenType: 'GlobalApiKey',
cloudflareDefaultProxyStatus: false,
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
bunnyAccessKey: '',
hetznerToken: '',
vultrToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
porkbunSecretapikey: '',
porkbunApikey: '',
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
}
};
$scope.setDefaultTlsProvider = function () {
var dnsProvider = $scope.dnsCredentials.provider;
// wildcard LE won't work without automated DNS
if (dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod';
} else {
$scope.dnsCredentials.tlsConfig.provider = 'letsencrypt-prod-wildcard';
}
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.dnsCredentials.gcdnsKey, 'content', 'keyFileName');
$scope.setDnsCredentials = function () {
$scope.dnsCredentials.busy = true;
$scope.error = {};
var provider = $scope.dnsCredentials.provider;
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
config.secretAccessKey = $scope.dnsCredentials.secretAccessKey;
} else if (provider === 'gcdns') {
try {
var serviceAccountKey = JSON.parse($scope.dnsCredentials.gcdnsKey.content);
config.projectId = serviceAccountKey.project_id;
config.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!config.projectId || !config.credentials || !config.credentials.client_email || !config.credentials.private_key) {
throw new Error('One or more fields are missing in the JSON');
}
} catch (e) {
$scope.error.dnsCredentials = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.dnsCredentials.busy = false;
return;
}
} else if (provider === 'digitalocean') {
config.token = $scope.dnsCredentials.digitalOceanToken;
} else if (provider === 'gandi') {
config.token = $scope.dnsCredentials.gandiApiKey;
} else if (provider === 'godaddy') {
config.apiKey = $scope.dnsCredentials.godaddyApiKey;
config.apiSecret = $scope.dnsCredentials.godaddyApiSecret;
} else if (provider === 'cloudflare') {
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
config.defaultProxyStatus = $scope.dnsCredentials.cloudflareDefaultProxyStatus;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} else if (provider === 'bunny') {
config.token = $scope.dnsCredentials.bunnyAccessKey;
} else if (provider === 'hetzner') {
config.token = $scope.dnsCredentials.hetznerToken;
} else if (provider === 'vultr') {
config.token = $scope.dnsCredentials.vultrToken;
} else if (provider === 'namecom') {
config.username = $scope.dnsCredentials.nameComUsername;
config.token = $scope.dnsCredentials.nameComToken;
} else if (provider === 'namecheap') {
config.token = $scope.dnsCredentials.namecheapApiKey;
config.username = $scope.dnsCredentials.namecheapUsername;
} else if (provider === 'netcup') {
config.customerNumber = $scope.dnsCredentials.netcupCustomerNumber;
config.apiKey = $scope.dnsCredentials.netcupApiKey;
config.apiPassword = $scope.dnsCredentials.netcupApiPassword;
} else if (provider === 'porkbun') {
config.apikey = $scope.dnsCredentials.porkbunApikey;
config.secretapikey = $scope.dnsCredentials.porkbunSecretapikey;
}
var tlsConfig = {
provider: $scope.dnsCredentials.tlsConfig.provider,
wildcard: false
};
if ($scope.dnsCredentials.tlsConfig.provider.indexOf('-wildcard') !== -1) {
tlsConfig.provider = tlsConfig.provider.replace('-wildcard', '');
tlsConfig.wildcard = true;
}
var sysinfoConfig = {
provider: $scope.sysinfo.provider
};
if ($scope.sysinfo.provider === 'fixed') {
sysinfoConfig.ipv4 = $scope.sysinfo.ipv4;
} else if ($scope.sysinfo.provider === 'network-interface') {
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
var data = {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
config: config,
tlsConfig: tlsConfig
},
sysinfoConfig: sysinfoConfig,
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
return;
}
waitForDnsSetup();
});
};
function waitForDnsSetup() {
$scope.state = 'waitingForDnsSetup';
Client.getStatus(function (error, status) {
if (!error && !status.setup.active) {
if (!status.adminFqdn || status.setup.errorMessage) { // setup reset or errored. start over
$scope.error.setup = status.setup.errorMessage;
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/setup.html' + (window.location.search);
}
return;
}
$scope.message = status.setup.message;
setTimeout(waitForDnsSetup, 5000);
});
}
function initialize() {
Client.getStatus(function (error, status) {
if (error) {
// During domain migration, the box code restarts and can result in getStatus() failing temporarily
console.error(error);
$scope.state = 'waitingForBox';
return $timeout(initialize, 3000);
}
// domain is currently like a lock flag
if (status.adminFqdn) return waitForDnsSetup();
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') {
$scope.dnsCredentials.provider = 'digitalocean';
} else if (status.provider === 'linode' || status.provider === 'linode-oneclick' || status.provider === 'linode-stackscript') {
$scope.dnsCredentials.provider = 'linode';
} else if (status.provider === 'vultr' || status.provider === 'vultr-mp') {
$scope.dnsCredentials.provider = 'vultr';
} else if (status.provider === 'gce') {
$scope.dnsCredentials.provider = 'gcdns';
} else if (status.provider === 'ami') {
$scope.dnsCredentials.provider = 'route53';
}
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.provider = status.provider;
$scope.state = 'initialized';
setTimeout(function () { $("[autofocus]:first").focus(); }, 100);
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
initialize();
}]);
-376
View File
@@ -1,376 +0,0 @@
'use strict';
/* global angular, $, Terminal, AttachAddon, FitAddon, ISTATES */
// create main application module
angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification']);
angular.module('Application').controller('TerminalController', ['$scope', '$translate', '$timeout', '$location', 'Client', function ($scope, $translate, $timeout, $location, Client) {
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.apps = [];
$scope.selected = '';
$scope.terminal = null;
$scope.terminalSocket = null;
$scope.fitAddon = null;
$scope.restartAppBusy = false;
$scope.appBusy = false;
$scope.selectedAppInfo = null;
$scope.schedulerTasks = [];
$scope.downloadFile = {
error: '',
filePath: '',
busy: false,
downloadUrl: function () {
if (!$scope.downloadFile.filePath) return '';
var filePath = encodeURIComponent($scope.downloadFile.filePath);
return Client.apiOrigin + '/api/v1/apps/' + $scope.selected.value + '/download?file=' + filePath + '&access_token=' + Client.getToken();
},
show: function () {
$scope.downloadFile.busy = false;
$scope.downloadFile.error = '';
$scope.downloadFile.filePath = '';
$('#downloadFileModal').modal('show');
},
submit: function () {
$scope.downloadFile.busy = true;
Client.checkDownloadableFile($scope.selected.value, $scope.downloadFile.filePath, function (error) {
$scope.downloadFile.busy = false;
if (error) {
$scope.downloadFile.error = 'The requested file does not exist.';
return;
}
// we have to click the link to make the browser do the download
// don't know how to prevent the browsers
$('#fileDownloadLink')[0].click();
$('#downloadFileModal').modal('hide');
});
}
};
$scope.uploadProgress = {
busy: false,
total: 0,
current: 0,
show: function () {
$scope.uploadProgress.total = 0;
$scope.uploadProgress.current = 0;
$('#uploadProgressModal').modal('show');
},
hide: function () {
$('#uploadProgressModal').modal('hide');
}
};
$scope.uploadFile = function () {
var fileUpload = document.querySelector('#fileUpload');
fileUpload.onchange = function (e) {
if (e.target.files.length === 0) return;
$scope.uploadProgress.busy = true;
$scope.uploadProgress.show();
Client.uploadFile($scope.selected.value, e.target.files[0], function progress(e) {
$scope.uploadProgress.total = e.total;
$scope.uploadProgress.current = e.loaded;
}, function (error) {
if (error) console.error(error);
$scope.uploadProgress.busy = false;
$scope.uploadProgress.hide();
});
};
fileUpload.click();
};
$scope.usesAddon = function (addon) {
if (!$scope.selected || !$scope.selected.addons) return false;
return !!Object.keys($scope.selected.addons).find(function (a) { return a === addon; });
};
function reset() {
if ($scope.terminal) {
$scope.terminal.dispose();
$scope.terminal = null;
}
if ($scope.terminalSocket) {
$scope.terminalSocket = null;
}
$scope.selectedAppInfo = null;
}
$scope.restartApp = function () {
$scope.restartAppBusy = true;
$scope.appBusy = true;
var appId = $scope.selected.value;
function waitUntilRestarted(callback) {
refreshApp(appId, function (error, result) {
if (error) return callback(error);
if (result.installationState === ISTATES.INSTALLED) return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartApp(appId, function (error) {
if (error) console.error('Failed to restart app.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartAppBusy = false;
$scope.appBusy = false;
});
});
};
function createTerminalSocket(callback) {
var appId = $scope.selected.value;
Client.createExec(appId, { cmd: [ '/bin/bash' ], tty: true, lang: 'C.UTF-8' }, function (error, execId) {
if (error) return callback(error);
try {
// websocket cannot use relative urls
var url = Client.apiOrigin.replace('https', 'wss') + '/api/v1/apps/' + appId + '/exec/' + execId + '/startws?tty=true&rows=' + $scope.terminal.rows + '&columns=' + $scope.terminal.cols + '&access_token=' + Client.getToken();
$scope.terminalSocket = new WebSocket(url);
$scope.terminal.loadAddon(new AttachAddon.AttachAddon($scope.terminalSocket));
$scope.terminalSocket.onclose = function () {
// retry in one second
$scope.terminalReconnectTimeout = setTimeout(function () {
showTerminal(true);
}, 1000);
};
callback();
} catch (e) {
callback(e);
}
});
}
function refreshApp(id, callback) {
Client.getApp(id, function (error, result) {
if (error) return callback(error);
$scope.selectedAppInfo = result;
callback(null, result);
});
}
function showTerminal(retry) {
reset();
if (!$scope.selected) return;
var appId = $scope.selected.value;
refreshApp(appId, function (error) {
if (error) return console.error(error);
var result = $scope.selectedAppInfo;
$scope.schedulerTasks = result.manifest.addons.scheduler ? Object.keys(result.manifest.addons.scheduler).map(function (k) { return { name: k, command: result.manifest.addons.scheduler[k].command }; }) : [];
$scope.terminal = new Terminal();
$scope.fitAddon = new FitAddon.FitAddon();
$scope.terminal.loadAddon($scope.fitAddon);
$scope.terminal.open(document.querySelector('#terminalContainer'));
window.terminal = $scope.terminal;
// Let the browser handle paste
$scope.terminal.attachCustomKeyEventHandler(function (e) {
if (e.key === 'v' && (e.ctrlKey || e.metaKey)) return false;
});
if (retry) $scope.terminal.writeln('Reconnecting...');
else $scope.terminal.writeln('Connecting...');
// we have to give it some time to setup the terminal to make it fit, there is no event unfortunately
setTimeout(function () {
if (!$scope.terminal) return;
// this is here so that the text wraps correctly after the fit!
// var YELLOW = '\u001b[33m'; // https://gist.github.com/dainkaplan/4651352
// var NC = '\u001b[0m';
// $scope.terminal.writeln(YELLOW + 'If you resize the browser window, press Ctrl+D to start a new session with the current size.' + NC);
// we have to first write something on reconnect after app restart..not sure why
$scope.fitAddon.fit();
// create exec container after we fit() since we cannot resize exec container post-creation
createTerminalSocket(function (error) { if (error) console.error(error); });
$scope.terminal.focus();
}, 1000);
});
}
$scope.terminalInject = function (addon, extra) {
if (!$scope.terminalSocket) return;
var cmd, manifestVersion = $scope.selected.manifest.manifestVersion;
if (addon === 'mysql') {
if (manifestVersion === 1) {
cmd = 'mysql --user=${MYSQL_USERNAME} --password=${MYSQL_PASSWORD} --host=${MYSQL_HOST} ${MYSQL_DATABASE}';
} else {
cmd = 'mysql --user=${CLOUDRON_MYSQL_USERNAME} --password=${CLOUDRON_MYSQL_PASSWORD} --host=${CLOUDRON_MYSQL_HOST} ${CLOUDRON_MYSQL_DATABASE}';
}
} else if (addon === 'postgresql') {
if (manifestVersion === 1) {
cmd = 'PGPASSWORD=${POSTGRESQL_PASSWORD} psql -h ${POSTGRESQL_HOST} -p ${POSTGRESQL_PORT} -U ${POSTGRESQL_USERNAME} -d ${POSTGRESQL_DATABASE}';
} else {
cmd = 'PGPASSWORD=${CLOUDRON_POSTGRESQL_PASSWORD} psql -h ${CLOUDRON_POSTGRESQL_HOST} -p ${CLOUDRON_POSTGRESQL_PORT} -U ${CLOUDRON_POSTGRESQL_USERNAME} -d ${CLOUDRON_POSTGRESQL_DATABASE}';
}
} else if (addon === 'mongodb') {
if (manifestVersion === 1) {
cmd = 'mongo -u "${MONGODB_USERNAME}" -p "${MONGODB_PASSWORD}" ${MONGODB_HOST}:${MONGODB_PORT}/${MONGODB_DATABASE}';
} else {
cmd = 'mongosh -u "${CLOUDRON_MONGODB_USERNAME}" -p "${CLOUDRON_MONGODB_PASSWORD}" ${CLOUDRON_MONGODB_HOST}:${CLOUDRON_MONGODB_PORT}/${CLOUDRON_MONGODB_DATABASE}';
}
} else if (addon === 'redis') {
if (manifestVersion === 1) {
cmd = 'redis-cli -h "${REDIS_HOST}" -p "${REDIS_PORT}" -a "${REDIS_PASSWORD}"';
} else {
cmd = 'redis-cli -h "${CLOUDRON_REDIS_HOST}" -p "${CLOUDRON_REDIS_PORT}" -a "${CLOUDRON_REDIS_PASSWORD}"';
}
} else if (addon === 'scheduler' && extra) {
cmd = extra.command;
}
if (!cmd) return;
cmd += ' ';
$scope.terminalSocket.send(cmd);
$scope.terminal.focus();
};
// terminal right click handling
$scope.terminalClear = function () {
if (!$scope.terminal) return;
$scope.terminal.clear();
$scope.terminal.focus();
};
$scope.terminalCopy = function () {
if (!$scope.terminal) return;
// execCommand('copy') would copy any selection from the page, so do this only if terminal has a selection
if (!$scope.terminal.getSelection()) return;
document.execCommand('copy');
$scope.terminal.focus();
};
$('.contextMenuBackdrop').on('click', function () {
$('#terminalContextMenu').hide();
$('.contextMenuBackdrop').hide();
$scope.terminal.focus();
});
$('#terminalContainer').on('contextmenu', function (e) {
if (!$scope.terminal) return true;
e.preventDefault();
$('.contextMenuBackdrop').show();
$('#terminalContextMenu').css({
display: 'block',
left: e.pageX,
top: e.pageY
});
return false;
});
window.addEventListener('resize', function () {
if ($scope.fitAddon) $scope.fitAddon.fit();
});
Client.getStatus(function (error, status) {
if (error) return $scope.error(error);
if (!status.activated) {
console.log('Not activated yet, closing or redirecting', status);
window.close();
window.location.href = '/';
return;
}
// check version and force reload if needed
if (!localStorage.version) {
localStorage.version = status.version;
} else if (localStorage.version !== status.version) {
localStorage.version = status.version;
window.location.reload(true);
}
console.log('Running terminal version ', localStorage.version);
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
Client.refreshUserInfo(function (error) {
if (error) return $scope.error(error);
Client.refreshConfig(function (error) {
if (error) return $scope.error(error);
refreshApp(search.id, function (error, app) {
$scope.selected = {
type: 'app',
value: app.id,
name: app.fqdn + ' (' + app.manifest.title + ')',
addons: app.manifest.addons,
manifest: app.manifest
};
// now mark the Client to be ready
Client.setReady();
$scope.initialized = true;
showTerminal();
});
});
});
});
$translate([ 'terminal.title' ]).then(function (tr) {
if (tr['terminal.title'] !== 'terminal.title') window.document.title = tr['terminal.title'];
});
// setup all the dialog focus handling
['downloadFileModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
}]);
-84
View File
@@ -1,84 +0,0 @@
<!DOCTYPE html>
<html ng-app="Application" ng-controller="LogsController">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Logs</title>
<meta name="description" content="Cloudron Logs">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js?<%= revision %>"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js?<%= revision %>"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-animate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-base64.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-sanitize.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- colors -->
<script type="text/javascript" src="/3rdparty/js/colors.js?<%= revision %>"></script>
<!-- moment -->
<script type="text/javascript" src="/3rdparty/js/moment-with-locales.min.js?<%= revision %>"></script>
<!-- Main Application -->
<script type="text/javascript" src="/js/logs.js?<%= revision %>"></script>
</head>
<body class="logs">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> {{ 'main.offline' | tr }}</a>
<div class="animateMe ng-hide layout-root" ng-show="initialized">
<div class="logs-controls">
<h3 style="display: inline-block;">{{ selected.name }}</h3>
<!-- logs actions -->
<div class="pull-right">
<a class="btn btn-primary" ng-href="{{ '/terminal.html?id=' + selected.value }}" target="_blank" ng-show="selected.type === 'app'"><i class="fa fa-terminal"></i> {{ 'terminal.title' | tr }}</a>
<a class="btn btn-primary" ng-href="{{ '/filemanager.html?type=app&id=' + selected.value }}" target="_blank" ng-show="selected.type === 'app'"><i class="fas fa-folder"></i> {{ 'filemanager.title' | tr }}</a>
<a class="btn btn-primary" ng-click="clear()"><i class="fa fa-trash"></i> {{ 'logs.clear' | tr }}</a>
<a class="btn btn-primary" ng-href="{{ selected.url }}&format=short&lines=-1"><i class="fa fa-download"></i> {{ 'logs.download' | tr }}</a>
</div>
</div>
<div class="logs-container"></div>
</div>
</body>
</html>
+15 -5
View File
@@ -4,8 +4,8 @@
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Not Found</title>
<meta name="description" content="Cloudron Not Found">
<title>Cloudron - Not Found</title>
<meta name="description" content="Cloudron - Not Found">
<!-- Use static style as we can't include local stylesheets -->
<style>
@@ -23,7 +23,7 @@
height: 100%;
width: 100%;
text-align: center;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
font-family: "Noto Sans", Helvetica, Arial, sans-serif;
font-size: 13px;
line-height: 1.846;
}
@@ -51,9 +51,19 @@
<script type="text/javascript">
window.addEventListener('load', (event) => {
document.getElementById('message').innerHTML =
'You are seeing this page because the DNS record of <b>' + window.location.hostname + '</b> is set to this server\'s IP'
// https://stackoverflow.com/questions/37437890/check-if-url-has-domain-name-and-not-an-ip
const containsLetter = /[a-zA-z]/.test(window.location.hostname); // ignore technicality that IP can contain letters ! http://192.168.0x1.0x1 or http://0xc0.0xa8.1.1
const isIPv6 = location.hostname.startsWith('[') && location.hostname.endsWith(']');
let message;
if (!containsLetter || isIPv6) { // ipv4 or ipv6
message = 'You cannot view Cloudron dashboard by IP address. Instead, navigate to the domain you configured during setup i.e <b>https://my.domain.example</b> .'
+ '<br>If you do not remember your domain, SSH into your server and run <code>cloudron-support --owner-login</code> .'
} else { // hostname
message = 'You are seeing this page because the DNS record of <b>' + window.location.hostname + '</b> is set to this server\'s IP'
+ ' but Cloudron has no app configured for this domain.';
}
document.getElementById('message').innerHTML = message;
});
</script>
</head>
@@ -6,8 +6,8 @@
<meta http-equiv="Content-Security-Policy" content="default-src <%= apiOrigin %> 'unsafe-inline' 'unsafe-eval' 'self'; img-src <%= apiOrigin %> 'self'" />
<!-- this gets changed once we get the status (because angular has not loaded yet, we see template string for a flash) -->
<title>Cloudron Login</title>
<meta name="description" content="Cloudron Login">
<title>Cloudron Password Reset</title>
<meta name="description" content="Cloudron Password Reset">
<link id="favicon" href="<%= apiOrigin %>/api/v1/cloudron/avatar" rel="icon" type="image/png">
@@ -15,7 +15,7 @@
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
@@ -47,54 +47,14 @@
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/login.js?<%= revision %>"></script>
<script type="text/javascript" src="/js/passwordreset.js?<%= revision %>"></script>
</head>
<body ng-app="Application" ng-controller="LoginController">
<body ng-app="Application" ng-controller="PasswordResetController">
<div class="layout-root ng-cloak" ng-show="initialized">
<div class="layout-content" ng-show="mode === 'login'">
<div class="card" style="padding: 20px; margin-top: 100px; max-width: 620px;">
<div class="row">
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= apiOrigin %>/api/v1/cloudron/avatar"/>
<br/>
<h1><small>{{ 'login.loginTo' | tr }}</small> {{ status.cloudronName || 'Cloudron' }}</h1>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4 class="has-error" ng-show="error && (error.generic || error.password)">{{ 'login.errorIncorrectCredentials' | tr }}</h4>
<h4 class="has-error" ng-show="error && error.totpToken">{{ 'login.errorIncorrect2FAToken' | tr }}</h4>
<h4 class="has-error" ng-show="error && error.internal">{{ 'login.errorInternal' | tr }}</h4>
</div>
</div>
<div class="row">
<div class="col-md-12">
<form name="loginForm" ng-submit="onLogin()">
<div class="form-group">
<label class="control-label" for="inputUsername">{{ 'login.username' | tr }}</label>
<input type="text" class="form-control" id="inputUsername" name="username" ng-model="username" ng-disabled="busy" autofocus required>
</div>
<div class="form-group" ng-class="{'has-error': error.password }">
<label class="control-label" for="inputPassword">{{ 'login.password' | tr }}</label>
<input type="password" class="form-control" name="password" id="inputPassword" ng-model="password" ng-disabled="busy" required password-reveal>
</div>
<div class="form-group" ng-class="{'has-error': error.totpToken }">
<label class="control-label" for="inputTotpToken">{{ 'login.2faToken' | tr }}</label>
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" ng-model="totpToken" ng-disabled="busy" value="">
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || loginForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'login.signInAction' | tr }}</button>
</form>
<a ng-href="" class="hand" ng-click="showPasswordReset()">{{ 'login.resetPasswordAction' | tr }}</a>
</div>
</div>
</div>
</div>
<div class="layout-content" ng-show="mode === 'passwordReset'">
<div class="card" style="padding: 20px; margin-top: 100px; max-width: 620px;">
<div class="row">
@@ -113,9 +73,11 @@
<input type="text" class="form-control" id="inputPasswordResetIdentifier" name="passwordResetIdentifier" ng-model="passwordResetIdentifier" ng-disabled="busy" autofocus required>
</div>
<br/>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || passwordResetForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.resetAction' | tr }}</button>
<div class="card-form-bottom-bar">
<a href="/" class="hand">{{ 'passwordReset.backToLoginAction' | tr }}</a>
<button class="btn btn-primary btn-outline" type="submit" ng-disabled="busy || passwordResetForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.resetAction' | tr }}</button>
</div>
</form>
<a ng-href="" class="hand" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
@@ -127,9 +89,10 @@
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= apiOrigin %>/api/v1/cloudron/avatar"/>
<br/>
<h2>{{ 'passwordReset.emailSent.title' | tr }}</h2>
<h2 ng-hide="error">{{ 'passwordReset.emailSent.title' | tr }}</h2>
<h4 ng-show="error" class="has-error">{{ error }}</h4>
<br/>
<button class="btn btn-primary" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</button>
<a href="/" class="btn btn-primary">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
@@ -173,10 +136,11 @@
<label class="control-label" for="inputPasswordResetTotpToken">{{ 'login.2faToken' | tr }}</label>
<input type="text" class="form-control" name="passwordResetTotpToken" id="inputPasswordResetTotpToken" ng-model="totpToken" ng-disabled="busy" value="">
</div>
<br/>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || newPasswordForm.$invalid || newPassword !== newPasswordRepeat"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.passwordChanged.submitAction' | tr }}</button>
<div class="card-form-bottom-bar">
<a href="/" class="hand">{{ 'passwordReset.backToLoginAction' | tr }}</a>
<button class="btn btn-primary btn-outline" type="submit" ng-disabled="busy || newPasswordForm.$invalid || newPassword !== newPasswordRepeat"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'passwordReset.passwordChanged.submitAction' | tr }}</button>
</div>
</form>
<a ng-href="" class="hand" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
@@ -197,7 +161,7 @@
</div>
<footer class="text-center">
<span class="text-muted" ng-bind-html="status.footer | markdown2html"></span>
<span class="text-muted" ng-bind-html="branding.footer | markdown2html"></span>
</footer>
</div>
+337 -312
View File
@@ -1,54 +1,51 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Restore</title>
<meta name="description" content="Cloudron Restore">
<title>Cloudron Restore</title>
<meta name="description" content="Cloudron Restore">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/restore.js"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/restore.js"></script>
</head>
@@ -56,289 +53,317 @@
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container ng-cloak text-center" ng-show="busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
</div>
</div>
<div class="main-container ng-cloak text-center" ng-show="busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="initialized && !busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="configureBackupForm" role="form" novalidate ng-submit="restore()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h2>Cloudron Restore</h2>
<p>Provide the backup to restore from</p>
</div>
</div>
<div class="main-container ng-cloak" ng-show="initialized && !busy">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="configureBackupForm" role="form" novalidate ng-submit="restore()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h2>Cloudron Restore</h2>
<p>Provide the backup to restore from</p>
</div>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col-md-8 col-md-offset-2 text-center">
<input type="file" id="backupConfigFileInput" style="display:none"/>
<button type="button" class="btn btn-default" onclick="getElementById('backupConfigFileInput').click();">Upload Backup Config</button>
</div>
<br/>
</div>
<div class="row" style="margin-bottom: 20px">
<div class="col-md-8 col-md-offset-2 text-center">
<input type="file" id="backupConfigFileInput" style="display:none"/>
<button type="button" class="btn btn-default" onclick="getElementById('backupConfigFileInput').click();">Upload Backup Config</button>
</div>
<br/>
</div>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<p class="has-error text-center" ng-show="error">{{ error.generic }}</p>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<p class="has-error text-center" ng-show="error">{{ error.generic }}</p>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageProviderProvider" ng-model="provider" ng-options="a.value as a.name for a in storageProviders" ng-change=clearForm()></select>
</div>
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': error.mountPoint }" ng-show="provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">Mountpoint</label>
<input type="text" class="form-control" ng-model="mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="busy" placeholder="Folder where filesystem is mounted" ng-required="provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">Server IP or Hostname</label>
<input type="text" class="form-control" ng-model="mountOptions.host" id="configureBackupHost" name="host" ng-disabled="busy" placeholder="Server IP or hostname" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="provider === 'cifs'">
<label>
<input type="checkbox" ng-model="mountOptions.seal">Use seal encryption. Requires at least SMB v3</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">Remote Directory</label>
<input type="text" class="form-control" ng-model="mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="busy" placeholder="/share" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">Username ({{ provider }})</label>
<input type="text" class="form-control" ng-model="mountOptions.username" id="configureBackupUsername" name="cifsUsername" ng-disabled="busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">Password ({{ provider }})</label>
<input type="password" class="form-control" ng-model="mountOptions.password" id="configureBackupPassword" name="cifsPassword" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'ext4' || provider === 'xfs'">
<label class="control-label" for="inputConfigureDiskPath">Disk Path</label>
<input type="text" class="form-control" ng-model="mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'ext4' || provider === 'xfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">SSH Port</label>
<input type="number" class="form-control" ng-model="mountOptions.port" id="configureBackupPort" name="port" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">SSH User</label>
<input type="text" class="form-control" ng-model="mountOptions.user" id="configureBackupUser" name="user" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">SSH Private Key</label>
<textarea class="form-control" ng-model="mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="busy"></textarea>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': error.backupFolder }" ng-show="provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'filesystem'">
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="busy" placeholder="URL" ng-required="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
</div>
<div class="checkbox" ng-show="provider === 'minio' || provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">
Accept Self-signed certificate
</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': error.bucket }" ng-show="s3like(provider) || provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.prefix }" ng-show="provider !== 'filesystem' && provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="busy" ng-required="provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupS3V4CompatRegion">Region</label>
<input class="form-control" type="text" name="region" id="inputConfigureBackupS3V4CompatRegion" ng-model="region" ng-disabled="busy" placeholder="Leave empty to use us-east-1 as default"></input>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="busy" ng-required="provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="busy" ng-required="provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="busy" ng-required="provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="busy" ng-required="provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="busy" ng-required="provider === 'linode-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ovh-objectstorage'">
<label class="control-label" for="inputConfigureBackupOvhRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupOvhRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ovhRegions" ng-disabled="busy" ng-required="provider === 'ovh-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ionos-objectstorage'">
<label class="control-label" for="inputConfigureBackupIonosRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupIonosRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ionosRegions" ng-disabled="busy" ng-required="provider === 'ionos-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'vultr-objectstorage'">
<label class="control-label" for="inputConfigureBackupVultrRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupVultrRegion" ng-model="endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="busy" ng-required="provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.accessKeyId }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">Access key id</label>
<input type="text" class="form-control" ng-model="accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.secretAccessKey }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.gcsKeyInput }" ng-show="provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="busy" ng-required="provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="storageFormat">Storage Format</label>
<select class="form-control" id="storageFormat" ng-change="key = ''" ng-model="format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
<label class="control-label" for="inputConfigureRemotePath">Backup Path</label>
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="Backup Path" required ng-disabled="busy">
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }">
<label class="control-label" for="inputConfigureBackupPassword">Encryption password <span ng-hide="encrypted">(optional)</span></label>
<input type="text" class="form-control" ng-model="password" id="inputConfigureBackupPassword" name="prefix" ng-disabled="busy" placeholder="Passphrase used to encrypt the backups" ng-required="encrypted">
</div>
<div class="checkbox" ng-show="format === 'rsync' && password.length !== 0">
<label>
<input type="checkbox" ng-model="encryptedFilenames">Decrypt Filenames</input>
</label>
</div>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="skipDnsSetup"><b>Dry run</b></sup>
</label>
<br/>
<small>When enabled, apps are restored but the DNS records are not updated to point to this server. To access the dashboard, this browser's host must have an entry in <code>/etc/hosts</code> for the dashboard domain to this server's IP.
See the <a href="https://docs.cloudron.io/backups/#dry-run" target="_blank">docs</a> for more information.</small>
</div>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">IP Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="sysinfo.provider" ng-options="a.value as a.name for a in sysinfoProvider"></select>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="sysinfo.provider === 'fixed'" ng-class="{ 'has-error': error.ipv4 }">
<label class="control-label">IP Address</label>
<input type="text" class="form-control" ng-model="sysinfo.ipv4" name="ipv4" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'fixed'">
<p class="has-error" ng-show="error.ipv4">{{ error.ipv4 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="sysinfo.provider === 'network-interface'" ng-class="{ 'has-error': error.ifname }">
<label class="control-label">Interface Name</label>
<input type="text" class="form-control" ng-model="sysinfo.ifname" name="ifname" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'network-interface'">
<p class="has-error" ng-show="error.ifname">{{ error.ifname }}</p>
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="configureBackupForm.$invalid"/><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> Restore</button>
</div>
</div>
</form>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="https://docs.cloudron.io/backups/#storage-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageProviderProvider" ng-model="provider" ng-options="a.value as a.name for a in storageProviders" ng-change=clearForm()></select>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': error.mountPoint }" ng-show="provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">Mountpoint</label>
<input type="text" class="form-control" ng-model="mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="busy" placeholder="Folder where filesystem is mounted" ng-required="provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">Server IP or Hostname</label>
<input type="text" class="form-control" ng-model="mountOptions.host" id="configureBackupHost" name="host" ng-disabled="busy" placeholder="Server IP or hostname" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="provider === 'cifs'">
<label>
<input type="checkbox" ng-model="mountOptions.seal">Use seal encryption. Requires at least SMB v3</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="provider === 'cifs' || provider === 'nfs' || provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">Remote Directory</label>
<input type="text" class="form-control" ng-model="mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="busy" placeholder="/share" ng-required="provider === 'cifs' || provider === 'nfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">Username ({{ provider }})</label>
<input type="text" class="form-control" ng-model="mountOptions.username" id="configureBackupUsername" name="cifsUsername" ng-disabled="busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">Password ({{ provider }})</label>
<input type="password" class="form-control" ng-model="mountOptions.password" id="configureBackupPassword" name="cifsPassword" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'ext4' || provider === 'xfs'">
<label class="control-label" for="inputConfigureDiskPath">Disk Path</label>
<input type="text" class="form-control" ng-model="mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'ext4' || provider === 'xfs'">
</div>
<!-- Disk -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'disk'">
<label class="control-label">Device</label>
<select class="form-control" ng-model="disk" ng-options="item as item.label for item in blockDevices track by item.path" ng-required="provider === 'disk'"></select>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">SSH Port</label>
<input type="number" class="form-control" ng-model="mountOptions.port" id="configureBackupPort" name="port" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">SSH User</label>
<input type="text" class="form-control" ng-model="mountOptions.user" id="configureBackupUser" name="user" ng-disabled="busy">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">SSH Private Key</label>
<textarea class="form-control" ng-model="mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="busy"></textarea>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': error.backupFolder }" ng-show="provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="busy" placeholder="Directory for backups" ng-required="provider === 'filesystem'">
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="busy" placeholder="URL" ng-required="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 'cloudflare-r2' || provider === 's3-v4-compat' || provider === 'idrive-e2'">
</div>
<div class="checkbox" ng-show="provider === 'minio' || provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">
Accept Self-signed certificate
</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': error.bucket }" ng-show="s3like(provider) || provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.prefix }" ng-show="provider !== 'filesystem' && provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="busy" ng-required="provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupS3V4CompatRegion">Region</label>
<input class="form-control" type="text" name="region" id="inputConfigureBackupS3V4CompatRegion" ng-model="region" ng-disabled="busy" placeholder="Leave empty to use us-east-1 as default"></input>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="busy" ng-required="provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="busy" ng-required="provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="busy" ng-required="provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="busy" ng-required="provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="busy" ng-required="provider === 'linode-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ovh-objectstorage'">
<label class="control-label" for="inputConfigureBackupOvhRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupOvhRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ovhRegions" ng-disabled="busy" ng-required="provider === 'ovh-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'ionos-objectstorage'">
<label class="control-label" for="inputConfigureBackupIonosRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupIonosRegion" ng-model="endpoint" ng-options="a.value as a.name for a in ionosRegions" ng-disabled="busy" ng-required="provider === 'ionos-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'vultr-objectstorage'">
<label class="control-label" for="inputConfigureBackupVultrRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupVultrRegion" ng-model="endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="busy" ng-required="provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.region }" ng-show="provider === 'contabo-objectstorage'">
<label class="control-label" for="inputConfigureBackupContaboRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupContaboRegion" ng-model="endpoint" ng-options="a.value as a.name for a in contaboRegions" ng-disabled="busy" ng-required="provider === 'contabo-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.accessKeyId }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">Access key id</label>
<input type="text" class="form-control" ng-model="accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.secretAccessKey }" ng-show="s3like(provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="busy" ng-required="s3like(provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': error.gcsKeyInput }" ng-show="provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="busy" ng-required="provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="storageFormat">Storage Format</label>
<select class="form-control" id="storageFormat" ng-change="key = ''" ng-model="format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
<label class="control-label" for="inputConfigureRemotePath">Backup Path<sup><a ng-href="https://docs.cloudron.io/backups/#restore-cloudron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="e.g. 2024-02-20-130007-637/box_v7.4.3.tar.gz" required ng-disabled="busy">
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }">
<label class="control-label" for="inputConfigureBackupPassword">Encryption password <span ng-hide="encrypted">(optional)</span></label>
<input type="text" class="form-control" ng-model="password" id="inputConfigureBackupPassword" name="prefix" ng-disabled="busy" placeholder="Passphrase used to encrypt the backups" ng-required="encrypted">
</div>
<div class="checkbox" ng-show="format === 'rsync' && password.length !== 0">
<label>
<input type="checkbox" ng-model="encryptedFilenames">Decrypt Filenames</input>
</label>
</div>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="skipDnsSetup"><b>Dry run</b></sup>
</label>
<br/>
<small>When enabled, apps are restored but the DNS records are not updated to point to this server. To access the dashboard, this browser's host must have an entry in <code>/etc/hosts</code> for the dashboard domain to this server's IP.
See the <a href="https://docs.cloudron.io/backups/#dry-run" target="_blank">docs</a> for more information.</small>
</div>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
<div uib-collapse="!advancedVisible">
<!-- IPv4 provider -->
<div class="form-group">
<label class="control-label">IPv4 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv4Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv4 Fixed -->
<div class="form-group" ng-show="ipv4Config.provider === 'fixed'">
<label class="control-label">IPv4 Address</label>
<input type="text" class="form-control" ng-model="ipv4Config.ip" name="ipv4" ng-required="ipv4Config.provider === 'fixed'">
</div>
<!-- IPv4 Network Interface -->
<div class="form-group" ng-show="ipv4Config.provider === 'network-interface'">
<label class="control-label">IPv4 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv4Config.ifname" name="ifname4" ng-required="ipv4Config.provider === 'network-interface'">
</div>
<!-- IPv6 provider -->
<div class="form-group">
<label class="control-label">IPv6 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv6Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv6 Fixed -->
<div class="form-group" ng-show="ipv6Config.provider === 'fixed'">
<label class="control-label">IPv6 Address</label>
<input type="text" class="form-control" ng-model="ipv6Config.ip" name="ipv6" ng-required="ipv6Config.provider === 'fixed'">
</div>
<!-- IPv6 Network Interface -->
<div class="form-group" ng-show="ipv6Config.provider === 'network-interface'">
<label class="control-label">IPv6 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv6Config.ifname" name="ifname6" ng-required="ipv6Config.provider === 'network-interface'">
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="configureBackupForm.$invalid"/><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> Restore</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
+346 -108
View File
@@ -1,152 +1,390 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Setup</title>
<meta name="description" content="Cloudron Setup">
<title>Cloudron Domain Setup</title>
<meta name="description" content="Cloudron Domain Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setup.js"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setup.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupController">
<body class="setup" ng-app="Application" ng-controller="SetupDNSController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://docs.cloudron.io/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<div class="main-container" ng-show="initialized">
<div class="row" ng-show="view === 'owner'">
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup'">
<div class="row">
<div class="col-md-6 col-md-offset-3 text-center">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard.<br/>
You can follow the logs on the server at <code class="clipboard hand" data-clipboard-text="/home/yellowtent/platformdata/logs/box.log" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">/home/yellowtent/platformdata/logs/box.log</code>
</p>
<br/>
<br/>
<p ng-show="taskMinutesActive >= 4">
If setup appears stuck, it can be restarted by running <code class="clipboard hand" data-clipboard-text="systemctl restart box" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">systemctl restart box</code> and reloading this page.
</p>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="state === 'initialized'">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form role="form" name="ownerForm" ng-submit="owner.submit()" novalidate>
<form name="dnsCredentialsForm" role="form" novalidate ng-submit="setDnsCredentials()" autocomplete="off">
<div class="row">
<div class="col-md-12 text-center">
<h1>Welcome to Cloudron</h1>
<h3>Set up Admin Account</h3>
<p class="has-error text-center" ng-show="owner.error.generic">{{ owner.error.generic }}</p>
<div class="col-md-10 col-md-offset-1 text-center">
<h1>Domain Setup</h1>
<p class="has-error text-center" ng-show="error.setup">{{ error.setup }}</p>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="form-group" ng-class="{ 'has-error': ownerForm.displayName.$dirty && ownerForm.displayName.$invalid }">
<label class="control-label" for="inputDisplayName">Full Name</label>
<input type="text" class="form-control" ng-model="owner.displayName" id="inputDisplayName" name="displayName" placeholder="Full Name" required autocomplete="off" ng-disabled="owner.busy" autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.email.$dirty && ownerForm.email.$invalid) || (!ownerForm.email.$dirty && owner.error.email) }">
<label class="control-label" for="inputEmail">Email <sup><a ng-href="https://docs.cloudron.io/installation/#admin-account" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="email" class="form-control" ng-model="owner.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" ng-disabled="owner.busy">
<small>A valid email is required for Let's Encrypt certificates. This email is local to your Cloudron. </small>
</div>
<div class="form-group" ng-class="{ 'has-error': (ownerForm.username.$dirty && ownerForm.username.$invalid) || (!ownerForm.username.$dirty && owner.error.username) }">
<label class="control-label" for="inputUsername">Username</label>
<input type="text" class="form-control" ng-model="owner.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="1" required autocomplete="off" ng-disabled="owner.busy">
<small>{{ owner.error.username }}</small>
</div>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': ownerForm.password.$dirty && ownerForm.password.$invalid }">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" ng-model="owner.password" id="inputPassword" name="password" placeholder="Password" ng-pattern="/^.{8,}$/" required autocomplete="off" ng-disabled="owner.busy" password-reveal>
<small><span ng-show="ownerForm.password.$dirty && ownerForm.password.$invalid">Password must be at least 8 characters</span> &nbsp;</small>
<div class="col-md-10 col-md-offset-1">
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.domain.$dirty && dnsCredentialsForm.domain.$invalid }">
<label class="control-label">Domain <sup><a ng-href="https://docs.cloudron.io/installation/#domain-setup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
<div class="text-danger" ng-show="dnsCredentials.domain.indexOf('my.') === 0 && dnsCredentials.domain.length > 3">Are you sure about this domain? The dashboard will be at <b>my.{{ dnsCredentials.domain }}</b></div>
<p style="margin-top: 5px; font-size: 13px;">
Apps will be installed on subdomains of this domain. The dashboard will be available on the <b>my</b> subdomain. You can add more domains later.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<h3 class="text-center">Domain Configuration <sup><a ng-href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup> </h3>
<p class="has-error text-center" ng-show="error.dnsCredentials">{{ error.dnsCredentials }}</p>
<div class="form-group">
<label class="control-label">DNS Provider</label>
<select class="form-control" ng-model="dnsCredentials.provider" ng-options="a.value as a.name for a in dnsProvider" ng-disabled="dnsCredentials.busy" ng-change="setDefaultTlsProvider()"></select>
</div>
<!-- Route53 -->
<div ng-if="provider === 'ami'" ng-show="dnsCredentials.provider === 'route53'">
<b class="has-error">This feature is disabled in AWS Marketplace AMI. <a href="https://docs.aws.amazon.com/marketplace/latest/userguide/product-and-ami-policies.html" target="_blank">AWS Marketplace Policy</a> disallows
AMIs from requesting IAM credentials from users to access Route53 hosted domains. Please use the Wildcard or Manual provider instead.</b>
</div>
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="dnsCredentials.accessKeyId" name="accessKeyId" placeholder="Access Key Id" ng-minlength="16" ng-maxlength="32" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<div ng-if="provider !== 'ami'" class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'gcdns'">
<label class="control-label">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="dnsCredentials.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-required="dnsCredentials.provider === 'gcdns'" ng-disabled="dnsCredentials.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.digitalOceanToken.$dirty && dnsCredentialsForm.digitalOceanToken.$invalid }" ng-show="dnsCredentials.provider === 'digitalocean'">
<label class="control-label">DigitalOcean Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" name="digitalOceanToken" placeholder="API Token" ng-required="dnsCredentials.provider === 'digitalocean'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.gandiApiKey.$dirty && dnsCredentialsForm.gandiApiKey.$invalid }" ng-show="dnsCredentials.provider === 'gandi'">
<label class="control-label">Gandi API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.gandiApiKey" name="gandiApiKey" placeholder="API Key" ng-required="dnsCredentials.provider === 'gandi'" ng-disabled="dnsCredentials.busy">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiKey.$dirty && dnsCredentialsForm.godaddyApiKey.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiKey" name="godaddyApiKey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiSecret.$dirty && dnsCredentialsForm.godaddyApiSecret.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiSecret" name="godaddyApiSecret" placeholder="API Secret" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Netcup -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupCustomerNumber.$dirty && dnsCredentialsForm.netcupCustomerNumber.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">Customer Number</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupCustomerNumber" name="netcupCustomerNumber" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiKey.$dirty && dnsCredentialsForm.netcupApiKey.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiKey" name="netcupApiKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiPassword.$dirty && dnsCredentialsForm.netcupApiPassword.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Password</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiPassword" name="netcupApiPassword" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label">Token Type</label>
<select class="form-control" ng-model="dnsCredentials.cloudflareTokenType">
<option value="GlobalApiKey">Global API Key</option>
<option value="ApiToken">API Token</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">Global API Key</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="dnsCredentials.provider === 'cloudflare'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareEmail.$dirty && dnsCredentialsForm.cloudflareEmail.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">Cloudflare Email</label>
<input type="email" class="form-control" ng-model="dnsCredentials.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="dnsCredentials.busy">
</div>
<div class="checkbox" ng-show="dnsCredentials.provider === 'cloudflare'">
<label>
<input type="checkbox" ng-model="dnsCredentials.cloudflareDefaultProxyStatus"> Enable proxying for new DNS records
<sup><a ng-href="https://docs.cloudron.io/domains/#cloudflare-dns" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComUsername.$dirty && dnsCredentialsForm.nameComUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">Name.com Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComUsername" name="nameComUsername" placeholder="Name.com Username" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComToken.$dirty && dnsCredentialsForm.nameComToken.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComToken" name="nameComToken" placeholder="Name.com API Token" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapUsername.$dirty && dnsCredentialsForm.namecheapUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">Namecheap Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapUsername" name="namecheapUsername" placeholder="Namecheap Username" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapApiKey.$dirty && dnsCredentialsForm.namecheapApiKey.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">API Key</label>
<p class="small text-info" ng-show="dnsCredentials.provider === 'namecheap'"><b>The server IP needs to be whitelisted for this API Key.</b></p>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapApiKey" name="namecheapApiKey" placeholder="Namecheap API Key" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Linode -->
<p class="form-group" ng-show="dnsCredentials.provider === 'linode'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.linodeToken" name="linodeToken" ng-required="dnsCredentials.provider === 'linode'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Bunny -->
<p class="form-group" ng-show="dnsCredentials.provider === 'bunny'">
<label class="control-label">Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.bunnyAccessKey" name="bunnyAccessKey" ng-required="dnsCredentials.provider === 'bunny'" ng-disabled="dnsCredentials.busy">
</p>
<!-- dnsimple -->
<p class="form-group" ng-show="dnsCredentials.provider === 'dnsimple'">
<label class="control-label">Access Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-required="dnsCredentials.provider === 'dnsimple'" ng-disabled="dnsCredentials.busy">
</p>
<!-- OVH -->
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">Endpoint</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="dnsCredentials.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'"></select>
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Consumer Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppKey" name="ovhAppKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<p class="form-group" ng-show="dnsCredentials.provider === 'ovh'">
<label class="control-label">Application Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.ovhAppSecret" name="ovhAppSecret" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'ovh'">
</p>
<!-- Porkbun -->
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunApikey" name="porkbunApikey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<p class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Hetzner -->
<p class="form-group" ng-show="dnsCredentials.provider === 'hetzner'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.hetznerToken" name="hetznerToken" ng-required="dnsCredentials.provider === 'hetzner'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Vultr -->
<p class="form-group" ng-show="dnsCredentials.provider === 'vultr'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.vultrToken" name="vultrToken" ng-required="dnsCredentials.provider === 'vultr'" ng-disabled="dnsCredentials.busy">
</p>
<!-- deSEC -->
<p class="form-group" ng-show="dnsCredentials.provider === 'desec'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.deSecToken" name="deSecToken" ng-required="dnsCredentials.provider === 'desec'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Wildcard -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'wildcard'">
<span>Set up A records for <b>*.{{ dnsCredentials.domain || 'example.com' }}.</b> and <b>{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.</span>
</p>
<!-- Manual -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'manual'">
<span>Set up an A record for <b>my.{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.<br/></span>
</p>
<p class="small text-info" ng-show="needsPort80(dnsCredentials.provider, dnsCredentials.tlsConfig.provider)">Let's Encrypt requires your server to be reachable on port 80</p>
<div ng-show="provider === 'ami'">
<h3 class="text-center">Owner verification</h3>
<p class="has-error text-center" ng-show="error.ami">{{ error.ami }}</p>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.instanceId.$dirty && (dnsCredentialsForm.instanceId.$invalid || error.ami) }">
<label class="control-label">EC2 Instance Id</label>
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="i-0123456789abcdefg" ng-minlength="1" ng-required="provider === 'ami'" autocomplete="off">
</div>
<p style="margin-top: 5px; font-size: 13px;">Provide the EC2 instance id to verify you have access to this server.</p>
</div>
<br/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">DNS Zone Name (Optional) <sup><a ng-href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.zoneName" name="zoneName" placeholder="Defaults to TLD" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group">
<label class="control-label">Certificate Provider <sup><a ng-href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="dnsCredentials.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider" ng-disabled="dnsCredentials.busy"></select>
</div>
<!-- IPv4 provider -->
<div class="form-group">
<label class="control-label">IPv4 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv4Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv4 Fixed -->
<div class="form-group" ng-show="ipv4Config.provider === 'fixed'">
<label class="control-label">IPv4 Address</label>
<input type="text" class="form-control" ng-model="ipv4Config.ip" name="ipv4" ng-required="ipv4Config.provider === 'fixed'">
</div>
<!-- IPv4 Network Interface -->
<div class="form-group" ng-show="ipv4Config.provider === 'network-interface'">
<label class="control-label">IPv4 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv4Config.ifname" name="ifname4" ng-required="ipv4Config.provider === 'network-interface'">
</div>
<!-- IPv6 provider -->
<div class="form-group">
<label class="control-label">IPv6 Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv6Config.provider" ng-options="a.value as a.name for a in ipProviders"></select>
</div>
<!-- IPv6 Fixed -->
<div class="form-group" ng-show="ipv6Config.provider === 'fixed'">
<label class="control-label">IPv6 Address</label>
<input type="text" class="form-control" ng-model="ipv6Config.ip" name="ipv6" ng-required="ipv6Config.provider === 'fixed'">
</div>
<!-- IPv6 Network Interface -->
<div class="form-group" ng-show="ipv6Config.provider === 'network-interface'">
<label class="control-label">IPv6 Interface Name</label>
<input type="text" class="form-control" ng-model="ipv6Config.ifname" name="ifname6" ng-required="ipv6Config.provider === 'network-interface'">
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-success" ng-disabled="ownerForm.$invalid || owner.busy"><i class="fa fa-circle-notch fa-spin" ng-show="owner.busy"></i> Create Admin</button>
<button type="submit" class="btn btn-primary" ng-disabled="dnsCredentialsForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="dnsCredentials.busy"></i> Next</button>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center"><small>Looking to <a ng-href="/restore.html{{ search }}">restore?</a></small></div>
</div>
</form>
</div>
</div>
</div>
<div class="row" ng-show="view === 'finished'">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px 40px;">
<div class="row">
<div class="col-md-12 text-center">
<h1>Cloudron is ready to use</h1>
</div>
</div>
<p>
&nbsp; &nbsp; Before you start:
<ul class="fa-ul">
<li><i class="fa-li fa fa-users"></i>
<b>User management</b>: Cloudron has a central user directory. When installing an app,
you can set it up to authenticate against this directory.
</li>
<br/>
<li><i class="fa-li fa fa-envelope-open"></i>
<b>Email Configuration</b>: Apps are configured to send email based on the settings in the Email view.
This saves you the trouble of having to configure mail settings inside each app.
</li>
<br/>
<li><i class="fa-li fa fa-archive"></i>
<b>Backups</b>: Store your backups on storage services completely independent from your server.
You can use backups to seamlessly migrate your setup to another server.
</li>
<br/>
<li><i class="fa-li fa fa-birthday-cake"></i>
<b>Updates</b>: The Cloudron team tracks upstream releases and publishes app updates after testing.
Your apps are kept fresh &amp; secure.
</li>
</ul>
</p>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<a class="btn btn-success" href="/">Proceed to Dashboard</a>
</div>
</div>
</div>
</div>
</div>
</div>
<footer class="text-center">
<footer class="text-center" ng-show="state === 'waitingForDnsSetup' || state === 'initialized'">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
+4 -4
View File
@@ -14,7 +14,7 @@
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
@@ -58,7 +58,7 @@
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= apiOrigin %>/api/v1/cloudron/avatar"/>
<br/>
<h1><small>{{ 'setupAccount.welcomeTo' | tr }}</small> {{ status.cloudronName || 'Cloudron' }}</h1>
<h1><small>{{ 'setupAccount.welcomeTo' | tr }}</small> {{ branding.cloudronName || 'Cloudron' }}</h1>
<h3>{{ 'setupAccount.description' | tr }}</h3>
</div>
</div>
@@ -147,14 +147,14 @@
<br/>
<h2>{{ 'setupAccount.success.title' | tr }}</h2>
<br/>
<a href="/" class="btn btn-primary">{{ 'setupAccount.success.openDashboardAction' | tr }}</a>
<a ng-href="dashboardUrl" class="btn btn-primary">{{ 'setupAccount.success.openDashboardAction' | tr }}</a>
</div>
</div>
</div>
</div>
<footer class="text-center ng-cloak">
<span class="text-muted" ng-bind-html="status.footer | markdown2html"></span>
<span class="text-muted" ng-bind-html="branding.footer | markdown2html"></span>
</footer>
</div>
-335
View File
@@ -1,335 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Domain Setup</title>
<meta name="description" content="Cloudron Domain Setup">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.js"></script>
<!-- Angular directives for tldjs -->
<script type="text/javascript" src="/3rdparty/js/tld.js"></script>
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Setup Application -->
<script type="text/javascript" src="/js/setupdns.js"></script>
</head>
<body class="setup" ng-app="Application" ng-controller="SetupDNSController">
<div class="main-container ng-cloak text-center" ng-show="state === 'waitingForDnsSetup' || state === 'waitingForBox'">
<div class="row">
<div class="col-md-6 col-md-offset-3 text-center">
<i class="fa fa-circle-notch fa-spin fa-5x"></i><br/>
<h3>{{ message }} ...</h3>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard at my.{{dnsCredentials.domain}}.<br/>
You can follow the logs on the server at <code class="clipboard hand" data-clipboard-text="/home/yellowtent/platformdata/logs/box.log" uib-tooltip="{{ clipboardDone ? 'Copied' : 'Click to copy' }}" tooltip-placement="right">/home/yellowtent/platformdata/logs/box.log</code>
</p>
</div>
</div>
</div>
<div class="main-container ng-cloak" ng-show="state === 'initialized'">
<div class="row">
<div class="col-md-6 col-md-offset-3">
<div class="card" style="max-width: none; padding: 20px;">
<form name="dnsCredentialsForm" role="form" novalidate ng-submit="setDnsCredentials()" autocomplete="off">
<div class="row">
<div class="col-md-10 col-md-offset-1 text-center">
<h1>Domain Setup</h1>
<p class="has-error text-center" ng-show="error.setup">{{ error.setup }}</p>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.domain.$dirty && dnsCredentialsForm.domain.$invalid }">
<label class="control-label">Domain <sup><a ng-href="https://docs.cloudron.io/installation/#domain-setup" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.domain" name="domain" placeholder="example.com" required autofocus ng-disabled="dnsCredentials.busy">
<div class="text-danger" ng-show="dnsCredentials.domain.indexOf('my.') === 0 && dnsCredentials.domain.length > 3">Are you sure about this domain? The dashboard will be at <b>my.{{ dnsCredentials.domain }}</b></div>
<p style="margin-top: 5px; font-size: 13px;">
Apps will be installed on subdomains of this domain. The dashboard will be available on the <b>my</b> subdomain. You can add more domains later.
</p>
</div>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<h3 class="text-center">Domain Configuration <sup><a ng-href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup> </h3>
<p class="has-error text-center" ng-show="error.dnsCredentials">{{ error.dnsCredentials }}</p>
<div class="form-group">
<label class="control-label">DNS Provider</label>
<select class="form-control" ng-model="dnsCredentials.provider" ng-options="a.value as a.name for a in dnsProvider" ng-disabled="dnsCredentials.busy" ng-change="setDefaultTlsProvider()"></select>
</div>
<!-- Route53 -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.accessKeyId.$dirty && dnsCredentialsForm.accessKeyId.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="dnsCredentials.accessKeyId" name="accessKeyId" placeholder="Access Key Id" ng-minlength="16" ng-maxlength="32" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.secretAccessKey.$dirty && dnsCredentialsForm.secretAccessKey.$invalid }" ng-show="dnsCredentials.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.secretAccessKey" name="secretAccessKey" placeholder="Secret Access Key" ng-required="dnsCredentials.provider === 'route53'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="dnsCredentials.provider === 'gcdns'">
<label class="control-label">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="dnsCredentials.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-required="dnsCredentials.provider === 'gcdns'" ng-disabled="dnsCredentials.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.digitalOceanToken.$dirty && dnsCredentialsForm.digitalOceanToken.$invalid }" ng-show="dnsCredentials.provider === 'digitalocean'">
<label class="control-label">DigitalOcean Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.digitalOceanToken" name="digitalOceanToken" placeholder="API Token" ng-required="dnsCredentials.provider === 'digitalocean'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.gandiApiKey.$dirty && dnsCredentialsForm.gandiApiKey.$invalid }" ng-show="dnsCredentials.provider === 'gandi'">
<label class="control-label">Gandi API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.gandiApiKey" name="gandiApiKey" placeholder="API Key" ng-required="dnsCredentials.provider === 'gandi'" ng-disabled="dnsCredentials.busy">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiKey.$dirty && dnsCredentialsForm.godaddyApiKey.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiKey" name="godaddyApiKey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.godaddyApiSecret.$dirty && dnsCredentialsForm.godaddyApiSecret.$invalid }" ng-show="dnsCredentials.provider === 'godaddy'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.godaddyApiSecret" name="godaddyApiSecret" placeholder="API Secret" ng-required="dnsCredentials.provider === 'godaddy'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Netcup -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupCustomerNumber.$dirty && dnsCredentialsForm.netcupCustomerNumber.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">Customer Number</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupCustomerNumber" name="netcupCustomerNumber" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiKey.$dirty && dnsCredentialsForm.netcupApiKey.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiKey" name="netcupApiKey" ng-disabled="dnsCredentials.busy" ng-minlength="1" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.netcupApiPassword.$dirty && dnsCredentialsForm.netcupApiPassword.$invalid }" ng-show="dnsCredentials.provider === 'netcup'">
<label class="control-label">API Password</label>
<input type="text" class="form-control" ng-model="dnsCredentials.netcupApiPassword" name="netcupApiPassword" ng-disabled="dnsCredentials.busy" ng-required="dnsCredentials.provider === 'netcup'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label">Token Type</label>
<select class="form-control" ng-model="dnsCredentials.cloudflareTokenType">
<option value="GlobalApiKey">Global API Key</option>
<option value="ApiToken">API Token</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareToken.$dirty && dnsCredentialsForm.cloudflareToken.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare'">
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">Global API Key</label>
<label class="control-label" ng-show="dnsCredentials.cloudflareTokenType === 'ApiToken'">Api Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="dnsCredentials.provider === 'cloudflare'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.cloudflareEmail.$dirty && dnsCredentialsForm.cloudflareEmail.$invalid }" ng-show="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">Cloudflare Email</label>
<input type="email" class="form-control" ng-model="dnsCredentials.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="dnsCredentials.provider === 'cloudflare' && dnsCredentials.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="dnsCredentials.busy">
</div>
<div class="checkbox" ng-show="dnsCredentials.provider === 'cloudflare'">
<label>
<input type="checkbox" ng-model="dnsCredentials.cloudflareDefaultProxyStatus"> Enable proxying for new DNS records
<sup><a ng-href="https://docs.cloudron.io/domains/#cloudflare-dns" class="help" target="_blank" tabIndex="-1"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComUsername.$dirty && dnsCredentialsForm.nameComUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">Name.com Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComUsername" name="nameComUsername" placeholder="Name.com Username" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.nameComToken.$dirty && dnsCredentialsForm.nameComToken.$invalid }" ng-show="dnsCredentials.provider === 'namecom'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.nameComToken" name="nameComToken" placeholder="Name.com API Token" ng-required="dnsCredentials.provider === 'namecom'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapUsername.$dirty && dnsCredentialsForm.namecheapUsername.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">Namecheap Username</label>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapUsername" name="namecheapUsername" placeholder="Namecheap Username" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.namecheapApiKey.$dirty && dnsCredentialsForm.namecheapApiKey.$invalid }" ng-show="dnsCredentials.provider === 'namecheap'">
<label class="control-label">API Key</label>
<p class="small text-info" ng-show="dnsCredentials.provider === 'namecheap'"><b>The server IP needs to be whitelisted for this API Key.</b></p>
<input type="text" class="form-control" ng-model="dnsCredentials.namecheapApiKey" name="namecheapApiKey" placeholder="Namecheap API Key" ng-required="dnsCredentials.provider === 'namecheap'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Linode -->
<p class="form-group" ng-show="dnsCredentials.provider === 'linode'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.linodeToken" name="linodeToken" ng-required="dnsCredentials.provider === 'linode'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Bunny -->
<p class="form-group" ng-show="dnsCredentials.provider === 'bunny'">
<label class="control-label">Access Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.bunnyAccessKey" name="bunnyAccessKey" ng-required="dnsCredentials.provider === 'bunny'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Porkbun -->
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunApikey.$dirty && dnsCredentialsForm.porkbunApikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunApikey" name="porkbunApikey" placeholder="API Key" ng-minlength="1" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': dnsCredentialsForm.porkbunSecretapikey.$dirty && dnsCredentialsForm.porkbunSecretapikey.$invalid }" ng-show="dnsCredentials.provider === 'porkbun'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="dnsCredentials.porkbunSecretapikey" name="porkbunSecretapikey" placeholder="API Secret" ng-required="dnsCredentials.provider === 'porkbun'" ng-disabled="dnsCredentials.busy">
</div>
<!-- Hetzner -->
<p class="form-group" ng-show="dnsCredentials.provider === 'hetzner'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.hetznerToken" name="hetznerToken" ng-required="dnsCredentials.provider === 'hetzner'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Vultr -->
<p class="form-group" ng-show="dnsCredentials.provider === 'vultr'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="dnsCredentials.vultrToken" name="vultrToken" ng-required="dnsCredentials.provider === 'vultr'" ng-disabled="dnsCredentials.busy">
</p>
<!-- Wildcard -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'wildcard'">
<span>Set up A records for <b>*.{{ dnsCredentials.domain || 'example.com' }}.</b> and <b>{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.</span>
</p>
<!-- Manual -->
<p class="small text-info" ng-show="dnsCredentials.provider === 'manual'">
<span>Set up an A record for <b>my.{{ dnsCredentials.domain || 'example.com' }}.</b> to this server's IP.<br/></span>
</p>
<p class="small text-info" ng-show="needsPort80(dnsCredentials.provider, dnsCredentials.tlsConfig.provider)">Let's Encrypt requires your server to be reachable on port 80</p>
<div ng-show="provider === 'ami'">
<h3 class="text-center">Owner verification</h3>
<p class="has-error text-center" ng-show="error.ami">{{ error.ami }}</p>
<div class="form-group" style="margin-bottom: 0;" ng-class="{ 'has-error': dnsCredentialsForm.instanceId.$dirty && (dnsCredentialsForm.instanceId.$invalid || error.ami) }">
<label class="control-label">EC2 Instance Id</label>
<input type="text" class="form-control" ng-model="instanceId" id="inputInstanceId" name="instanceId" placeholder="i-0123456789abcdefg" ng-minlength="1" ng-required="provider === 'ami'" autocomplete="off">
</div>
<p style="margin-top: 5px; font-size: 13px;">Provide the EC2 instance id to verify you have access to this server.</p>
</div>
<br/>
<div uib-collapse="!advancedVisible">
<div class="form-group">
<label class="control-label">Zone Name (Optional) <sup><a ng-href="https://docs.cloudron.io/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="dnsCredentials.zoneName" name="zoneName" placeholder="{{dnsCredentials.domain | zoneName}}" ng-disabled="dnsCredentials.busy">
</div>
<div class="form-group">
<label class="control-label">Certificate Provider <sup><a ng-href="https://docs.cloudron.io/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="dnsCredentials.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider" ng-disabled="dnsCredentials.busy"></select>
</div>
<div class="form-group">
<label class="control-label">IP Configuration <sup><a ng-href="https://docs.cloudron.io/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="sysinfo.provider" ng-options="a.value as a.name for a in sysinfoProvider"></select>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="sysinfo.provider === 'fixed'" ng-class="{ 'has-error': error.ipv4 }">
<label class="control-label">IP Address</label>
<input type="text" class="form-control" ng-model="sysinfo.ipv4" name="ipv4" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'fixed'">
<p class="has-error" ng-show="error.ipv4">{{ error.ipv4 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="sysinfo.provider === 'network-interface'" ng-class="{ 'has-error': error.ifname }">
<label class="control-label">Interface Name</label>
<input type="text" class="form-control" ng-model="sysinfo.ifname" name="ifname" ng-disabled="sysinfo.busy" ng-required="sysinfo.provider === 'network-interface'">
<p class="has-error" ng-show="error.ifname">{{ error.ifname }}</p>
</div>
</div>
<div class="text-center">
<a href="" ng-click="advancedVisible = true" ng-hide="advancedVisible">Advanced settings...</a>
<a href="" ng-click="advancedVisible = false" ng-show="advancedVisible">Hide Advanced settings</a>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<button type="submit" class="btn btn-primary" ng-disabled="dnsCredentialsForm.$invalid"><i class="fa fa-circle-notch fa-spin" ng-show="dnsCredentials.busy"></i> Next</button>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center"><small>Looking to <a ng-href="/restore.html{{ search }}">restore?</a></small></div>
</div>
</form>
</div>
</div>
</div>
</div>
<footer class="text-center">
<span class="text-muted">&copy;2022 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<span class="text-muted"><a href="https://forum.cloudron.io" target="_blank">Forum <i class="fa fa-comments"></i></a></span>
</footer>
</body>
</html>
-120
View File
@@ -1,120 +0,0 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title> Login to <%= title %> </title>
<link href="<%= icon %>" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="<%= dashboardOrigin %>/theme.css">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="<%= dashboardOrigin %>/3rdparty/fontawesome/css/all.css"/>
</head>
<body>
<div class="layout-root">
<div class="layout-content">
<div class="card" style="padding: 20px; margin-top: 100px; max-width: 620px;">
<div class="row">
<div class="col-md-12" style="text-align: center;">
<img width="128" height="128" style="margin-top: -84px" src="<%= icon %>"/>
<br/>
<h1><small>{{ login.loginTo }}</small> <%= title %></h1>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4 class="has-error" id="message"></h4>
</div>
</div>
<div class="row">
<div class="col-md-12">
<form name="loginForm" onsubmit="return onLogin(event)">
<div class="form-group">
<label class="control-label" for="inputUsername">{{ login.username }}</label>
<input type="text" class="form-control" id="inputUsername" name="username" autofocus required>
</div>
<div class="form-group">
<label class="control-label" for="inputPassword">{{ login.password }}</label>
<input type="password" class="form-control" name="password" id="inputPassword" required>
</div>
<div class="form-group">
<label class="control-label" for="inputTotpToken">{{ login.2faToken }}</label>
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" value="">
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit" id="login">{{ login.signInAction }}</button>
</form>
<!-- <a ng-href="" class="hand" ng-click="showPasswordReset()">Reset password</a> -->
</div>
</div>
</div>
</div>
<script>
function onLogin(event) {
event.preventDefault();
var username = document.getElementById('inputUsername').value;
var password = document.getElementById('inputPassword').value;
var totpToken = document.getElementById('inputTotpToken').value;
fetch('/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
redirect: 'manual',
body: JSON.stringify({ username: username, password: password, totpToken: totpToken })
}).then(function (response) {
if (response.status === 401 || response.status === 403) {
document.getElementById('message').innerText = "{{ login.errorIncorrectCredentials }}"; // FIXME this needs proper escaping for translated strings, single quotes break easily!
return;
}
var search = decodeURIComponent(window.location.search).slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
window.location.href = search.redirect || '/';
});
return false;
}
// patch up for password reveal see dashboard/js/utils.js
var element = document.getElementById('inputPassword');
var svgEye = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye" class="svg-inline--fa fa-eye fa-w-18" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 576 512"><path fill="currentColor" d="M572.52 241.4C518.29 135.59 410.93 64 288 64S57.68 135.64 3.48 241.41a32.35 32.35 0 0 0 0 29.19C57.71 376.41 165.07 448 288 448s230.32-71.64 284.52-177.41a32.35 32.35 0 0 0 0-29.19zM288 400a144 144 0 1 1 144-144 143.93 143.93 0 0 1-144 144zm0-240a95.31 95.31 0 0 0-25.31 3.79 47.85 47.85 0 0 1-66.9 66.9A95.78 95.78 0 1 0 288 160z"></path></svg>';
var svgEyeSlash = '<svg aria-hidden="true" focusable="false" data-prefix="fas" data-icon="eye-slash" class="svg-inline--fa fa-eye-slash fa-w-20" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M320 400c-75.85 0-137.25-58.71-142.9-133.11L72.2 185.82c-13.79 17.3-26.48 35.59-36.72 55.59a32.35 32.35 0 0 0 0 29.19C89.71 376.41 197.07 448 320 448c26.91 0 52.87-4 77.89-10.46L346 397.39a144.13 144.13 0 0 1-26 2.61zm313.82 58.1l-110.55-85.44a331.25 331.25 0 0 0 81.25-102.07 32.35 32.35 0 0 0 0-29.19C550.29 135.59 442.93 64 320 64a308.15 308.15 0 0 0-147.32 37.7L45.46 3.37A16 16 0 0 0 23 6.18L3.37 31.45A16 16 0 0 0 6.18 53.9l588.36 454.73a16 16 0 0 0 22.46-2.81l19.64-25.27a16 16 0 0 0-2.82-22.45zm-183.72-142l-39.3-30.38A94.75 94.75 0 0 0 416 256a94.76 94.76 0 0 0-121.31-92.21A47.65 47.65 0 0 1 304 192a46.64 46.64 0 0 1-1.54 10l-73.61-56.89A142.31 142.31 0 0 1 320 112a143.92 143.92 0 0 1 144 144c0 21.63-5.29 41.79-13.9 60.11z"></path></svg>';
var eye = document.createElement('i');
eye.innerHTML = svgEyeSlash;
eye.style.width = '18px';
eye.style.height = '18px';
eye.style.position = 'relative';
eye.style.float = 'right';
eye.style.marginTop = '-24px';
eye.style.marginRight = '10px';
eye.style.cursor = 'pointer';
eye.addEventListener('click', function () {
if (element.type === 'password') {
element.type = 'text';
eye.innerHTML = svgEye;
} else {
element.type = 'password';
eye.innerHTML = svgEyeSlash;
}
});
element.parentNode.style.position = 'relative';
element.parentNode.insertBefore(eye, element.nextSibling);
</script>
</body>
</html>
-172
View File
@@ -1,172 +0,0 @@
<!DOCTYPE html>
<html ng-app="Application" ng-controller="TerminalController">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<title>Cloudron Terminal</title>
<meta name="description" content="Cloudron Terminal">
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
<link rel="icon" href="/api/v1/cloudron/avatar">
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.css?<%= revision %>"/>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js?<%= revision %>"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js?<%= revision %>"></script>
<!-- Angularjs scripts -->
<script type="text/javascript" src="/3rdparty/js/angular.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-loader.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-animate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-base64.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-sanitize.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js?<%= revision %>"></script>
<!-- Angular directives for bootstrap https://angular-ui.github.io/bootstrap/ -->
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js?<%= revision %>"></script>
<!-- Angular translate https://angular-translate.github.io/ -->
<script type="text/javascript" src="/3rdparty/js/angular-translate.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-loader-static-files.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-cookie.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/angular-translate-storage-local.min.js?<%= revision %>"></script>
<!-- Clipboard handling -->
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js?<%= revision %>"></script>
<!-- xterm -->
<link type="text/css" rel="stylesheet" href="/3rdparty/xterm/css/xterm.css?<%= revision %>" />
<script src="/3rdparty/xterm/lib/xterm.js?<%= revision %>"></script>
<script src="/3rdparty/xterm-addon-attach/lib/xterm-addon-attach.js?<%= revision %>"></script>
<script src="/3rdparty/xterm-addon-fit/lib/xterm-addon-fit.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></script>
<!-- Main Application -->
<script type="text/javascript" src="/js/terminal.js?<%= revision %>"></script>
</head>
<body style="overflow: hidden;">
<!-- Modal download file -->
<div class="modal fade" id="downloadFileModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'terminal.download.title' | tr:{ name: selected.name } }}</h4>
</div>
<div class="modal-body">
<form name="downloadFileForm" ng-submit="downloadFile.submit()">
<div class="form-group" ng-class="{ 'has-error': downloadFileForm.filePath.$dirty && downloadFile.error }">
<label class="control-label" for="inputDownloadFilePath">{{ 'terminal.download.filePath' | tr }}</label>
<div class="control-label" ng-show="{ 'has-error': downloadFileForm.filePath.$dirty && downloadFile.error }">
<small>{{ downloadFile.error }}</small>
</div>
<input type="text" class="form-control" name="filePath" ng-model="downloadFile.filePath" required autofocus>
</div>
<input id="inputDownloadFilePath" class="ng-hide" type="submit" ng-disabled="!downloadFile.filePath"/>
</form>
</div>
<div class="modal-footer">
<a id="fileDownloadLink" class="" ng-href="{{ downloadFile.downloadUrl() }}" target="_blank"></a>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="downloadFile.submit()" ng-disabled="!downloadFile.filePath"><i class="fa fa-circle-notch fa-spin" ng-show="downloadFile.busy"></i> {{ 'terminal.download.download' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal upload progress -->
<div class="modal fade" id="uploadProgressModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'terminal.upload.title' | tr:{ name: selected.name } }}</h4>
</div>
<div class="modal-body">
<span><b>{{ uploadProgress.current | prettyDecimalSize }}</b> (total {{ uploadProgress.total | prettyDecimalSize }})</span>
<div class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ 100*(uploadProgress.current/uploadProgress.total) }}%"></div>
</div>
</div>
<div class="modal-footer"></div>
</div>
</div>
</div>
<div class="animateMe ng-hide layout-root terminal-view" ng-show="initialized">
<div class="terminal-controls">
<h3 style="display: inline-block;">{{ selected.name }}</h3>
<input type="file" id="fileUpload" class="hide"/>
<div class="pull-right">
<div class="btn-group" ng-show="usesAddon('scheduler')">
<button type="button" class="btn btn-success dropdown-toggle" data-toggle="dropdown" ng-disabled="appBusy">
{{ 'terminal.scheduler' | tr }} <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in schedulerTasks"><a href="" ng-click="terminalInject('scheduler', task)">{{ task.name }}</a></li>
</ul>
</div>
<!-- addon actions -->
<button class="btn btn-success" ng-click="terminalInject('mysql')" ng-show="usesAddon('mysql')" ng-disabled="appBusy">MySQL</button>
<button class="btn btn-success" ng-click="terminalInject('postgresql')" ng-show="usesAddon('postgresql')" ng-disabled="appBusy">Postgres</button>
<button class="btn btn-success" ng-click="terminalInject('mongodb')" ng-show="usesAddon('mongodb')" ng-disabled="appBusy">MongoDB</button>
<button class="btn btn-success" ng-click="terminalInject('redis')" ng-show="usesAddon('redis')" ng-disabled="appBusy">Redis</button>
<!-- terminal actions -->
<button class="btn btn-primary" ng-click="restartApp()" ng-show="selected.type === 'app'" ng-disabled="appBusy"><i class="fa fa-sync-alt" ng-class="{ 'fa-spin': restartAppBusy }"></i> {{ 'terminal.restart' | tr }}</button>
<button class="btn btn-primary" ng-click="uploadFile()" ng-show="selected.type === 'app' && !uploadProgress.busy" ng-disabled="appBusy"><i class="fa fa-upload"></i> {{ 'terminal.uploadToTmp' | tr }}</button>
<button class="btn btn-primary" ng-click="uploadProgress.show()" ng-show="uploadProgress.busy" ng-disabled="appBusy"><i class="fa fa-circle-notch fa-spin"></i> {{ 'terminal.uploading' | tr }}</button>
<button class="btn btn-primary" ng-click="downloadFile.show()" ng-show="selected.type === 'app'" ng-disabled="appBusy"><i class="fa fa-download"></i> {{ 'terminal.downloadAction' | tr }}</button>
</div>
</div>
<div class="terminal-container" id="terminalContainer" ng-hide="appBusy"></div>
<div class="terminal-container placeholder" ng-show="appBusy">
<h4>&nbsp;
<span ng-show="restartAppBusy">{{ 'terminal.busy.restarting' | tr }}</span>
<span ng-show="selectedAppInfo.installationState === 'pending_debug' && selectedAppInfo.debugMode">{{ 'terminal.busy.restartingInPausedMode' | tr }}</span>
<span ng-show="selectedAppInfo.installationState === 'pending_debug' && !selectedAppInfo.debugMode ">{{ 'terminal.busy.resuming' | tr }}</span>
<span ng-show="selectedAppInfo.installationState === 'pending_installed'">{{ 'terminal.busy.installing' | tr }}</span>
</h4>
<div class="progress" ng-show="appBusy" style="width: 80%">
<div class="progress-bar progress-bar-striped active" role="progressbar" style="width: 100%"></div>
</div>
</div>
<div class="contextMenuBackdrop">
<ul class="dropdown-menu" id="terminalContextMenu" style="position: absolute; display:none;">
<li><a href="" ng-click="terminalCopy()">{{ 'terminal.contextmenu.copy' | tr }}</a></li>
<li class="disabled"><a>{{ 'terminal.contextmenu.pasteInfo' | tr }}</a></li>
<li role="separator" class="divider"></li>
<li><a href="" ng-click="terminalClear()">{{ 'terminal.contextmenu.clear' | tr }}</a></li>
</ul>
</div>
</div>
</body>
</html>
+237 -76
View File
@@ -9,13 +9,13 @@ $brand-info: #3995b1 !default;
$brand-warning: #f0ad4e !default;
$brand-danger: #ff4c4c !default;
$body-bg: #E5E5E5;
$font-family-sans-serif: Roboto, Helvetica, Arial, sans-serif;
$font-family-heading: Roboto-Light, Helvetica, Arial, sans-serif;
$body-bg: #f4f4f4;
$font-family-sans-serif: "Noto Sans", Helvetica, Arial, sans-serif;
$font-family-heading: "Noto Sans Light", Helvetica, Arial, sans-serif;
$navbar-default-link-color: #428BCA !default;
$navbar-default-link-color: $brand-primary !default;
$navbar-default-link-hover-color: #62bdfc !default;
$navbar-default-link-active-color: #62bdfc !default;
$navbar-default-link-active-color: #428BCA !default;
$navbar-default-brand-color: #777 !default;
$btn-default-bg: transparent !default;
@@ -54,21 +54,28 @@ $state-danger-text: $brand-danger;
$state-danger-border: $brand-danger;
@import "bootstrap";
@font-face {
font-family: Roboto;
src: url(3rdparty/Roboto-Regular.ttf);
}
@font-face {
font-family: Roboto-Light;
src: url(3rdparty/Roboto-Light.ttf);
}
@import "3rdparty/noto-sans/index.css";
// ----------------------------
// Bootstrap extension
// ----------------------------
h1, h2, h3, h4, h5, h6,
.h1, .h2, .h3, .h4, .h5, .h6 {
font-family: $font-family-heading;
font-weight: 400;
}
.hide-mobile {
@media(max-width:767px) {
display: none;
}
}
.table-hover > tbody > tr:hover {
background-color: $body-bg;
}
.text-monospace {
font-family: $font-family-monospace;
}
@@ -105,6 +112,7 @@ $state-danger-border: $brand-danger;
white-space: nowrap;
overflow: hidden;
max-width: 300px;
vertical-align: middle !important;
}
.wrap-table-cell {
@@ -169,16 +177,7 @@ html, body {
}
.view-header-filter-bar {
position: absolute;
right: 14px;
margin-top: 5px;
padding: 5px;
padding-top: 0;
background-color: #fff;
background-clip: padding-box;
border: 1px solid rgba(0,0,0,.15);
border-radius: 2px;
box-shadow: 0 6px 12px rgba(0,0,0,.175);
text-align: right;
}
.view-header-search-bar {
@@ -272,9 +271,11 @@ html, body {
display: block;
width: 100%;
flex-grow: 0;
background-color: white;
border-color: white;
.navbar-collapse {
background-color: #F8F8F8;
background-color: white;
}
@media(min-width:768px) {
@@ -320,6 +321,10 @@ h1, h2, h3 {
z-index: 200;
}
.section-header {
margin-top: 50px;
}
.offscreen {
position: absolute;
left: -999em;
@@ -350,6 +355,51 @@ textarea {
// Apps view
// ----------------------------
.app-list {
width: 100%;
margin-top: 20px !important;
th {
white-space: nowrap;
}
.app-list-item {
.app-list-item-icon {
height: 32px;
}
.app-list-app-link-cell {
padding: 0;
}
.app-list-item-fqdn {
visibility: hidden;
color: $text-muted;
margin-left: 20px;
font-size: 12px;
}
&:hover .app-list-item-fqdn {
visibility: visible;
}
.app-list-app-link {
display: inline-block;
color: $text-dark;
padding: 8px;
&:hover {
text-decoration: none;
}
}
.app-list-item-progress {
height: 5px;
margin: 0 8px;
}
}
}
.app-grid {
display: flex;
flex-wrap: wrap;
@@ -484,6 +534,28 @@ textarea {
}
}
.app-checklist-badge {
display: flex;
justify-content: center;
align-items: center;
position: absolute;
right: -12px;
top: -12px;
z-index: 2;
font-size: 14px;
height: 24px;
width: 24px;
color: white;
cursor: pointer;
background-color: $brand-danger;
border-radius: 34px;
transition: all 100ms ease-out;
&:hover {
transform: scale(1.4);
}
}
.app-postinstall-message {
max-height: 500px;
overflow-x: none;
@@ -496,11 +568,6 @@ textarea {
line-height: 1.4;
}
.app-info-meta {
margin-left: 4px;
color: $text-muted;
}
.app-info-icon {
float: left;
min-height: 64px;
@@ -536,6 +603,7 @@ multiselect {
cursor: pointer;
width: 64px;
height: 64px;
margin-bottom: 5px;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
@@ -602,6 +670,10 @@ multiselect {
padding: 10px;
background-color: white;
@media (prefers-color-scheme: dark) {
background-color: #1c1c1c;
}
@media(min-width:768px) {
background-color: transparent;
width: auto;
@@ -625,6 +697,22 @@ multiselect {
}
}
.checklist-item {
padding: 8px;
border: none;
border-left: 2px solid rgb(255, 76, 76);
background-color: #ff000014;
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 5px;
}
.checklist-item-acknowledged {
border-left: 2px solid $brand-success;
background-color: transparent;
}
// ----------------------------
// Mail view
// ----------------------------
@@ -685,7 +773,7 @@ multiselect {
}
.card {
min-height: 488px;
min-height: 558px;
}
@media(min-width:768px) {
@@ -711,11 +799,14 @@ multiselect {
h1 {
margin-right: 10px;
line-height: 0.7;
margin-bottom: 0px;
margin-top: 16px;
padding: 4px 0;
line-height: 1;
font-size: 30px;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
overflow: hidden visible;
a {
white-space: nowrap;
@@ -791,7 +882,7 @@ multiselect {
&:hover,
&:focus {
text-decoration: none;
background-color: #f3f3f3;
background-color: #e9ebed;
box-shadow: -4px 3px 5px -2px rgba(0,0,0,.1);
}
@@ -824,6 +915,19 @@ multiselect {
cursor: not-allowed;
}
// ----------------------------
// Login and password forms
// ----------------------------
.card-form-bottom-bar {
display: flex;
justify-content: space-between;
}
.card-form-bottom-bar > * {
align-self: center;
}
// ----------------------------
// Appstore view
// ----------------------------
@@ -850,7 +954,7 @@ multiselect {
.appstore-toolbar-content {
display: flex;
margin: auto;
max-width: 1200px;
max-width: 1400px;
> * {
margin: 0 10px;
@@ -888,7 +992,7 @@ multiselect {
margin: auto;
overflow: auto;
height: calc(100% - 65px); // offset navigation bar
max-width: 1200px;
max-width: 1400px;
h2 {
font-size: 20px;
@@ -926,6 +1030,11 @@ multiselect {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
margin-bottom: 0px;
margin-top: 16px;
padding: 4px 0;
line-height: 1;
font-family: $font-family-sans-serif;
}
.appstore-item-content-tagline {
@@ -935,11 +1044,18 @@ multiselect {
}
.appstore-item-content-icon {
width: 100px;
min-width: 100px;
max-width: 100px;
width: 90px;
min-width: 90px;
max-width: 90px;
padding-left: 10px;
padding-right: 10px;
> .app-icon {
width: 70px;
height: 70px;
min-width: 70px;
min-height: 70px;
}
}
.appstore-category-link {
@@ -1061,6 +1177,10 @@ multiselect {
margin-bottom: 15px;
padding: 10px 15px;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
@media(max-width:767px) {
padding: 0;
}
}
.card-small {
@@ -1071,6 +1191,10 @@ multiselect {
max-width: 970px;
}
.card-expand {
max-width: initial;
}
.text-success {
color: #5CB85C;
}
@@ -1283,7 +1407,7 @@ select.purpose:invalid {
footer {
flex-grow: 0;
background-color: #f8f8f8;
background-color: white;
width: 100%;
color: #555;
max-height: 30px;
@@ -1409,6 +1533,39 @@ footer {
// Settings
// ----------------------------
.picture-edit-indicator {
position: absolute;
bottom: -4px;
right: -4px;
border-radius: 20px;
padding: 5px;
color: $text-dark;
background-color: white;
transition: all 250ms;
}
div:hover > .picture-edit-indicator {
color: white;
background: $brand-primary;
transform: scale(1.2);
}
.info-edit-indicator {
float: right;
border-radius: 20px;
padding: 5px;
color: $text-dark;
background-color: white;
transition: all 250ms;
cursor: pointer;
}
.info-edit-indicator:hover {
color: white;
background: $brand-primary;
transform: scale(1.2);
}
.settings-avatar {
position: relative;
cursor: pointer;
@@ -1426,23 +1583,6 @@ footer {
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(127, 127, 127 ,0.3);
background-image: url('/img/plus.png');
background-repeat: no-repeat;
background-position: center;
transition: all 150ms;
opacity: 0;
&:hover {
opacity: 1;
}
}
}
.settings-avatar-selector {
@@ -1649,23 +1789,6 @@ footer {
width: 100%;
height: 100%;
}
.overlay {
position: absolute;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(127, 127, 127 ,0.3);
background-image: url('/img/plus.png');
background-repeat: no-repeat;
background-position: center;
transition: all 150ms;
opacity: 0;
&:hover {
opacity: 1;
}
}
}
.branding-avatar-selector {
@@ -1710,6 +1833,23 @@ footer {
}
}
.branding-background {
position: relative;
cursor: pointer;
width: 256px;
background-position: center;
background-size: 100% 100%;
background-repeat: no-repeat;
border: 1px solid gray;
border-radius: 3px;
img {
display: block;
width: 100%;
height: 100%;
}
}
// ----------------------------
// Tag Input
// ----------------------------
@@ -1757,6 +1897,14 @@ tag-input {
.logs {
background: black;
.logs-error {
color: white;
width: 100%;
font-size: 18px;
text-align: center;
margin-top: 200px;
}
.logs-controls {
margin: 5px;
@@ -2023,8 +2171,13 @@ tag-input {
.has-background {
h1, h2, h3 {
filter: drop-shadow(0 0 0.5px black);
color: white;
-webkit-text-stroke: 0.3px black;
.btn {
color: white;
-webkit-text-stroke: 0;
}
}
.modal-content {
@@ -2175,6 +2328,14 @@ tag-input {
}
}
.app-list .app-list-item .app-list-app-link {
color: $textColor;
}
.app-list .app-list-item:hover .app-list-app-link {
color: white;
}
footer, .card, .app-configure-links div.active {
background-color: $backgroundDark;
}
+110 -29
View File
@@ -51,7 +51,8 @@
"save": "Gem",
"close": "Luk",
"no": "Nej",
"yes": "Ja"
"yes": "Ja",
"delete": "Slet"
},
"username": "Brugernavn",
"displayName": "Vis navn",
@@ -61,7 +62,8 @@
},
"action": {
"reboot": "Genstart",
"logs": "Logfiler"
"logs": "Logfiler",
"showLogs": "Vis logfiler"
},
"clipboard": {
"copied": "Kopieret til udklipsholderen",
@@ -87,7 +89,9 @@
"statusEnabled": "Aktiveret",
"statusDisabled": "Slået fra",
"loadingPlaceholder": "Indlæsning",
"disableAction": "Deaktiver"
"disableAction": "Deaktiver",
"settings": "Indstillinger",
"saveAction": "Gem"
},
"appstore": {
"category": {
@@ -178,7 +182,6 @@
"title": "Tilslut en ekstern mappe",
"description": "Cloudron synkroniserer brugere og grupper fra en ekstern LDAP- eller ActiveDirectory-server. Adgangskodebekræftelse til autentificering af disse brugere foretages mod den eksterne server. Synkroniseringen køres ikke automatisk, men skal udløses manuelt.",
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
"subscriptionRequired": "Denne funktion er kun tilgængelig i de betalte abonnementer.",
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
"provider": "Udbyder",
@@ -267,12 +270,6 @@
"failed": "Følgende brugere blev ikke importeret:",
"sendInviteCheckbox": "Send en e-mail med invitation til importerede brugere"
},
"makeLocalDialog": {
"description": "Dette vil migrere brugeren fra den eksterne mappe til Cloudron.",
"title": "Gør denne bruger lokal",
"warning": "En nulstilling af adgangskode vil blive iværksat for at indstille en lokal adgangskode for denne bruger.",
"submitAction": "Gør lokale"
},
"title": "Brugerkatalog",
"newUserAction": "Ny bruger",
"users": {
@@ -292,7 +289,6 @@
"invitationTooltip": "Inviter bruger",
"mailmanagerTooltip": "Denne bruger kan administrere brugere og postkasser",
"count": "Antal brugere i alt: {{ count }}",
"makeLocalTooltip": "Gør brugeren lokal",
"setGhostTooltip": "Udgive sig for at være"
},
"groups": {
@@ -303,7 +299,7 @@
"externalLdapTooltip": "Fra ekstern LDAP-mappe"
},
"settings": {
"title": "Indstillinger",
"title": "Brugerindstillinger",
"allowProfileEditCheckbox": "Tillad brugere at redigere deres navn og e-mail",
"require2FACheckbox": "Kræv, at brugerne skal oprette 2FA",
"subscriptionRequired": "Disse funktioner er kun tilgængelige i de betalte abonnementer.",
@@ -503,7 +499,8 @@
},
"changeBackgroundImage": {
"title": "Indstil baggrundsbillede"
}
},
"enable2FANotAvailable": "Ikke tilgængelig for brugere fra ekstern autentificeringskilde"
},
"backups": {
"location": {
@@ -535,7 +532,7 @@
"noApps": "Ingen apps",
"cleanupBackups": "Oprydning af sikkerhedskopier",
"backupNow": "Backup nu",
"stopTask": "Stop {{ taskType === 'backup' ? 'Backup' : 'Oprydning' }}",
"stopTask": "Stop Backup",
"tooltipEditBackup": "Rediger sikkerhedskopi"
},
"backupDetails": {
@@ -629,7 +626,8 @@
"preserved": {
"description": "Vedvarende backup uanset opbevaringspolitik",
"tooltip": "Dette vil også bevare mailen og {{ appsLength }} app-backuppen(erne)."
}
},
"remotePath": "Ekstern sti"
}
},
"branding": {
@@ -670,7 +668,8 @@
"solrRunning": "Kører",
"solrNotRunning": "Ikke kørende",
"acl": "ACL for post",
"aclOverview": "{{ dnsblZonesCount }} DNSBL-zone(r)"
"aclOverview": "{{ dnsblZonesCount }} DNSBL-zone(r)",
"virtualAllMail": "Mappen \"Al post\""
},
"eventlog": {
"empty": "Logbogen er tom.",
@@ -762,6 +761,10 @@
},
"action": {
"queue": "Kø"
},
"changeVirtualAllMailDialog": {
"title": "Mappen \"Al post\"",
"description": "Mappen \"All Mail\" er en enkelt mappe, der indeholder alle mails i din indbakke. Mappen kan være nyttig i mailklienter, der ikke understøtter rekursiv mappesøgning."
}
},
"network": {
@@ -787,7 +790,8 @@
},
"dyndns": {
"description": "Aktiver denne indstilling for at holde alle dine DNS-poster synkroniseret med en skiftende IP-adresse. Dette er nyttigt, når Cloudron kører i et netværk med en ofte skiftende offentlig IP-adresse, f.eks. en hjemmeforbindelse.",
"title": "Dynamisk DNS"
"title": "Dynamisk DNS",
"showLogsAction": "Vis logfiler"
},
"title": "Netværk",
"configureIp": {
@@ -804,7 +808,13 @@
},
"configureIpv6": {
"title": "Konfigurer IPv6-udbyder"
}
},
"trustedIps": {
"title": "Konfigurer tillidsfulde IP'er",
"summary": "{{ trustCount }} IP'er, der er tillid til",
"description": "Der vil blive stolet på HTTP-headere fra matchende IP-adresser"
},
"trustedIpRanges": "Tillid til IP'er og områder "
},
"services": {
"configure": {
@@ -919,7 +929,8 @@
"submitAction": "Indsend",
"reportPlaceholder": "Beskriv dit problem",
"emailPlaceholder": "Angiv om nødvendigt en anden e-mail-adresse end ovenstående, så du kan kontaktes",
"emailVerifyAction": "Bekræft nu"
"emailVerifyAction": "Bekræft nu",
"typeBilling": "Problemer med fakturering"
},
"remoteSupport": {
"description": "Aktiver denne indstilling for at give supportteknikere mulighed for at oprette forbindelse til denne server via SSH.",
@@ -1013,7 +1024,8 @@
"hetznerToken": "Hetzner Token",
"porkbunSecretapikey": "Hemmelig API-nøgle",
"cloudflareDefaultProxyStatus": "Aktiver proxying for nye DNS-poster",
"porkbunApikey": "API-nøgle"
"porkbunApikey": "API-nøgle",
"bunnyAccessKey": "Bunny Access Key"
},
"title": "Domæner og certs",
"addDomain": "Tilføj domæne",
@@ -1023,7 +1035,7 @@
"tooltipRemove": "Fjern domæne",
"changeDashboardDomain": {
"title": "Ændre Dashboard-domæne",
"description": "Dette vil flytte instrumentbrættet og e-mail-serveren til den<code>my</code>underdomæne til det valgte domæne.",
"description": "Dette vil flytte dashboardet til <code>my</code>subdomænet i det valgte domæne.",
"changeAction": "Ændre domæne",
"cancelAction": "Annuller",
"showLogsAction": "Vis logs"
@@ -1042,7 +1054,8 @@
"domainWellKnown": {
"title": "Well-Known locations på {{ domain }}"
},
"tooltipWellKnown": "Indstil well-known lokationer"
"tooltipWellKnown": "Indstil well-known lokationer",
"count": "Samlede domæner: {{ count }}"
},
"notifications": {
"markAllAsRead": "Markér alle som læst",
@@ -1147,7 +1160,8 @@
"cut": "Skær",
"copy": "Kopier",
"paste": "Indsæt",
"selectAll": "Vælg alle"
"selectAll": "Vælg alle",
"open": "Åben"
},
"mtime": "Ændret"
},
@@ -1162,7 +1176,19 @@
},
"status": {
"restartingApp": "genstart af app"
}
},
"extractionInProgress": "Udvinding i gang",
"uploader": {
"exitWarning": "Upload er stadig i gang. Skal vi virkelig lukke denne side?",
"uploading": "Uploading"
},
"textEditor": {
"undo": "Fortryd",
"redo": "Omarbejdning",
"save": "Gem"
},
"pasteInProgress": "Indsætning i gang",
"deleteInProgress": "Sletning i gang"
},
"email": {
"incoming": {
@@ -1416,7 +1442,8 @@
"title": "Datakatalog",
"dataDirPlaceholder": "Lad det være tomt for at bruge platformens standard",
"moveAction": "Flyt data",
"diskUsage": "Appen bruger i øjeblikket {{ size }} af lagerplads (pr. {{ date }})."
"diskUsage": "Appen bruger i øjeblikket {{ size }} af lagerplads (pr. {{ date }}).",
"mountTypeWarning": "Destinationsfilsystemet skal understøtte filtilladelser og ejerskab, for at flytningen kan fungere"
},
"mounts": {
"title": "Montering",
@@ -1680,6 +1707,17 @@
"label": "Etiket",
"clearIconAction": "Ryd ikon",
"clearIconDescription": "Dette vil forsøge at hente appens favicon ved lagring."
},
"servicesTabTitle": "Tjenester",
"turn": {
"title": "TURN Opsætning",
"enable": "Konfigurer appen til at bruge den indbyggede TURN-server",
"disable": "Du må ikke konfigurere appens TURN-indstillinger. Appens TURN-indstillinger skal ikke konfigureres. Du kan konfigurere dem inde i appen."
},
"redis": {
"title": "Redis-konfiguration",
"enable": "Konfigurer appen til at bruge Redis",
"disable": "Deaktiver Redis"
}
},
"passwordReset": {
@@ -1808,7 +1846,9 @@
"logs": {
"title": "Logfiler",
"clear": "Klart udsyn",
"download": "Download komplette logs"
"download": "Download komplette logs",
"notFoundError": "Ingen sådan opgave eller app",
"logsGoneError": "Logfil(er) ikke fundet"
},
"login": {
"loginTo": "Log ind på",
@@ -1817,7 +1857,9 @@
"password": "Adgangskode",
"2faToken": "2FA-token (hvis aktiveret)",
"signInAction": "Log ind",
"resetPasswordAction": "Nulstil adgangskode"
"resetPasswordAction": "Nulstil adgangskode",
"errorIncorrect2FAToken": "2FA-token er ugyldig",
"errorInternal": "Intern fejl, prøv igen senere"
},
"lang": {
"en": "English",
@@ -1831,9 +1873,48 @@
"zh_Hans": "Kinesisk (forenklet)",
"es": "Spansk",
"ru": "Russisk",
"pt": "Portugisisk"
"pt": "Portugisisk",
"da": "Dansk"
},
"supportConfig": {
"emailNotVerified": "Du bedes først bekræfte e-mailen på cloudron.io-kontoen for at sikre, at vi kan kontakte dig."
}
},
"oidc": {
"newClientDialog": {
"title": "Tilføj klient",
"description": "Tilføj nye OpenID connect-klientindstillinger.",
"createAction": "Opret"
},
"client": {
"name": "Navn",
"id": "Klient-id",
"secret": "Klientens secret",
"signingAlgorithm": "Signeringsalgoritme",
"loginRedirectUri": "Url til tilbagekaldelse af login (kommasepareret, hvis der er mere end én)",
"logoutRedirectUri": "Url til tilbagekaldelse af logout (valgfrit)"
},
"title": "OpenID Connect-udbyder",
"description": "Cloudron kan fungere som OpenID Connect-udbyder for interne apps og eksterne tjenester.",
"editClientDialog": {
"title": "Rediger klient {{ client }}"
},
"deleteClientDialog": {
"title": "Virkelig slette klient {{ client }}?",
"description": "Dette vil afbryde forbindelsen til alle eksterne OpenID-apps fra denne Cloudron, der bruger dette klient-id."
},
"env": {
"discoveryUrl": "URL til opdagelse",
"logoutUrl": "URL til logout",
"profileEndpoint": "Profil slutpunkt",
"keysEndpoint": "Nøgler Slutpunkt",
"tokenEndpoint": "Token slutpunkt",
"authEndpoint": "Auth-slutpunkt"
},
"clients": {
"title": "Klienter",
"newClient": "Ny klient",
"empty": "Ingen klienten endnu"
}
},
"automation": "Automatisering"
}
+153 -60
View File
@@ -22,13 +22,17 @@
"auth": {
"nosso": "Die App verwendet eine eigene Benutzerverwaltung",
"email": "Mit E-Mail-Adresse anmelden",
"sso": "Mit Cloudron Zugangsdaten anmelden"
"sso": "Mit Cloudron Zugangsdaten anmelden",
"openid": "Mit Cloudron OpenID anmelden"
},
"addAppAction": "App hinzufügen",
"addAppproxyAction": "App Proxy hinzufügen",
"addApplinkAction": "App Link hinzufügen",
"filter": {
"clearAll": "Alles löschen"
},
"apps": {
"count": "Appanzahl: {{ count }}"
}
},
"main": {
@@ -51,7 +55,8 @@
},
"action": {
"logs": "Logs",
"reboot": "Neustarten"
"reboot": "Neustarten",
"showLogs": "Zeige Logs"
},
"pagination": {
"perPageSelector": "Zeige {{ n }} pro Seite",
@@ -79,7 +84,8 @@
"justNow": "gerade eben",
"yeserday": "Gestern",
"minutesAgo": "vor {{ m }} Minuten",
"hoursAgo": "vor {{ h }} Stunden"
"hoursAgo": "vor {{ h }} Stunden",
"never": "Nie"
},
"disableAction": "Deaktivieren",
"enableAction": "Aktivieren",
@@ -89,16 +95,18 @@
},
"statusDisabled": "Deaktiviert",
"loadingPlaceholder": "Laden",
"settings": "Einstellungen"
"settings": "Einstellungen",
"saveAction": "Speichern"
},
"network": {
"title": "Netzwerk",
"dyndns": {
"title": "Dynamischer DNS",
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft."
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft.",
"showLogsAction": "Zeige Logs"
},
"configureIp": {
"title": "IP-Anbieter konfigurieren",
"title": "IPv4-Anbieter konfigurieren",
"providerGenericDescription": "Die öffentliche IP-Adresse des Servers wird automatisch erkannt."
},
"firewall": {
@@ -112,12 +120,12 @@
"blocklist": "{{ blockCount }} IP(s) sind gesperrt"
},
"ip": {
"description": "Cloudron verwendet diese IP-Adresse beim Einrichten von DNS-Einträgen.",
"description": "Cloudron verwendet diese IPv4-Adresse beim Einrichten von DNS A Einträgen.",
"provider": "Anbieter",
"interface": "Name der Netzwerkschnittstelle",
"configure": "Konfigurieren",
"interfaceDescription": "Verfügbare Netzwerkgeräte auf dem Server anzeigen mit:",
"title": "IP-Adresse",
"title": "IPv4",
"detected": "ermittelt",
"address": "IP Adresse"
},
@@ -131,7 +139,13 @@
},
"ipv4": {
"address": "IPv4 Adresse"
}
},
"trustedIps": {
"description": "HTTP header, von übereinstimmenden IP-Adressen, wird vertraut",
"summary": "{{ trustCount }} IPs vertrauen",
"title": "Konfiguriere vertrauenswürdige IPs"
},
"trustedIpRanges": "Vertrauenswürdige IPs & IP-Bereichen "
},
"settings": {
"title": "Einstellungen",
@@ -167,7 +181,7 @@
"description": "Ein Cloudron.io-Konto wird für den Zugriff auf den App-Store und die Verwaltung des Abonnements verwendet.",
"subscriptionSetupAction": "Abonnement einrichten",
"cloudronId": "Cloudron-ID",
"subscriptionChangeAction": "Abonnement ändern",
"subscriptionChangeAction": "Abonnement verwalten",
"setupAction": "Konto einrichten",
"subscription": "Abonnement-Typ",
"subscriptionReactivateAction": "Abonnement reaktivieren",
@@ -216,7 +230,7 @@
"configureAction": "Einrichten",
"syncAction": "Synchronisieren",
"showLogsAction": "Zeige Logs",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden auf der Cloudron-Instanz",
"autocreateUsersOnLogin": "Erstelle User automatisch beim Anmelden",
"auth": "Authentifizierung",
"groupnameField": "Gruppennamen Feld",
"groupFilter": "Gruppenfilter",
@@ -230,11 +244,11 @@
"provider": "Anbieter",
"noopInfo": "LDAP Authentifizierung ist nicht konfiguriert.",
"subscriptionRequiredAction": "Abonnenement jetzt abschließen",
"subscriptionRequired": "Diese Funktion ist nur im Abo enthalten.",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Passwörter beim Anmelden werden immer durch den externen Server validiert. Die Synchronisierung läuft nicht automatisch, sondern muss manuell gestartet werden.",
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Die Synchronisierung läuft automatisch, kann aber auch manuell gestartet werden.",
"title": "Verbinde ein externes Verzeichnis",
"providerOther": "Sonstige",
"providerDisabled": "Deaktiviert"
"providerDisabled": "Deaktiviert",
"disableWarning": "Die Authentifizierungsmethode von allen Usern wird auf die lokale Datenbank zurückgesetzt."
},
"settings": {
"saveAction": "Speichern",
@@ -242,7 +256,7 @@
"subscriptionRequired": "Diese Funktionen sind nur im Abo enthalten.",
"require2FACheckbox": "User müssen Zwei-Faktor-Authentifizierung (2FA) aktivieren",
"allowProfileEditCheckbox": "Erlaube Usern ihren Namen und E-Mail-Adresse zu ändern",
"title": "Einstellungen",
"title": "User Einstellungen",
"require2FAWarning": "Richte 2FA ein um nicht ausgesperrt zu werden."
},
"groups": {
@@ -269,8 +283,7 @@
"invitationTooltip": "User einladen",
"mailmanagerTooltip": "Dieser User kann Benutzer und Postfächer verwalten.",
"setGhostTooltip": "Als anderer User ausgeben",
"count": "User insgesamt: {{ count }}",
"makeLocalTooltip": "Mache user lokal"
"count": "User insgesamt: {{ count }}"
},
"newUserAction": "Neuer User",
"role": {
@@ -344,7 +357,9 @@
"username": "Username",
"fullName": "Vollständiger Name",
"fallbackEmailPlaceholder": "Optional. Falls nicht gesetzt wird die Primäre E-Mail benutzt",
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden"
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden",
"external2FA": "2FA Einstellungen werden von der externen Authentifikationsmethode verwaltet",
"ldapGroups": "LDAP Gruppen"
},
"addUserDialog": {
"addUserAction": "User hinzufügen",
@@ -388,12 +403,13 @@
},
"description": "Cloudron kann als zentraler Benutzerverzeichnis-Server für externe Anwendungen fungieren.",
"ipRestriction": {
"description": "Der Verzeichnisserver kann auf bestimmte IPs oder Bereiche beschränkt werden.",
"description": "Der Verzeichnisserver muss auf bestimmte IPs oder Bereiche beschränkt werden. Zeilen, die mit <code>#</code> beginnen werden als Kommentare gewertet.",
"label": "Zugriff beschränken",
"placeholder": "Zeilen separierte IP Adresse oder Subnetz"
},
"enabled": "Aktiviert",
"title": "Verzeichnis Server"
"title": "Verzeichnis Server",
"cloudflarePortWarning": "Cloudflare Proxying für die Dashboarddomäne muss deaktiviert sein um den LDAP Server zu erreichen"
},
"invitationNotification": {
"title": "Einladungslink versendet",
@@ -421,12 +437,6 @@
"all": "Alle User",
"active": "Aktive User",
"inactive": "Inaktive User"
},
"makeLocalDialog": {
"description": "Dies migriert den User vom externen Verzeichnis zum Cloudron.",
"warning": "Das Passwort wird zurückgesetzt um dem User ein lokale Passwort zu geben.",
"title": "Mache den Benutzer lokal",
"submitAction": "Änderungen lokal speichern"
}
},
"profile": {
@@ -508,7 +518,10 @@
"changeEmail": {
"errorEmailRequired": "Eine gültige E-Mail-Adresse ist erforderlich",
"errorEmailInvalid": "Die E-Mail-Adresse ist nicht gültig",
"title": "Primäre E-Mail-Adresse ändern"
"title": "Primäre E-Mail-Adresse ändern",
"email": "Neue E-Mail-Adresse",
"password": "Passwort zur Bestätigung",
"errorWrongPassword": "Falsches Passwort"
},
"loginTokens": {
"logoutAll": "Von allen abmelden",
@@ -537,14 +550,15 @@
},
"changeBackgroundImage": {
"title": "Hintergrundbild setzen"
}
},
"enable2FANotAvailable": "Für externe User nicht verfügbar"
},
"emails": {
"title": "E-Mail",
"settings": {
"spamFilter": "Spamfilter",
"maxMailSize": "Maximalgröße einer E-Mail",
"location": "Standort des Mail-Servers",
"location": "Domäne des Mail-Servers",
"info": "Die Einstellungen sind global und werden bei allen Domains verwendet.",
"title": "Einstellungen",
"spamFilterOverview": "{{ blacklistCount }} Adressen sind auf der Blockliste.",
@@ -555,7 +569,8 @@
"solrNotRunning": "Inaktiv",
"solrRunning": "Aktiv",
"aclOverview": "{{ dnsblZonesCount }} DNSBL Zonen",
"acl": "Postfachberechtigungen"
"acl": "Postfachberechtigungen",
"virtualAllMail": "\"All Mail\" Ordner"
},
"domains": {
"testEmailTooltip": "Test E-Mail senden",
@@ -604,7 +619,7 @@
},
"changeDomainDialog": {
"locationPlaceholder": "Leer lassen, um die Haupt-Domäne zu verwenden",
"description": "Cloudron nimmt die notwendigen DNS-Änderungen in allen Domänen vor und startet den Mail-Server neu. Desktop & Mobile E-Mail-Clients müssen neu konfiguriert werden, um diese neue Adresse als IMAP- und SMTP-Server zu verwenden.",
"description": "Dies zieht den E-Mail Server auf die neue Domäne um.",
"location": "Adresse",
"title": "E-Mail-Server Standort ändern",
"manualInfo": "Manuell einen A-Eintrag für {{ Domain }} zur öffentlichen IP dieses Cloudrons hinzufügen"
@@ -655,6 +670,10 @@
},
"action": {
"queue": "Warteschlange"
},
"changeVirtualAllMailDialog": {
"description": "Der \"All Mail\" Ordner ist ein einziger Ordner, welcher alle E-Mails des Posteingangs beinhaltet. Dieser Ordner unterstützt mit E-Mail Anwendungen, welche keine rekursive Suche anbieten.",
"title": "\"All Mail\" Ordner"
}
},
"support": {
@@ -677,7 +696,8 @@
"report": "Meldung",
"subscriptionRequiredDescription": "Antworten auf die häufigsten Fragen sind in der <a href=\"{{ supportViewLink }}\" target=\"_blank\">Dokumentation</a> verfügbar. Unser <a href=\"{{ forumLink }}\" target=\"_blank\">Forum</a> bietet einen Platz in die Community einzusteigen und sich auszutauschen.",
"emailVerifyAction": "Jetzt verifizieren",
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen."
"emailNotVerified": "Ihre cloudron.io Konto E-Mail {{ email }} ist nicht verifiziert. Bitte bestätigen Sie Ihre E-Mail Adresse, um Support-Tickets zu öffnen.",
"typeBilling": "Problem mit Rechnung"
},
"remoteSupport": {
"title": "Fernwartung",
@@ -686,6 +706,10 @@
"subscriptionRequired": "Fernwartung ist nur im Abo verfügbar.",
"description": "Diese Option aktivieren, um Mitarbeitenden aus dem Support zu erlauben, sich über SSH mit diesem Server zu verbinden.",
"disableAction": "Zugang zur SSH-Unterstützung deaktivieren"
},
"help": {
"description": "Bitte die folgenden Resourcen für Hilfe und Support:\n* [Cloudron Forum]({{ forumLink }}) - Bitte die Support und App spezifischen Kategorien nutzen .\n* [Cloudron Doku & Wissensdatenbank]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n",
"title": "Hilfe"
}
},
"eventlog": {
@@ -751,12 +775,18 @@
"cloudflareDefaultProxyStatus": "Proxying für neue DNS-Einträge aktivieren",
"porkbunSecretapikey": "Geheimer API-Schlüssel",
"porkbunApikey": "API-Schlüssel",
"bunnyAccessKey": "Bunny Access Key"
"bunnyAccessKey": "Bunny Access Key",
"deSecToken": "deSEC Token",
"dnsimpleAccessToken": "Access Token",
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"changeDashboardDomain": {
"title": "Die Dashboard-Domäne ändern",
"showLogsAction": "Logfiles anzeigen",
"description": "Dadurch werden das Dashboard und der E-Mail-Server in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"description": "Dadurch wird das Dashboard in die Subdomain <code>my</code> der ausgewählten Domäne verschoben.",
"changeAction": "Domäne ändern",
"cancelAction": "Abbrechen"
},
@@ -784,7 +814,8 @@
"tooltipWellKnown": ".well-known Pfade setzen",
"domainWellKnown": {
"title": ".well-known Pfade von {{ domain }}"
}
},
"count": "Domänenanzahl: {{ count }}"
},
"notifications": {
"title": "Benachrichtigungen",
@@ -815,7 +846,19 @@
"title": "CPU-Auslastung",
"graphSubtext": "Es werden nur Anwendungen angezeigt, die mehr als {{ threshold }} an Rechenleistung benötigen"
},
"selectPeriodLabel": "Zeitraum auswählen"
"selectPeriodLabel": "Zeitraum auswählen",
"info": {
"platformVersion": "Plattform Version",
"title": "Info",
"vendor": "Anbieter",
"product": "Produkt",
"memory": "Arbeitsspeicher",
"uptime": "Betriebszeit",
"activationTime": "Cloudron Aktivierungszeit"
},
"graphs": {
"title": "Graphen"
}
},
"backups": {
"title": "Datensicherung",
@@ -900,7 +943,7 @@
"title": "Logfiles"
},
"listing": {
"stopTask": "Stop {{ taskType === 'backup' ? 'Backup' : 'Cleanup' }}",
"stopTask": "Stop Backup",
"backupNow": "Backup jetzt erstellen",
"cleanupBackups": "Backups löschen",
"tooltipDownloadBackupConfig": "Konfiguration herunterladen",
@@ -941,7 +984,8 @@
"tooltip": "Dadurch bleiben auch die Mail- und {{ appsLength }} App-Backups erhalten.",
"description": "Backup unabhängig von der Aufbewahrungsrichtlinie beibehalten"
},
"label": "Label"
"label": "Label",
"remotePath": "Remote Pfad"
}
},
"appstore": {
@@ -960,7 +1004,10 @@
"email": "E-Mail",
"description": "Dieses Konto gibt Zugriff zum App-Store und Aboverwaltung",
"titleLogin": "Bei Cloudron.io anmelden",
"titleSignUp": "Bei Cloudron.io registrieren"
"titleSignUp": "Bei Cloudron.io registrieren",
"setupWithTokenAction": "Registrieren",
"setupToken": "Setup Token",
"titleToken": "Mit Setup Token registrieren"
},
"appNotFoundDialog": {
"description": "Die Anwendung <b>{{ appId }}</b> mit der Version <b>{{ version }}</b> existiert nicht.",
@@ -1060,7 +1107,9 @@
"title": "Fußzeile"
},
"logo": "Logo",
"cloudronName": "Name der Cloudron-Instanz"
"cloudronName": "Name der Cloudron-Instanz",
"backgroundImage": "Hintergrundbild der Login-Seite",
"clearBackgroundImage": "Löschen"
},
"login": {
"password": "Passwort",
@@ -1069,12 +1118,15 @@
"2faToken": "2FA-Token (wenn aktiviert)",
"loginTo": "Anmeldung bei",
"signInAction": "Anmelden",
"resetPasswordAction": "Passwort zurücksetzen"
"resetPasswordAction": "Passwort zurücksetzen",
"loginWith": "Mit Cloudron anmelden",
"errorIncorrect2FAToken": "2FA Token ist ungültig",
"errorInternal": "Interner Fehler, später nochmals versuchen"
},
"welcomeEmail": {
"welcomeTo": "Willkommen bei <%= cloudronName %>!",
"subject": "Willkommen bei <%= cloudron %>",
"inviteLinkActionText": "Öffnen den folgenden Link um dich anzumelden: <%- inviteLink %>",
"inviteLinkActionText": "Öffne den folgenden Link, um dich anzumelden: <%- inviteLink %>",
"expireNote": "Dieser Link ist 7 Tage gültig.",
"invitor": "Diese Email wurde geschickt, weil Du von <%= invitor %> eingeladen wurdest.",
"inviteLinkAction": "Starte hier",
@@ -1178,7 +1230,7 @@
"enableEmailDialog": {
"description": "Dies wird Cloudron so konfigurieren, dass E-Mails für <b>{{ domain }}</b> empfangen werden. Die Dokumentation zum Öffnen der <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">erforderlichen Ports</a> für Cloudron E-Mail lesen.",
"noProviderInfo": "Es ist kein DNS-Anbieter eingerichtet. Die in der Registerkarte Status aufgeführten DNS-Einträge müssen manuell eingerichtet werden.",
"cloudflareInfo": "Die Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"cloudflareInfo": "Die E-Mail Domäne <code>{{ adminDomain }}</code> wird von Cloudflare verwaltet. Sicherstellen, dass das Cloudflare-Proxying für <code>{{ mailFqdn }}</code> deaktiviert und auf <code>DNS only</code> gesetzt ist. Dies ist erforderlich, da Cloudflare kein E-Mail-Proxying durchführt.",
"enableAction": "Aktivieren",
"title": "E-Mail für {{ domain }} aktivieren?",
"setupDnsCheckbox": "DNS-Einträge für E-Mail jetzt einrichten",
@@ -1326,7 +1378,8 @@
"renameDialog": {
"newName": "Neuer Name",
"title": "{{ fileName }} umbennen",
"rename": "Umbenennen"
"rename": "Umbenennen",
"reallyOverwrite": "Eine Datei mit diesem Namen existiert bereits. Diese Datei überschreiben?"
},
"extractDialog": {
"title": "Extrahieren von {{ fileName }}",
@@ -1366,7 +1419,8 @@
"paste": "Einfügen",
"copy": "Kopieren",
"cut": "Ausschneiden",
"edit": "Bearbeiten"
"edit": "Bearbeiten",
"open": "Öffnen"
},
"symlink": "Symlink zu {{ target }}",
"mtime": "Geändert"
@@ -1387,7 +1441,19 @@
},
"status": {
"restartingApp": "Die Anwendung wird neugestartet"
}
},
"uploader": {
"uploading": "Hochladen",
"exitWarning": "Aktuell werden noch Dateien hochgeladen. Wirklich schließen?"
},
"textEditor": {
"undo": "Rückgängig",
"redo": "Wiederherstellen",
"save": "Speichern"
},
"extractionInProgress": "Entpacken läuft",
"pasteInProgress": "Einfügen läuft",
"deleteInProgress": "Löschen läuft"
},
"passwordReset": {
"usernameOrEmail": "Username oder E-Mail-Adresse",
@@ -1463,14 +1529,14 @@
"logsActionTooltip": "Logfiles",
"resources": {
"cpu": {
"setAction": "Festlegen",
"title": "CPU-Freigabe",
"description": "Prozent der CPU-Zeit, wenn das System unter hoher Last steht."
"setAction": "Skalieren",
"title": "CPU Limit",
"description": "Maximale CPU Prozente, die dieser App zur Verfügung stehen"
},
"memory": {
"resizeAction": "Größe ändern",
"title": "Speicherlimit",
"description": "Cloudron weist 50% dieses Wertes als RAM und 50% als Swap zu.",
"description": "Maximaler Arbeitsspeicher der dieser App zur Verfügung steht",
"error": "Speicherlimit nicht einstellbar. Weniger versuchen."
}
},
@@ -1537,7 +1603,7 @@
},
"uninstall": {
"backupWarning": "Anwendungs-Backups werden nicht entfernt und auf der Grundlage der Backup-Richtlinie bereinigt. Diese Anwendung kann aus einem bestehenden App-Backup mit den folgenden <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">Schritten</a> wiederhergestellt werden.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"description": "Dies wird die Anwendung sofort deinstallieren und alle zugehörigen Daten löschen. Die Anwendung steht anschließend nicht mehr zur Verfügung.",
"title": "Deinstallieren",
"uninstallAction": "Deinstallieren"
}
@@ -1553,12 +1619,12 @@
},
"updates": {
"auto": {
"enableAction": "Automatische Aktualisierungen aktivieren",
"enableAction": "Aktivieren",
"disabled": "Die automatische Aktualisierung ist deaktiviert.",
"enabled": "Die automatische Aktualisierung ist aktiviert.",
"title": "Automatische Aktualisierungen",
"description": "Cloudron fragt regelmäßig den App-Store nach Aktualisierungen ab. Wenn automatisches Aktualisieren deaktiviert ist, bitte sicherstellen, dass manuell nach Aktualisierungen gesucht wird.",
"disableAction": "Automatische Aktualisierungen deaktivieren"
"disableAction": "Deaktivieren"
},
"info": {
"updateAvailableAction": "Aktualisierung verfügbar",
@@ -1569,7 +1635,8 @@
"customAppUpdateInfo": "Aktualiserung steht für benutzerdefinierte Anwendungen nicht zur Verfügung",
"checkForUpdatesAction": "Auf Aktualisierungen überprüfen",
"packageVersion": "Paket-Version",
"repository": "Paket-Repository"
"repository": "Paket-Repository",
"installedAt": "Installationszeitpunkt"
},
"noUpdates": "Keine neuen Updates verfügbar"
},
@@ -1616,7 +1683,8 @@
"dataDirPlaceholder": "Leer lassen, um Systemvorgabe zu verwenden",
"description": "Wenn dem Server der Speicherplatz ausgeht, kann durch Hinzufügen einer <a href=\"/#/volumes\">externen Festplatte</a>, die Daten der Anwendung dorthin verschoben werden.",
"moveAction": "Daten verschieben",
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }})."
"diskUsage": "Die App verwendet derzeit {{ size }} an Speicherplatz (ab {{ date }}).",
"mountTypeWarning": "Das Zieldateisystem muss Dateiberechtigungen und Eigentümerschaft unterstützen, damit die Verschiebung funktioniert"
},
"mounts": {
"title": "Mounts",
@@ -1766,12 +1834,31 @@
},
"addApplinkDialog": {
"title": "Link zur externen Anwendung hinzufügen"
}
},
"redis": {
"disable": "Redis deaktivieren",
"title": "Redis Konfiguration",
"enable": "Die App mit Redis vorkonfigurieren"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Administrator Notizen"
}
},
"turn": {
"enable": "App für den internen TURN Server konfigurieren",
"disable": "TURN Server dieser App nicht automatisch konfigurieren.",
"title": "TURN Einstellungen"
},
"servicesTabTitle": "Dienste"
},
"logs": {
"download": "Vollständige Logfiles herunterladen",
"title": "Logfiles",
"clear": "Anzeige löschen"
"clear": "Anzeige löschen",
"notFoundError": "Task oder App existiert nicht",
"logsGoneError": "Logdatei(n) nicht gefunden"
},
"lang": {
"en": "Englisch",
@@ -1785,7 +1872,8 @@
"es": "Spanisch",
"ru": "Russisch",
"pt": "Portugiesisch",
"da": "Dänisch"
"da": "Dänisch",
"id": "Indonesian"
},
"volumes": {
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können.",
@@ -1822,7 +1910,11 @@
"mountStatus": "Einhängestatus",
"localDirectory": "Lokales Verzeichnis",
"type": "Typ",
"remountActionTooltip": "Datenträger neu einhängen"
"remountActionTooltip": "Datenträger neu einhängen",
"editVolumeDialog": {
"title": "Datenträger {{ name }} konfigurieren"
},
"editActionTooltip": "Datenträger konfigurieren"
},
"lang.ja": "Japanisch",
"newLoginEmail": {
@@ -1876,5 +1968,6 @@
"newClient": "Neuer Client",
"empty": "Noch keine Clienten erstellt"
}
}
},
"automation": "Automatisierung"
}
+153 -62
View File
@@ -22,13 +22,17 @@
"auth": {
"sso": "Log in with Cloudron credentials",
"nosso": "Log in with dedicated account",
"email": "Log in with email address"
"email": "Log in with email address",
"openid": "Log in with Cloudron OpenID"
},
"addAppAction": "Add App",
"addAppproxyAction": "Add App Proxy",
"addApplinkAction": "Add App Link",
"filter": {
"clearAll": "Clear All"
},
"apps": {
"count": "Total apps: {{ count }}"
}
},
"main": {
@@ -56,7 +60,8 @@
},
"action": {
"reboot": "Reboot",
"logs": "Logs"
"logs": "Logs",
"showLogs": "Show Logs"
},
"clipboard": {
"copied": "Copied to clipboard",
@@ -79,7 +84,8 @@
"justNow": "just now",
"yeserday": "Yesterday",
"minutesAgo": "{{ m }} minutes ago",
"hoursAgo": "{{ h }} hours ago"
"hoursAgo": "{{ h }} hours ago",
"never": "Never"
},
"navbar": {
"users": "Users"
@@ -89,7 +95,8 @@
"statusEnabled": "Enabled",
"statusDisabled": "Disabled",
"loadingPlaceholder": "Loading",
"settings": "Settings"
"settings": "Settings",
"saveAction": "Save"
},
"appstore": {
"title": "App Store",
@@ -167,7 +174,10 @@
"loginAction": "Login",
"createAccountAction": "Create Account",
"switchToSignUpAction": "Don't have an account yet? Sign up",
"switchToLoginAction": "Already have an account? Log in"
"switchToLoginAction": "Already have an account? Log in",
"setupWithTokenAction": "Setup",
"setupToken": "Setup Token",
"titleToken": "Sign up with Setup Token"
},
"categoryLabel": "Category",
"ssofilter": {
@@ -195,8 +205,7 @@
"invitationTooltip": "Invite User",
"setGhostTooltip": "Impersonate",
"mailmanagerTooltip": "This user can manage users and mailboxes",
"count": "Total users: {{ count }}",
"makeLocalTooltip": "Make user local"
"count": "Total users: {{ count }}"
},
"groups": {
"title": "Groups",
@@ -206,7 +215,7 @@
"externalLdapTooltip": "From external LDAP directory"
},
"settings": {
"title": "Settings",
"title": "User Settings",
"allowProfileEditCheckbox": "Allow users to edit their name and email",
"require2FACheckbox": "Require users to set up 2FA",
"subscriptionRequired": "These features are only available in the paid plans.",
@@ -216,8 +225,7 @@
},
"externalLdap": {
"title": "Connect an External Directory",
"description": "Cloudron will synchronize users and groups from an external LDAP or ActiveDirectory server. Password verification for authenticating those users is done against the external server. The synchronization is not run automatically but needs to be triggered manually.",
"subscriptionRequired": "This feature is only available in the paid plans.",
"description": "This setting will synchronize and authenticate users and groups from an external LDAP or Active Directory server. The synchronization is run periodically but can also be triggered manually.",
"subscriptionRequiredAction": "Set up Subscription Now",
"noopInfo": "LDAP authentication is not configured.",
"provider": "Provider",
@@ -231,15 +239,16 @@
"groupFilter": "Group Filter",
"groupnameField": "Groupname Field",
"auth": "Auth",
"autocreateUsersOnLogin": "Automatically create users when they login to Cloudron",
"autocreateUsersOnLogin": "Automatically create users on login",
"showLogsAction": "Show Logs",
"syncAction": "Synchronize",
"syncAction": "Sync",
"configureAction": "Configure",
"bindUsername": "Bind DN/Username (optional)",
"bindPassword": "Bind Password (optional)",
"errorSelfSignedCert": "Server is using an invalid or self-signed certificate.",
"providerOther": "Other",
"providerDisabled": "Disabled"
"providerDisabled": "Disabled",
"disableWarning": "The authentication source of all existing users will be reset to authenticate against the local password database."
},
"subscriptionDialog": {
"title": "Subscription required",
@@ -268,7 +277,9 @@
"errorDisplayNameRequired": "Name is required",
"activeCheckbox": "User is active",
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up",
"fallbackEmailPlaceholder": "Optional. If not specified, primary email will be used"
"fallbackEmailPlaceholder": "Optional. If not specified, primary email will be used",
"external2FA": "2FA setup is managed by external authentication source",
"ldapGroups": "LDAP Groups"
},
"deleteUserDialog": {
"title": "Delete user {{ username }}",
@@ -357,7 +368,7 @@
"description": "Cloudron can act as a central user directory server for external applications.",
"enabled": "Enabled",
"ipRestriction": {
"description": "The directory server can be limited to specific IPs or ranges.",
"description": "Limit Directory Server access to specific IPs or ranges. Lines starting with <code>#</code> are treated as comments.",
"placeholder": "Line separated IP address or Subnet",
"label": "Restrict Access"
},
@@ -365,7 +376,8 @@
"label": "Bind Password",
"description": "All LDAP queries have to be authenticated with this secret and the user DN <i>{{ userDN }}</i>",
"url": "Server URL"
}
},
"cloudflarePortWarning": "Cloudflare proxying must be disabled on the dashboard domain to access the LDAP server"
},
"userImportDialog": {
"title": "Import Users",
@@ -389,12 +401,6 @@
"all": "All Users",
"active": "Active Users",
"inactive": "Inactive Users"
},
"makeLocalDialog": {
"title": "Make this user local",
"description": "This will migrate the user from the external directory to the Cloudron.",
"warning": "A password reset will be initiated to set a local password for this user.",
"submitAction": "Make local"
}
},
"profile": {
@@ -461,7 +467,10 @@
"changeEmail": {
"title": "Change primary email address",
"errorEmailInvalid": "The Email address is not valid",
"errorEmailRequired": "A valid email address is required"
"errorEmailRequired": "A valid email address is required",
"email": "New Email Address",
"password": "Password for confirmation",
"errorWrongPassword": "Wrong password"
},
"changeFallbackEmail": {
"title": "Change password recovery email address",
@@ -505,7 +514,8 @@
},
"changeBackgroundImage": {
"title": "Set Background Image"
}
},
"enable2FANotAvailable": "Not available for users from external authentication source"
},
"backups": {
"title": "Backups",
@@ -537,7 +547,7 @@
"tooltipDownloadBackupConfig": "Download Backup Configuration",
"cleanupBackups": "Cleanup Backups",
"backupNow": "Backup now",
"stopTask": "Stop {{ taskType === 'backup' ? 'Backup' : 'Cleanup' }}",
"stopTask": "Stop Backup",
"tooltipEditBackup": "Edit Backup",
"tooltipPreservedBackup": "This backup will be preserved"
},
@@ -623,7 +633,7 @@
},
"check": {
"noop": "Cloudron backups are disabled. Please ensure this server is backed up using alternate means. See https://docs.cloudron.io/backups/#storage-providers for more information.",
"sameDisk": "Cloudron backups are currently on the same disk as the Cloudron server instance. This is dangerous and can lead to complete data loss if the disk fails. See https://docs.cloudron.io/backups/#storage-providers for storing backups in an external location."
"sameDisk": "Backups are currently on the same disk as Cloudron itself. If the disk fills up with these backups, Cloudron will not function. A disk failure can also lead to complete data loss. See https://docs.cloudron.io/backups/#storage-providers for storing backups in an external location."
},
"backupEdit": {
"title": "Edit Backup",
@@ -631,7 +641,8 @@
"preserved": {
"description": "Persist backup regardless of retention policy",
"tooltip": "This will also preserve the mail and {{ appsLength }} app backup(s)."
}
},
"remotePath": "Remote Path"
}
},
"branding": {
@@ -646,7 +657,9 @@
},
"changeLogo": {
"title": "Choose Cloudron Avatar"
}
},
"backgroundImage": "Login page background image",
"clearBackgroundImage": "Clear"
},
"emails": {
"title": "Email",
@@ -662,7 +675,7 @@
"settings": {
"title": "Settings",
"info": "These settings are global and apply to all domains.",
"location": "Mail server location",
"location": "Mail Server Location",
"maxMailSize": "Maximum email size",
"spamFilter": "Spam filtering",
"spamFilterOverview": "{{ blacklistCount }} address(es) on the blocklist.",
@@ -673,7 +686,8 @@
"solrRunning": "Running",
"solrNotRunning": "Not Running",
"acl": "Mail ACL",
"aclOverview": "{{ dnsblZonesCount }} DNSBL zone(s)"
"aclOverview": "{{ dnsblZonesCount }} DNSBL zone(s)",
"virtualAllMail": "\"All Mail\" Folder"
},
"eventlog": {
"title": "Email Event Log",
@@ -707,7 +721,7 @@
},
"changeDomainDialog": {
"title": "Change Email Server Location",
"description": "Cloudron will make the necessary DNS changes across all the domains and restart the mail server. Desktop & Mobile email clients have to be re-configured to use this new location as the IMAP and SMTP server.",
"description": "This will move the IMAP and SMTP server to the specified location.",
"location": "Location",
"locationPlaceholder": "Leave empty to use bare domain",
"manualInfo": "Add an A record manually for {{ domain }} to this Cloudron's public IP"
@@ -764,13 +778,17 @@
},
"action": {
"queue": "Queue"
},
"changeVirtualAllMailDialog": {
"title": "\"All Mail\" Folder",
"description": "The \"All Mail\" folder is a single folder that contains all the mails in your Inbox. The folder can be useful in mail clients that do not support recursive folder search."
}
},
"network": {
"title": "Network",
"ip": {
"title": "IP Address",
"description": "Cloudron uses this IP address when setting up DNS records.",
"title": "IPv4",
"description": "Cloudron uses this IPv4 address to setup DNS A records.",
"provider": "Provider",
"interface": "Network Interface Name",
"configure": "Configure",
@@ -790,10 +808,11 @@
},
"dyndns": {
"title": "Dynamic DNS",
"description": "Enable this option to keep all your DNS records in sync with a changing IP address. This is useful when Cloudron runs in a network with a frequently changing public IP address like a home connection."
"description": "Enable this option to keep all your DNS records in sync with a changing IP address. This is useful when Cloudron runs in a network with a frequently changing public IP address like a home connection.",
"showLogsAction": "Show Logs"
},
"configureIp": {
"title": "Configure IP Provider",
"title": "Configure IPv4 Provider",
"providerGenericDescription": "The Public IP address of the server will be automatically detected."
},
"ipv4": {
@@ -806,7 +825,13 @@
},
"configureIpv6": {
"title": "Configure IPv6 Provider"
}
},
"trustedIps": {
"description": "HTTP headers from matching IP addresses will be trusted",
"title": "Configure Trusted IPs",
"summary": "{{ trustCount }} IPs trusted"
},
"trustedIpRanges": "Trusted IPs & Ranges "
},
"services": {
"title": "Services",
@@ -839,7 +864,7 @@
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Canceled and ends on",
"subscriptionSetupAction": "Upgrade to Premium",
"subscriptionChangeAction": "Change Subscription",
"subscriptionChangeAction": "Manage Subscription",
"subscriptionReactivateAction": "Reactivate Subscription",
"emailNotVerified": "Email not yet verified"
},
@@ -922,7 +947,8 @@
"reportPlaceholder": "Describe your issue",
"emailPlaceholder": "If needed, provide an email address different from above to reach you",
"emailVerifyAction": "Verify now",
"emailNotVerified": "Your cloudron.io account email {{ email }} is not verified. Please verify it to open support tickets."
"emailNotVerified": "Your cloudron.io account email {{ email }} is not verified. Please verify it to open support tickets.",
"typeBilling": "Billing Issue"
},
"remoteSupport": {
"title": "Remote Support",
@@ -931,6 +957,10 @@
"warning": "Do not enable this option unless requested by the Cloudron support team.",
"disableAction": "Disable SSH support access",
"enableAction": "Enable SSH support access"
},
"help": {
"title": "Help",
"description": "Please use the following resources for help and support:\n* [Cloudron Forum]({{ forumLink }}) - Please use the Support and App specific categories for questions.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
}
},
"system": {
@@ -955,7 +985,19 @@
"graphTitle": "Percentage",
"graphSubtext": "Only apps using more than {{ threshold }} of cpu are shown"
},
"selectPeriodLabel": "Select Period"
"selectPeriodLabel": "Select Period",
"info": {
"platformVersion": "Platform Version",
"title": "Info",
"vendor": "Vendor",
"product": "Product",
"memory": "Memory",
"uptime": "Uptime",
"activationTime": "Cloudron Creation Time"
},
"graphs": {
"title": "Graphs"
}
},
"eventlog": {
"title": "Event Log",
@@ -980,7 +1022,7 @@
},
"changeDashboardDomain": {
"title": "Change Dashboard Domain",
"description": "This will move the dashboard and the email server to the <code>my</code>subdomain of the selected domain.",
"description": "This will move the dashboard to the <code>my</code>subdomain of the selected domain.",
"changeAction": "Change Domain",
"cancelAction": "Cancel",
"showLogsAction": "Show Logs"
@@ -1037,7 +1079,13 @@
"cloudflareDefaultProxyStatus": "Enable proxying for new DNS records",
"porkbunApikey": "API Key",
"porkbunSecretapikey": "Secret API Key",
"bunnyAccessKey": "Bunny Access Key"
"bunnyAccessKey": "Bunny Access Key",
"dnsimpleAccessToken": "Access Token",
"ovhEndpoint": "Endpoint",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret",
"deSecToken": "deSEC Token"
},
"removeDialog": {
"title": "Really remove {{ domain }}?",
@@ -1066,7 +1114,9 @@
"logs": {
"title": "Logs",
"clear": "Clear View",
"download": "Download Full Logs"
"download": "Download Full Logs",
"notFoundError": "No such task or app",
"logsGoneError": "Log file(s) not found"
},
"terminal": {
"title": "Terminal",
@@ -1093,7 +1143,8 @@
"copy": "Copy",
"clear": "Clear",
"pasteInfo": "For Paste use Ctrl+v"
}
},
"uploadTo": "Upload to {{ path }}"
},
"filemanager": {
"title": "File Manager",
@@ -1111,7 +1162,8 @@
"renameDialog": {
"title": "Rename {{ fileName }}",
"newName": "New Name",
"rename": "Rename"
"rename": "Rename",
"reallyOverwrite": "A file with that name already exists. Overwrite existing file?"
},
"chownDialog": {
"title": "Change ownership",
@@ -1164,7 +1216,8 @@
"cut": "Cut",
"copy": "Copy",
"paste": "Paste",
"selectAll": "Select All"
"selectAll": "Select All",
"open": "Open"
},
"mtime": "Modified"
},
@@ -1179,7 +1232,19 @@
},
"status": {
"restartingApp": "restarting app"
}
},
"uploader": {
"uploading": "Uploading",
"exitWarning": "Upload still in progress. Really close this page?"
},
"textEditor": {
"undo": "Undo",
"redo": "Redo",
"save": "Save"
},
"extractionInProgress": "Extraction in progress",
"pasteInProgress": "Pasting in progress",
"deleteInProgress": "Deletion in progress"
},
"email": {
"backAction": "Back to Email",
@@ -1301,7 +1366,7 @@
"title": "Enable Email for {{ domain }}?",
"description": "This will configure Cloudron to receive emails for <b>{{ domain }}</b>. See the documentation for opening up the <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">required ports</a> for Cloudron Email.",
"noProviderInfo": "No DNS provider is set up. The DNS records listed in the Status tab have to be set up manually.",
"cloudflareInfo": "The domain <code>{{ adminDomain }}</code> is managed by Cloudflare. Please verify that Cloudflare proxying is disabled for <code>{{ mailFqdn }}</code> and set to <code>DNS only</code>. This is required because Cloudflare does not proxy email.",
"cloudflareInfo": "The mail server's domain <code>{{ adminDomain }}</code> is managed by Cloudflare. Please verify that Cloudflare proxying is disabled for <code>{{ mailFqdn }}</code> and set to <code>DNS only</code>. This is required because Cloudflare does not proxy email.",
"setupDnsCheckbox": "Set up Mail DNS records now",
"setupDnsInfo": "Use this option to automatically set up Email related DNS records. Leaving this option unchecked is useful for creating mail boxes and <a href=\"{{ importEmailDocsLink }}\">importing email</a> before going live.",
"enableAction": "Enable"
@@ -1437,14 +1502,14 @@
"resources": {
"memory": {
"title": "Memory Limit",
"description": "Cloudron allocates 50% of this value as RAM and 50% as swap.",
"description": "Maximum memory app can use",
"error": "Unable to set memory limit, try less.",
"resizeAction": "Resize"
},
"cpu": {
"setAction": "Set",
"title": "CPU Shares",
"description": "Percent of CPU time when system is under heavy load."
"setAction": "Scale",
"title": "CPU Limit",
"description": "Maximum percent of CPU app can use"
}
},
"storage": {
@@ -1453,7 +1518,8 @@
"description": "If the server is running out of disk space, use this to move the app's data to a <a href=\"/#/volumes\">volume</a>. Any data here is part of the app's backup.",
"dataDirPlaceholder": "Leave empty to use platform default",
"moveAction": "Move Data",
"diskUsage": "The app is currently using {{ size }} of storage (as of {{ date }})."
"diskUsage": "The app is currently using {{ size }} of storage (as of {{ date }}).",
"mountTypeWarning": "The destination file system must support file permissions and ownership for the move to work"
},
"mounts": {
"title": "Mounts",
@@ -1527,17 +1593,18 @@
"packageVersion": "Package Version",
"lastUpdated": "Last Updated",
"checkForUpdatesAction": "Check for Updates",
"customAppUpdateInfo": "Updates are not available for custom apps",
"customAppUpdateInfo": "Auto-update is not available for custom apps.",
"updateAvailableAction": "Update Available",
"repository": "Package Repository"
"repository": "Package Repository",
"installedAt": "Installed At"
},
"auto": {
"title": "Automatic Updates",
"description": "Cloudron periodically checks the App Store for updates. If you disable automatic updates, be sure to manually apply the updates.",
"description": "Cloudron periodically checks the <a href=\"{{ appStoreLink }}\" target=\"_blank\">App Store</a> for updates.",
"enabled": "Automatic Updates is currently enabled.",
"disabled": "Automatic Updates is currently disabled.",
"disableAction": "Disable Automatic Updates",
"enableAction": "Enable Automatic Updates"
"disableAction": "Disable Auto-update",
"enableAction": "Enable Auto-update"
},
"noUpdates": "No new updates available"
},
@@ -1591,7 +1658,7 @@
},
"uninstall": {
"title": "Uninstall",
"description": "This will uninstall the app immediately and remove all its data. The site will be inaccessible.",
"description": "This will uninstall the app immediately and remove the app's data. The site will be inaccessible.",
"backupWarning": "App backups are not removed and will be cleaned up based on the backup policy. You can resurrect this app from an existing app backup using the following <a target=\"_blank\" href=\"{{ importBackupDocsLink }}\">instructions</a>.",
"uninstallAction": "Uninstall"
}
@@ -1697,6 +1764,23 @@
"label": "Label",
"clearIconAction": "Clear Icon",
"clearIconDescription": "This will try to fetch the app's favicon on save."
},
"servicesTabTitle": "Services",
"turn": {
"title": "TURN Setup",
"enable": "Configure the app to use the built-in TURN server",
"disable": "Do not configure the app's TURN settings. The app's TURN settings are left alone. You can configure it inside the app."
},
"redis": {
"title": "Redis Configuration",
"enable": "Configure the app to use Redis",
"disable": "Disable Redis"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Admin Notes"
}
}
},
"login": {
@@ -1708,7 +1792,8 @@
"signInAction": "Sign in",
"resetPasswordAction": "Reset password",
"errorIncorrect2FAToken": "2FA token is invalid",
"errorInternal": "Internal error, try again later"
"errorInternal": "Internal error, try again later",
"loginWith": "Login with Cloudron"
},
"passwordReset": {
"title": "Password reset",
@@ -1789,7 +1874,8 @@
"es": "Spanish",
"ru": "Russian",
"pt": "Portuguese",
"da": "Danish"
"da": "Danish",
"id": "Indonesian"
},
"volumes": {
"title": "Volumes",
@@ -1826,7 +1912,11 @@
"mountStatus": "Mount Status",
"type": "Type",
"localDirectory": "Local Directory",
"remountActionTooltip": "Remount Volume"
"remountActionTooltip": "Remount Volume",
"editVolumeDialog": {
"title": "Edit volume {{ name }}"
},
"editActionTooltip": "Edit Volume"
},
"newLoginEmail": {
"subject": "[<%= cloudron %>] New login on your account",
@@ -1879,5 +1969,6 @@
"newClient": "New client",
"empty": "No clients yet"
}
}
},
"automation": "Automation"
}
+166 -47
View File
@@ -62,7 +62,7 @@
"switchToLoginAction": "¿Ya tienes una cuenta? Inicia sesión",
"switchToSignUpAction": "¿No tienes una cuenta todavía? Regístrate",
"createAccountAction": "Crear Cuenta",
"loginAction": "Iniciar sesión",
"loginAction": "Iniciar Sesión",
"errorWrongPassword": "Contraseña errónea",
"licenseCheckbox": "Acepto la <a href=\"{{ licenseLink }}\" target=\"_blank\">licencia de Cloudron</a>",
"chooseAnOption": "Por favor escoge una opción…",
@@ -72,7 +72,10 @@
"email": "Email",
"description": "Esta cuenta se usa para acceder a la App Store y administrar tu suscripción",
"titleLogin": "Iniciar sesión en Cloudron.io",
"titleSignUp": "Regístrate en Cloudron.io"
"titleSignUp": "Regístrate en Cloudron.io",
"setupWithTokenAction": "Ajustes",
"setupToken": "Configurar Token",
"titleToken": "Registrarse con el token de configuración"
},
"appNotFoundDialog": {
"description": "No hay aplicación <b>{{ appId }}</b> con versión <b>{{ version }}</b>.",
@@ -97,12 +100,14 @@
},
"action": {
"logs": "Registros",
"reboot": "Reiniciar"
"reboot": "Reiniciar",
"showLogs": "Mostrar registros"
},
"pagination": {
"perPageSelector": "Mostrar {{ n }} por página",
"next": "siguiente",
"prev": "anterior"
"prev": "anterior",
"itemCount": "Encontrado {{ count }}"
},
"table": {
"date": "Fecha"
@@ -115,7 +120,8 @@
"no": "No",
"close": "Cerrar",
"save": "Guardar",
"cancel": "Cancelar"
"cancel": "Cancelar",
"delete": "Borrar"
},
"logout": "Salir",
"offline": "Cloudron está desconectado. Reconectando…",
@@ -137,7 +143,10 @@
},
"enableAction": "Habilitar",
"statusEnabled": "Habilitado",
"statusDisabled": "Deshabilitado"
"statusDisabled": "Deshabilitado",
"loadingPlaceholder": "Cargando",
"settings": "Ajustes",
"saveAction": "Guardar"
},
"apps": {
"domainsFilterHeader": "Todos los Dominios",
@@ -157,12 +166,13 @@
"description": "¿Qué te parece si instalas algunas? Echa un vistazo a la <a href=\"{{ appStoreLink }}\"> Tienda de Aplicaciones</a>",
"title": "¡No hay aplicaciones instaladas todavía!"
},
"title": "Mis aplicaciones",
"title": "Mis Aplicaciones",
"groupsFilterHeader": "Todos los Grupos",
"auth": {
"nosso": "Inicia sesión con una cuenta dedicada",
"sso": "Inicia sesión con las credenciales de Cloudron",
"email": "Inicia sesión con el correo electrónico"
"email": "Inicia sesión con el correo electrónico",
"openid": "Iniciar sesión con Cloudron OpenID"
},
"addAppAction": "Añadir Aplicación",
"addAppproxyAction": "Añadir Proxi de la Aplicación",
@@ -201,7 +211,6 @@
"provider": "Proveedor",
"noopInfo": "La autentificación LDAP no está configurada.",
"subscriptionRequiredAction": "Configura tu Suscripción Ahora",
"subscriptionRequired": "Esta característica solo está habilitada en planes de pago.",
"description": "Cloudron sincronizará usuarios y grupos desde un servidor LDAP o ActiveDirectory externo. La verificación de la contraseña para autentificar a esos usuarios se realiza en el servidor externo. La sincronización no se ejecuta automáticamente, sino que debe activarse manualmente.",
"title": "Conectar un directorio externo",
"auth": "Auth",
@@ -214,7 +223,7 @@
"subscriptionRequired": "Estas características solo están habilitadas para planes de pago.",
"require2FACheckbox": "Requerir que los usuarios configuren 2FA",
"allowProfileEditCheckbox": "Permitir a los usuarios editar su nombre y correo",
"title": "Ajustes",
"title": "Ajustes de usuario",
"require2FAWarning": "Configura primero 2FA para tu cuenta para evitar que la bloqueen."
},
"groups": {
@@ -241,7 +250,6 @@
"setGhostTooltip": "Suplantar",
"invitationTooltip": "Invitar Usuario",
"mailmanagerTooltip": "Este usuario puede administrar usuarios y buzones de correo",
"makeLocalTooltip": "Hacer que el usuario sea local",
"count": "Total usuarios: {{ count }}"
},
"newUserAction": "Nuevo Usuario",
@@ -385,12 +393,6 @@
"all": "Todos los Usuarios",
"active": "Usuarios Activos",
"inactive": "Usuarios Inactivos"
},
"makeLocalDialog": {
"title": "Hacer este usuario local",
"description": "Esto migrará el usuario desde un directorio externo a Cloudron.",
"submitAction": "Hacer local",
"warning": "Se iniciará un restablecimiento de contraseña para establecer una contraseña local para este usuario."
}
},
"backups": {
@@ -400,7 +402,7 @@
"description": "Ten cuidado al cargar estos registros en un servidor público, ya que pueden contener información confidencial."
},
"listing": {
"stopTask": "Parar {{ taskType === 'backup' ? 'Backup' : 'Cleanup' }}",
"stopTask": "Parar Backup",
"backupNow": "Hacer Copia de Seguridad Ahora",
"cleanupBackups": "Borrar Copias de Seguridad",
"tooltipDownloadBackupConfig": "Descarga Configuración de la Copia de Seguridad",
@@ -517,7 +519,8 @@
"preserved": {
"description": "Copia de seguridad persistente independientemente de la política de retención",
"tooltip": "Esto también conservará el correo y las copias de seguridad de la aplicación {{ appsLength }}."
}
},
"remotePath": "Ruta remota"
}
},
"profile": {
@@ -607,7 +610,7 @@
"errorPasswordsDontMatch": "Las contraseñas no coinciden",
"errorPasswordRequired": "Se requiere una contraseña",
"newPasswordRepeat": "Repite nueva contraseña",
"newPassword": "Nueva contraseña",
"newPassword": "Nueva Contraseña",
"currentPassword": "Contraseña actual",
"title": "Cambia tu contraseña"
},
@@ -628,7 +631,8 @@
},
"changeBackgroundImage": {
"title": "Establecer imagen de fondo"
}
},
"enable2FANotAvailable": "No disponible para usuarios de una fuente de autentificación externa"
},
"emails": {
"eventlog": {
@@ -675,7 +679,8 @@
"info": "Esta configuración es global y se aplica a todos los dominios.",
"title": "Ajustes",
"acl": "Correo ACL",
"aclOverview": "{{ dnsblZonesCount }} zona(s) DNSBL"
"aclOverview": "{{ dnsblZonesCount }} zona(s) DNSBL",
"virtualAllMail": "Carpeta \"Todos los correos\""
},
"domains": {
"testEmailTooltip": "Enviar Email de prueba",
@@ -718,7 +723,7 @@
"manualInfo": "Agrega un registro A manualmente para el {{dominio}} a la IP pública de este Cloudron",
"locationPlaceholder": "Dejar vacío para usar el dominio desnudo",
"location": "Ubicación",
"description": "Cloudron realizará los cambios de DNS necesarios en todos los dominios y reiniciará el servidor de correo. Los clientes de correo electrónico de escritorio y móviles deben reconfigurarse para usar esta nueva ubicación como servidor IMAP y SMTP.",
"description": "Esto moverá el servidor IMAP y SMTP a la ubicación especificada.",
"title": "Cambiar ubicación del Servidor de Correo"
},
"aclDialog": {
@@ -746,6 +751,10 @@
},
"action": {
"queue": "Cola"
},
"changeVirtualAllMailDialog": {
"title": "Carpeta \"Todos los correos\"",
"description": "La carpeta \"Todos los correos\" es una carpeta única que contiene todos los correos electrónicos de su bandeja de entrada. La carpeta puede resultar útil en clientes de correo que no admiten la búsqueda recursiva de carpetas."
}
},
"branding": {
@@ -790,7 +799,8 @@
},
"dyndns": {
"description": "Habilite esta opción para mantener todos sus registros DNS sincronizados con una dirección IP cambiante. Esto es útil cuando Cloudron se ejecuta en una red con una dirección IP pública que cambia con frecuencia, como una conexión doméstica.",
"title": "DNS Dinámico"
"title": "DNS Dinámico",
"showLogsAction": "Mostrar registros"
},
"ipv4": {
"address": "Dirección IPv4"
@@ -802,7 +812,13 @@
},
"configureIpv6": {
"title": "Configurar Proveedor de IPv6"
}
},
"trustedIps": {
"summary": "{{ trustCount }} IPs confiables",
"description": "Se confiará en los encabezados HTTP de direcciones IP coincidentes",
"title": "Configurar IP confiables"
},
"trustedIpRanges": "Rangos e IPs confiables "
},
"services": {
"configure": {
@@ -822,7 +838,7 @@
"service": "Servicio",
"description": "Los servicios de Cloudron implementan funcionalidades como bases de datos, correo electrónico y autentificación.",
"title": "Servicios",
"refresh": "Actualizar"
"refresh": "Refrescar"
},
"settings": {
"appstoreAccount": {
@@ -901,7 +917,7 @@
"domains": {
"title": "Dominios y Certificados",
"changeDashboardDomain": {
"description": "Esto moverá el Panel y el Servidor de Correo al subdominio <code>my</code> del dominio seleccionado.",
"description": "Esto moverá el panel al subdominio <code>my</code> del dominio seleccionado.",
"showLogsAction": "Mostrar Registros",
"cancelAction": "Cancelar",
"changeAction": "Cambiar Dominio",
@@ -950,7 +966,16 @@
"vultrToken": "Token Vultr",
"jitsiHostname": "Ubicación de Jitsi",
"wellKnownDescription": "Cloudron utilizará los valores para responder a las URLs <code>/.well-known/</code> . Ten en cuenta que la aplicación debe estar disponible en el dominio desnudo <code>{{ domain }}</code> para que esto funcione. Consulta <a href=\"{{docsLink}}\" target=\"_blank\">esta documentación</a> para más información.",
"hetznerToken": "Token de Hetzner"
"hetznerToken": "Token de Hetzner",
"bunnyAccessKey": "Clave de acceso Bunny",
"cloudflareDefaultProxyStatus": "Habilitar proxy para nuevos registros DNS",
"porkbunApikey": "Clave API",
"porkbunSecretapikey": "Clave API secreta",
"dnsimpleAccessToken": "Token de acceso",
"ovhEndpoint": "Punto final",
"ovhConsumerKey": "Clave del consumidor",
"ovhAppKey": "Clave de Aplicación",
"ovhAppSecret": "Clave Secreta Aplicación"
},
"subscriptionRequired": {
"setupAction": "Configura tu suscripción",
@@ -982,7 +1007,8 @@
"domainWellKnown": {
"title": "Ubicaciones Well-known de {{ domain }}"
},
"tooltipWellKnown": "Establece las ubicaciones Well-Known"
"tooltipWellKnown": "Establece las ubicaciones Well-Known",
"count": "Dominios totales: {{ count }}"
},
"app": {
"appInfo": {
@@ -1038,7 +1064,8 @@
"dataDirPlaceholder": "Dejar vacío para usar la plataforma predeterminada",
"description": "Si el servidor se está quedando sin espacio en disco, usa esto para mover los datos de la aplicación a un <a href=\"/#/volumes\">volumen</a>. Cualquier dato aquí es parte de la copia de seguridad de la aplicación.",
"moveAction": "Mover datos",
"diskUsage": "Actualmente, la aplicación está usando {{ size }} de almacenamiento (hasta el {{ date }})."
"diskUsage": "Actualmente, la aplicación está usando {{ size }} de almacenamiento (hasta el {{ date }}).",
"mountTypeWarning": "El sistema de archivos de destino debe admitir permisos y propiedad de los archivos para que el traslado funcione"
}
},
"logsActionTooltip": "Registros",
@@ -1098,7 +1125,8 @@
"saveAction": "Guardar",
"description": "La configuración de esta opción anulará cualquier encabezado CSP enviado por la propia aplicación",
"title": "Política de seguridad de contenido"
}
},
"hstsPreload": "Habilitar la carga previa de HSTS para este sitio y todos los subdominios"
},
"email": {
"from": {
@@ -1207,7 +1235,8 @@
"description": "Todos los datos generados entre ahora y la última copia de seguridad conocida se perderán de forma irrevocable. Se recomienda crear una copia de seguridad de los datos actuales antes de intentar una importación.",
"title": "Importar Backup",
"uploadAction": "Subir Configuración de Backup",
"importAction": "Importar"
"importAction": "Importar",
"remotePath": "Ruta del Backup"
},
"restoreDialog": {
"warning": "Todos los datos generados entre ahora y la última copia de seguridad conocida se perderán de forma irrevocable. Se recomienda crear una copia de seguridad de los datos actuales antes de intentar una restauración.",
@@ -1310,6 +1339,17 @@
"label": "Etiqueta",
"clearIconAction": "Borrar icono",
"clearIconDescription": "Esto intentará obtener el favicon de la aplicación al guardar."
},
"servicesTabTitle": "Servicios",
"turn": {
"title": "Configuración de TURN",
"enable": "Configura la aplicación para utilizar el servidor TURN integrado",
"disable": "No configures los ajustes de la aplicación TURN. Su configuración se deja como está. Puedes hacer los ajustes dentro de la aplicación."
},
"redis": {
"title": "Configuración de Redis",
"enable": "Configura la aplicación para usar Redis",
"disable": "Deshabilitar Redis"
}
},
"lang": {
@@ -1324,7 +1364,8 @@
"en": "Inglés",
"es": "Español",
"ru": "Ruso",
"pt": "Portugués"
"pt": "Portugués",
"da": "Danés"
},
"system": {
"title": "Información del Sistema",
@@ -1345,9 +1386,22 @@
"title": "Uso del Disco",
"usedInfo": "{{ used }} usados de {{ size }}",
"uninstalledApp": "Aplicación desinstalada",
"volumeContent": "Este disco es el volumen <code>{{ name }}</code>"
"volumeContent": "Este disco es el volumen <code>{{ name }}</code>",
"diskSpeed": "Velocidad: {{ speed }} MB/seg"
},
"selectPeriodLabel": "Seleccionar Periodo"
"selectPeriodLabel": "Seleccionar Periodo",
"info": {
"title": "Información",
"memory": "Memoria",
"uptime": "Tiempo de actividad",
"activationTime": "Tiempo de creación de Cloudron",
"platformVersion": "Versión de plataforma",
"product": "Producto",
"vendor": "Vendedor"
},
"graphs": {
"title": "Gráficos"
}
},
"support": {
"remoteSupport": {
@@ -1376,9 +1430,14 @@
"sshCheckbox": "Permitir que los ingenieros de soporte se conecten a este servidor a través de SSH",
"emailPlaceholder": "Si es necesario, proporciona una dirección de correo electrónico diferente de la anterior para contactarte",
"emailVerifyAction": "Verificar ahora",
"emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte."
"emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte.",
"typeBilling": "Problema de facturación"
},
"title": "Soporte"
"title": "Soporte",
"help": {
"title": "Ayuda",
"description": "Utiliza los siguientes recursos para obtener ayuda y soporte:\n* [Foro de Cloudron]({{ forumLink }}) - Utiliza las categorías específicas de Soporte y Aplicación si tiene preguntas.\n* [Base de conocimientos y documentos de Cloudron]({{ docsLink }})\n* [API y empaquetado de aplicaciones personalizadas]({{ packagingLink }})\n"
}
},
"volumes": {
"removeVolumeDialog": {
@@ -1403,7 +1462,7 @@
"removeVolumeActionTooltip": "Borrar Volumen",
"openFileManagerActionTooltip": "Abrir Gestor de Archivos",
"name": "Nombre",
"hostPath": "Punto de montaje",
"hostPath": "Objetivo",
"addVolumeAction": "Añade un Volumen",
"title": "Volúmenes",
"description": "Los volúmenes son sistemas de archivos locales o remotos. Se pueden usar como el almacenamiento de datos principal de una aplicación o como una ubicación de almacenamiento compartida entre aplicaciones.",
@@ -1415,7 +1474,11 @@
"title": "Actualizar Volumen {{ volume }}"
},
"tooltipEdit": "Editar Volumen",
"remountActionTooltip": "Volver a montar Volumen"
"remountActionTooltip": "Volver a montar Volumen",
"editVolumeDialog": {
"title": "Editar volumen {{ name }}"
},
"editActionTooltip": "Editar Volumen"
},
"eventlog": {
"filterAllEvents": "Todos los Eventos",
@@ -1449,7 +1512,8 @@
"renameDialog": {
"title": "Renombrar {{ fileName }}",
"newName": "Nuevo Nombre",
"rename": "Renombrar"
"rename": "Renombrar",
"reallyOverwrite": "Ya existe un archivo con ese nombre. ¿Sobrescribir el archivo existente?"
},
"chownDialog": {
"newOwner": "Nuevo propietario",
@@ -1494,7 +1558,8 @@
"copy": "Copiar",
"paste": "Pegar",
"selectAll": "Seleccionar todo",
"download": "Descargar"
"download": "Descargar",
"open": "Abrir"
},
"mtime": "Modificado"
},
@@ -1509,12 +1574,26 @@
},
"extract": {
"error": "La extracción falló: {{ message }}"
}
},
"extractionInProgress": "Extracción en progreso",
"uploader": {
"exitWarning": "Subida en progreso... ¿quieres realmente cerrar esta página?",
"uploading": "Subiendo"
},
"textEditor": {
"undo": "Deshacer",
"redo": "Rehacer",
"save": "Guardar"
},
"pasteInProgress": "Pegado en progreso",
"deleteInProgress": "Borrado en progreso"
},
"logs": {
"download": "Descarga los Registros Completos",
"clear": "Borrar Vista",
"title": "Registros"
"title": "Registros",
"notFoundError": "No existe esa tarea o aplicación",
"logsGoneError": "Archivo(s) de registro no encontrados"
},
"email": {
"signature": {
@@ -1750,7 +1829,7 @@
"newPassword": {
"errorLength": "La contraseña debe tener al menos 8 y un máximo de 265 caracteres",
"title": "Establecer nueva contraseña",
"password": "Nueva contraseña",
"password": "Nueva Contraseña",
"passwordRepeat": "Repetir Contraseña",
"errorMismatch": "Las contraseñas no coinciden"
},
@@ -1810,8 +1889,10 @@
"username": "Nombre de usuario",
"password": "Contraseña",
"2faToken": "Token 2FA (si está habilitado)",
"signInAction": "Iniciar sesión",
"resetPasswordAction": "Resetear contraseña"
"signInAction": "Iniciar Sesión",
"resetPasswordAction": "Resetear contraseña",
"errorIncorrect2FAToken": "El token 2FA es inválido",
"errorInternal": "Error interno, prueba de nuevo más tarde"
},
"newLoginEmail": {
"subject": "[<% = cloudron%>] Nuevo inicio de sesión en tu cuenta",
@@ -1827,5 +1908,43 @@
"mounts": {
"description": "Las aplicaciones pueden acceder a <a href=\"/#/volumes\">volúmenes</a> montados a través del directorio <code>/media/{volume name}</code>. Estos datos no están incluidos en la copia de seguridad de la aplicación."
}
}
},
"oidc": {
"newClientDialog": {
"title": "Añadir Cliente",
"description": "Agrega una nueva configuración de cliente de conexión de OpenID.",
"createAction": "Crear"
},
"client": {
"name": "Nombre",
"id": "ID de cliente",
"secret": "Secreto de cliente",
"signingAlgorithm": "Algoritmo de firma",
"loginRedirectUri": "URL de devolución de llamada de inicio de sesión (separadas por comas si hay más de una)",
"logoutRedirectUri": "URL de devolución de llamada de cierre de sesión (opcional)"
},
"title": "Proveedor de conexión OpenID",
"description": "Cloudron puede actuar como proveedor de OpenID Connect para aplicaciones internas y servicios externos.",
"editClientDialog": {
"title": "Editar cliente {{ client }}"
},
"deleteClientDialog": {
"title": "¿Realmente quieres borrar el cliente {{ client }}?",
"description": "Esto desconectará todas las aplicaciones OpenID externas de este Cloudron que utilicen este ID de cliente."
},
"env": {
"discoveryUrl": "URL de descubrimiento",
"logoutUrl": "URL de cierre de sesión",
"profileEndpoint": "Punto final del perfil",
"keysEndpoint": "Punto final de claves",
"tokenEndpoint": "Punto final del Token",
"authEndpoint": "Punto final de autenticación"
},
"clients": {
"title": "Clientes",
"newClient": "Nuevo cliente",
"empty": "No hay clientes aún"
}
},
"automation": "Automatización"
}
+193 -52
View File
@@ -22,7 +22,8 @@
"auth": {
"nosso": "Se connecter avec un compte dédié",
"email": "Se connecter avec une adresse email",
"sso": "Se connecter avec vos identifiants Cloudron"
"sso": "Se connecter avec vos identifiants Cloudron",
"openid": "Se connecter avec Cloudron OpenID"
},
"addAppAction": "Ajouter Application",
"addAppproxyAction": "Ajouter Proxy d'application",
@@ -39,7 +40,8 @@
"cancel": "Annuler",
"save": "Sauvegarder",
"no": "Non",
"yes": "Oui"
"yes": "Oui",
"delete": "Supprimer"
},
"username": "Nom d'utilisateur",
"actions": "Actions",
@@ -50,11 +52,13 @@
"pagination": {
"prev": "préc.",
"next": "suiv.",
"perPageSelector": "Afficher {{ n }} par page"
"perPageSelector": "Afficher {{ n }} par page",
"itemCount": "Trouvé {{ count }}"
},
"action": {
"logs": "Journaux",
"reboot": "Redémarrer"
"reboot": "Redémarrer",
"showLogs": "Afficher Journaux"
},
"rebootDialog": {
"rebootAction": "Redémarrer maintenant",
@@ -85,7 +89,10 @@
"users": "Utilisateurs"
},
"disableAction": "Désactiver",
"enableAction": "Activer"
"enableAction": "Activer",
"loadingPlaceholder": "Chargement",
"settings": "Paramètres",
"saveAction": "Sauvegarde"
},
"users": {
"title": "Annuaire des utilisateurs",
@@ -106,8 +113,7 @@
"setGhostTooltip": "Emprunter l'identité",
"invitationTooltip": "Envoyer une invitation à l'utilisateur",
"mailmanagerTooltip": "Cet utilisateur peut gérer les utilisateurs et les boîtes mail",
"count": "Total des utilisateurs : {{ count }}",
"makeLocalTooltip": "Rendre l'utilisateur local"
"count": "Total des utilisateurs : {{ count }}"
},
"newUserAction": "Nouvel utilisateur",
"groups": {
@@ -118,7 +124,7 @@
"externalLdapTooltip": "Depuis un annuaire LDAP externe"
},
"settings": {
"title": "Paramètres",
"title": "Paramètres Utilisateur",
"allowProfileEditCheckbox": "Autoriser les utilisateurs à modifier leur nom et leur adresse email",
"saveAction": "Enregistrer",
"subscriptionRequired": "Ces fonctionnalités sont uniquement disponibles dans la version payante.",
@@ -138,7 +144,6 @@
"groupnameField": "Champ nom du groupe",
"syncGroups": "Groupes synchronisés",
"filter": "Filtre",
"subscriptionRequired": "Cette fonctionnalité est disponible uniquement dans la version payante.",
"acceptSelfSignedCert": "Accepter le certificat auto-signé",
"usernameField": "Champ nom d'utilisateur",
"groupFilter": "Filtre des groupes",
@@ -151,7 +156,8 @@
"description": "Cloudron va importer les utilisateurs et les groupes depuis un annuaire LDAP externe ou Active Directory. La vérification du mot de passe pour l'authentification de ces utilisateurs se fait via le serveur externe. La synchronisation ne s'exécute pas automatiquement, elle doit être lancée manuellement.",
"subscriptionRequiredAction": "Paramétrer mon abonnement maintenant",
"providerOther": "Autre",
"providerDisabled": "Désactivé"
"providerDisabled": "Désactivé",
"disableWarning": "La source d'authentification de tous les utilisateurs existants sera réinitialisée pour utiliser la base de données locale."
},
"role": {
"usermanager": "Gestionnaire",
@@ -183,7 +189,9 @@
"errorInvalidEmail": "Cette adresse email est invalide",
"usernamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la première connexion",
"fallbackEmailPlaceholder": "Optionnel. Si laissé vide, ce sera l'adresse email principale qui sera utilisée",
"displayNamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la création du compte"
"displayNamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la création du compte",
"external2FA": "La configuration multi-facteur est gérée par une source externe",
"ldapGroups": "Groupes LDAP"
},
"group": {
"errorNameRequired": "Un nom est nécessaire",
@@ -264,15 +272,9 @@
"title": "Lien d'invitation envoyé",
"body": "Email envoyé à {{ email }}"
},
"makeLocalDialog": {
"description": "Cela migrera l'utilisateur du répertoire externe vers le Cloudron.",
"submitAction": "Rendre local",
"title": "Rendre cet utilisateur local",
"warning": "Une réinitialisation du mot de passe sera initiée pour définir un mot de passe local pour cet utilisateur."
},
"exposedLdap": {
"secret": {
"label": "Mot de passe de liaison",
"label": "Mot de passe Bind",
"description": "Toutes les requêtes LDAP doivent être authentifiées avec ce secret et le DN utilisateur <i>{{ userDN }}</i>",
"url": "URL du serveur"
},
@@ -283,7 +285,8 @@
"placeholder": "Adresse IP séparée par ligne ou sous-réseau",
"label": "Accès restreint"
},
"title": "Serveur d'annuaire"
"title": "Serveur d'annuaire",
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé sur le domaine du tableau de bord pour accéder au service LDAP"
},
"userImportDialog": {
"title": "Importer des utilisateurs",
@@ -346,7 +349,10 @@
"changeEmail": {
"errorEmailInvalid": "Cette adresse email est invalide",
"title": "Modifier l'adresse email principale",
"errorEmailRequired": "Une adresse email valide est nécessaire"
"errorEmailRequired": "Une adresse email valide est nécessaire",
"email": "Nouvelle adresse e-mail",
"password": "Mot de passe pour confirmation",
"errorWrongPassword": "Mauvais mot de passe"
},
"createAppPassword": {
"copyNow": "Veillez à copier le mot de passe maintenant. Il ne s'affichera plus pour des raisons de sécurité.",
@@ -417,7 +423,8 @@
},
"changeBackgroundImage": {
"title": "Définir l'image d'arrière-plan"
}
},
"enable2FANotAvailable": "Non disponible pour les utilisateurs provenant d'une source d'authentification externe"
},
"backups": {
"title": "Sauvegardes",
@@ -500,7 +507,8 @@
"port": "Port",
"cifsSealSupport": "Utilisez le cryptage du sceau. Nécessite au moins SMB v3",
"chown": "Le système de fichiers distant prend en charge chown",
"encryptedFilenames": "Crypter les noms de fichiers"
"encryptedFilenames": "Crypter les noms de fichiers",
"encryptFilenames": "Chiffré les nom de fichiers"
},
"backupDetails": {
"title": "Informations sur la sauvegarde",
@@ -521,7 +529,7 @@
"tooltipDownloadBackupConfig": "Télécharger le fichier de configuration de la sauvegarde",
"cleanupBackups": "Supprimer toutes les sauvegardes",
"backupNow": "Faire une sauvegarder maintenant",
"stopTask": "Interrompre {{ taskType === 'backup' ? 'la sauvegarde' : 'Nettoyer' }}",
"stopTask": "Interrompre la sauvegarde",
"noBackups": "Aucune sauvegarde n'a encore été effectuée.",
"contents": "Contenu",
"version": "Version",
@@ -542,7 +550,8 @@
"preserved": {
"description": "Sauvegarde persistante quelle que soit la politique de rétention",
"tooltip": "Cela préservera également le courrier et les sauvegardes d'application {{ appsLength }}."
}
},
"remotePath": "Chemin d'accès à distance"
}
},
"emails": {
@@ -552,7 +561,7 @@
"location": "Emplacement",
"title": "Changer l'emplacement du serveur de messagerie",
"locationPlaceholder": "Laisser vide pour utiliser le nom de domaine nu",
"description": "Cloudron effectuera les modifications DNS nécessaires pour l'ensemble des domaines et redémarrera le serveur de messagerie. Les clients de messagerie sur ordinateur et sur mobile doivent être reconfigurés pour que ce nouvel emplacement soit utilisé comme serveur IMAP et SMTP."
"description": "Cela déplacera le serveur IMAP et SMTP vers l'emplacement choisi."
},
"eventlog": {
"details": "Détails",
@@ -598,7 +607,8 @@
"solrEnabled": "Activé",
"solrRunning": "Actif",
"acl": "Adresse ACL (liste de contrôle d'accès)",
"aclOverview": "{{ dnsblZonesCount }} liste(s) DNSBL"
"aclOverview": "{{ dnsblZonesCount }} liste(s) DNSBL",
"virtualAllMail": "Dossier \"Tout les Emails\""
},
"domains": {
"disabled": "Désactivé",
@@ -661,6 +671,10 @@
},
"action": {
"queue": "File d'attente"
},
"changeVirtualAllMailDialog": {
"title": "Dossier \"Tout les Emails\"",
"description": "Le dossier \"Tout les E-mails\" est un dossier contenant tout les e-mails de votre boite de réception. Ce dossier peut être utile pour les clients e-mails ne supportant pas les dossiers imbriqués."
}
},
"network": {
@@ -677,7 +691,8 @@
},
"dyndns": {
"title": "DNS dynamique",
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique."
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique.",
"showLogsAction": "Afficher les journaux"
},
"ip": {
"configure": "Paramétrer",
@@ -703,7 +718,13 @@
"address": "Adresse IPv6",
"title": "IPv6",
"description": "Cloudron utilise cette adresse IPv6 pour configurer les enregistrements DNS AAAA.\n"
}
},
"trustedIps": {
"description": "Les en-têtes HTTP provenant d'adresses IP correspondantes seront considérés comme sûrs",
"title": "Configurer les adresses IP de Confiance",
"summary": "{{ trustCount }} adresses IP de confiance"
},
"trustedIpRanges": "Adresses et plages d'IP de confiance. "
},
"settings": {
"title": "Paramètres",
@@ -807,7 +828,12 @@
"subscriptionRequiredDescription": "Vous devriez trouver votre réponse dans notre <a href=\"{{ supportViewLink }}\" target=\"_blank\">documentation</a>, vous pouvez également poser votre question sur le <a href=\"{{ forumLink }}\" target=\"_blank\">forum</a>.",
"title": "Ticket",
"emailVerifyAction": "Confirmer maintenant",
"emailNotVerified": "L'adresse email de votre compte Cloudron.io {{ email }} n'a pas encore été confirmée. Veuillez la valider pour ouvrir des tickets d'incident."
"emailNotVerified": "L'adresse email de votre compte Cloudron.io {{ email }} n'a pas encore été confirmée. Veuillez la valider pour ouvrir des tickets d'incident.",
"typeBilling": "Problème de facturation"
},
"help": {
"description": "Veuillez utiliser les ressources suivantes pour obtenir de l'aide\n* [Forum Cloudron]({{ forumLink }}) - Veuillez utiliser les catégories d'assistance et d'applications spécifiques pour vos questions.\n* [Documentation et base de connaissances de Cloudron]({{ docsLink }})\n* [Packaging d'applications personnalisées et API]({{ packagingLink }})\n",
"title": "Aide"
}
},
"notifications": {
@@ -857,7 +883,10 @@
"titleLogin": "Se connecter à Cloudron.io",
"description": "Ce compte permet d'accéder à l'App Store et de gérer votre abonnement",
"2faToken": "Jeton 2FA (si activé)",
"intendedUse": "Type d'usage"
"intendedUse": "Type d'usage",
"setupWithTokenAction": "Configuration",
"setupToken": "Configuration Jeton",
"titleToken": "Se connecter avec un Jeton"
},
"title": "App Store",
"appNotFoundDialog": {
@@ -912,7 +941,8 @@
"packageVersion": "Version du package",
"appId": "ID de l'application",
"description": "Nom et version de l'application",
"title": "Informations sur l'application"
"title": "Informations sur l'application",
"repository": "Dépot de paquets"
},
"auto": {
"title": "Mises à jour automatiques",
@@ -921,7 +951,8 @@
"disabled": "Les mises à jour automatiques sont actuellement désactivées.",
"enabled": "Les mises à jour automatiques sont actuellement activées.",
"description": "Cloudron vérifie régulièrement les mises à jour disponibles dans l'App Store. Si vous désactivez les mises à jour automatiques, veillez à les faire manuellement."
}
},
"noUpdates": "Aucune nouvelle mise à jour disponible"
},
"backupsTabTitle": "Sauvegardes",
"storage": {
@@ -931,13 +962,20 @@
"noMounts": "Aucun volume n'est monté.",
"volume": "Volume",
"readOnly": "En lecture seule",
"title": "Montages"
"title": "Montages",
"permissions": {
"label": "Permissions",
"readOnly": "Lecture seule",
"readWrite": "Lecture et écriture"
}
},
"appdata": {
"moveAction": "Déplacer les données",
"dataDirPlaceholder": "Laisser vide pour utiliser la plateforme par défaut",
"description": "Si le serveur manque d'espace disque, utilisez-le pour déplacer les données de l'application vers un <a href=\"/#/volumes\">volume</a>. Toutes les données ici font partie de la sauvegarde de l'application.",
"title": "Données de l'application"
"title": "Données de l'application",
"diskUsage": "L'application utilise actuellement {{ size }} de stockage (en date du {{ date }}).",
"mountTypeWarning": "Le système de fichiers de destination doit prendre en charge les autorisations et la propriété des fichiers pour que le transfert fonctionne"
}
},
"security": {
@@ -950,7 +988,8 @@
"disableIndexingAction": "Désactiver l'indexation",
"txtPlaceholder": "Laisser vide pour autoriser les robots à indexer cette application",
"title": "Robots.txt"
}
},
"hstsPreload": "Activer HSTS pour ce site et tous les sous-domaines"
},
"updateDialog": {
"updateAction": "Mettre à jour",
@@ -1044,7 +1083,8 @@
"uploadAction": "Charger le fichier de configuration de la sauvegarde",
"description": "Toutes les données créées depuis la dernière sauvegarde connue seront définitivement perdues. Il est fortement recommandé de sauvegarder les données actuelles avant de lancer un import.",
"title": "Importer la sauvegarde",
"importAction": "Importer"
"importAction": "Importer",
"remotePath": "Chemin de la sauvegarde"
},
"repairDialog": {
"fromBackup": "Restaurer depuis la sauvegarde :",
@@ -1115,7 +1155,8 @@
"time": "Créée le",
"packageVersion": "Version du package",
"description": "Les sauvegardes sont des instantanés complets de l'application. Vous pouvez utiliser les sauvegardes pour restaurer ou cloner l'application.",
"title": "Sauvegardes"
"title": "Sauvegardes",
"downloadBackupTooltip": "Télécharger la sauvegarde"
}
},
"graphs": {
@@ -1127,7 +1168,9 @@
"12h": "12 heures",
"6h": "6 heures"
},
"diskTitle": "Utilisation du disque"
"diskTitle": "Utilisation du disque",
"diskIOTotal": "total: lecture {{ read }} / écriture {{ write }}",
"networkIOTotal": "total: entrant {{ inbound }} / sortant {{ outbound }}"
},
"resources": {
"memory": {
@@ -1216,12 +1259,25 @@
"label": "Étiquette",
"clearIconAction": "Effacer Icône",
"clearIconDescription": "Cela récupérera le favicon de l'application."
},
"servicesTabTitle": "Services",
"turn": {
"enable": "Configurer l'application pour utiliser le serveur TURN intégré",
"disable": "Ne pas configurer les paramètres TURN de l'application. Les paramètres TURN de l'application sont laissés à leur valeurs par défaut. Vous pouvez les configurer à l'intérieur de l'application.",
"title": "Configuration de TURN"
},
"redis": {
"title": "Configuration de Redis",
"enable": "Configurer l'application pour utiliser Redis",
"disable": "Désactiver Redis"
}
},
"logs": {
"title": "Journaux",
"download": "Télécharger l'ensemble des journaux",
"clear": "Nettoyer"
"clear": "Nettoyer",
"notFoundError": "Aucune tâche ou application de ce type",
"logsGoneError": "Fichier(s) journal(s) introuvable(s)"
},
"volumes": {
"name": "Nom",
@@ -1258,7 +1314,11 @@
"title": "Mettre à jour le volume {{ volume }}"
},
"mountStatus": "Statut du montage",
"type": "Type"
"type": "Type",
"editVolumeDialog": {
"title": "Modifier le volume {{ name }}"
},
"editActionTooltip": "Modifier le volume"
},
"lang": {
"en": "Anglais",
@@ -1272,7 +1332,8 @@
"zh_Hans": "Chinois (Simplifié)",
"es": "Espagnol",
"ru": "Russe",
"pt": "Portugais"
"pt": "Portugais",
"da": "Danois"
},
"email": {
"mailboxboxDialog": {
@@ -1517,10 +1578,19 @@
"vultrToken": "Token Vultr",
"wellKnownDescription": "Les valeurs seront utilisées par Cloudron pour répondre aux URL <code>/.well-known/</code>. Notez qu'une application doit être disponible sur le domaine nu <code>{{ domaine }}</code> pour que cela fonctionne. Consultez la <a href=\"{{docsLink}}\" target=\"_blank\">documentation</a> pour plus d'informations.",
"hetznerToken": "Token Hetzner",
"jitsiHostname": "Emplacement de Jitsi"
"jitsiHostname": "Emplacement de Jitsi",
"cloudflareDefaultProxyStatus": "Activer le proxy pour les nouveaux enregistrements DNS",
"porkbunApikey": "Clé API",
"porkbunSecretapikey": "Clé API secrète",
"dnsimpleAccessToken": "Jeton d'accès",
"ovhEndpoint": "Point de terminaison",
"bunnyAccessKey": "Bunny Access Key",
"ovhConsumerKey": "Consumer Key",
"ovhAppKey": "Application Key",
"ovhAppSecret": "Application Secret"
},
"changeDashboardDomain": {
"description": "Cette action entraînera le déplacement du tableau de bord et du serveur de messagerie vers le sous-domaine <code>my</code> du domaine sélectionné.",
"description": "Cette action entraînera le déplacement du tableau de bord vers le sous-domaine <code>my</code> du domaine sélectionné.",
"showLogsAction": "Afficher les journaux",
"cancelAction": "Annuler",
"changeAction": "Changer le domaine",
@@ -1546,7 +1616,8 @@
"domainWellKnown": {
"title": "Emplacements Well-Known de {{ domain }}"
},
"tooltipWellKnown": "Définir des emplacements Well-Known"
"tooltipWellKnown": "Définir des emplacements Well-Known",
"count": "Nombre de domaines: {{ count }}"
},
"branding": {
"footer": {
@@ -1627,7 +1698,8 @@
"download": "Télécharger",
"extract": "Extraire ici",
"chown": "Modifier la propriété",
"rename": "Renommer"
"rename": "Renommer",
"open": "Ouvrir"
},
"symlink": "Symlink vers {{ target }}",
"empty": "Aucun fichier",
@@ -1673,7 +1745,8 @@
"renameDialog": {
"rename": "Renommer",
"newName": "Nouveau nom",
"title": "Renommer {{ fileName }}"
"title": "Renommer {{ fileName }}",
"reallyOverwrite": "Un fichier portant ce nom existe déjà. Écraser le fichier existant?"
},
"newFileDialog": {
"create": "Créer",
@@ -1686,7 +1759,19 @@
"removeDialog": {
"reallyDelete": "Voulez-vous vraiment supprimer ces fichiers ?"
},
"title": "Gestionnaire de fichiers"
"title": "Gestionnaire de fichiers",
"uploader": {
"uploading": "Téléversement",
"exitWarning": "Téléversement toujours en cours. Voulez-vous vraiment fermer cette page?"
},
"deleteInProgress": "Suppression en cours",
"textEditor": {
"undo": "Annuler",
"redo": "Refaire",
"save": "Enregistrer"
},
"extractionInProgress": "Décompression en cours",
"pasteInProgress": "Collage en cours"
},
"terminal": {
"contextmenu": {
@@ -1727,7 +1812,8 @@
"selectPeriodLabel": "Période sélectionnée",
"cpuUsage": {
"graphTitle": "Pourcentage",
"title": "Utilisation du microprocesseur"
"title": "Utilisation du microprocesseur",
"graphSubtext": "Seules les applications utilisant plus de {{ threshold }} de processeur sont affichées"
},
"systemMemory": {
"graphSubtext": "Seules les applications utilisant plus de 1GB de mémoire sont affichées",
@@ -1739,9 +1825,24 @@
"usageInfo": "{{ available | prettyDiskSize }}</b> sur <b>{{ size | prettyDiskSize }}</b> disponible(s)",
"mountedAt": "{{ filesystem }} <small>monté sur</small> {{ mountpoint }}",
"title": "Utilisation du disque",
"usedInfo": "{{ used }} utilisé de {{ size }}"
"usedInfo": "{{ used }} utilisé de {{ size }}",
"uninstalledApp": "Désinstaller App",
"diskSpeed": "Vitesse : {{ speed }} MB/sec",
"volumeContent": "Ce disque est le volume <code>{{ name }}</code>"
},
"title": "Info système"
"title": "Info système",
"info": {
"platformVersion": "Version de la Plate-forme",
"vendor": "Vendeur",
"product": "Produit",
"memory": "Mémoire",
"uptime": "Durée de fonctionnement",
"activationTime": "Heure de création de Cloudron",
"title": "Informations"
},
"graphs": {
"title": "Graphiques"
}
},
"services": {
"refresh": "Rafraîchir",
@@ -1796,7 +1897,9 @@
"password": "Mot de passe",
"username": "Nom d'utilisateur",
"errorIncorrectCredentials": "Nom d'utilisateur ou mot de passe incorrect",
"loginTo": "Se connecter à"
"loginTo": "Se connecter à",
"errorIncorrect2FAToken": "Le jeton 2FA n'est pas valide",
"errorInternal": "Erreur interne, réessayer ultérieurement"
},
"newLoginEmail": {
"salutation": "Bonjour <%= user %>,",
@@ -1812,5 +1915,43 @@
"mounts": {
"description": "Les applications peuvent accéder aux <a href=\"/#/volumes\">volumes</a> montés via le répertoire <code>/media/{volume name}</code>. Ces données ne sont pas incluses dans la sauvegarde de l'application."
}
}
},
"oidc": {
"client": {
"signingAlgorithm": "Algorithme de signature",
"name": "Nom",
"id": "ID du client",
"secret": "Secret du client",
"loginRedirectUri": "Url de retour post connexion (séparées par des virgules s'il y en a plus d'une)",
"logoutRedirectUri": "Url de retour après déconnexion (facultatif)"
},
"description": "Cloudron peut agir en tant que fournisseur OpenID Connect pour les applications internes et les services externes.",
"deleteClientDialog": {
"description": "Cela déconnectera toutes les applications OpenID externes de ce Cloudron utilisant cet identifiant client.",
"title": "Supprimer définitivement le client {{ client }}?"
},
"newClientDialog": {
"title": "Ajouter un client",
"description": "Ajouter de nouveaux paramètres pour le client OpenID connect.",
"createAction": "Créer"
},
"title": "OpenID Connect Provider",
"editClientDialog": {
"title": "Modifier le client {{ client }}"
},
"env": {
"discoveryUrl": "URL de découverte",
"logoutUrl": "URL de déconnexion",
"profileEndpoint": "Point de terminaison pour le profil",
"keysEndpoint": "Point de terminaison pour les clés",
"tokenEndpoint": "Point de terminaison pour les jetons",
"authEndpoint": "Point de terminaison pour l'authentification"
},
"clients": {
"title": "Clients",
"newClient": "Nouveau client",
"empty": "Aucun client pour le moment"
}
},
"automation": "Automatisation"
}
+1 -2
View File
@@ -727,7 +727,7 @@
"title": "Logs"
},
"listing": {
"stopTask": "Ferma {{ taskType === 'backup' ? 'Backup' : 'Pulizia' }}",
"stopTask": "Ferma Backup",
"backupNow": "Esegui il backup adesso",
"cleanupBackups": "Pulizia Backup",
"tooltipDownloadBackupConfig": "Scarica Configurazione Backup",
@@ -932,7 +932,6 @@
"server": "URL del Server",
"noopInfo": "L'autenticazione LDAP non è configurata.",
"subscriptionRequiredAction": "Attiva un piano a pagamento",
"subscriptionRequired": "Questa funzionalità è disponibile solo nei piani a pagamento.",
"description": "Cloudron sincronizzerà utenti e gruppi da un server LDAP o ActiveDirectory esterni. La verifica della password per l'autenticazione di tali utenti viene eseguita sul server esterno. La sincronizzazione non viene eseguita automaticamente ma deve essere attivata manualmente.",
"auth": "Auth",
"groupnameField": "Campo Groupname",
+199 -67
View File
@@ -22,13 +22,17 @@
"auth": {
"nosso": "Log in met specifiek account",
"sso": "Log in met Cloudron aanmeldgegevens",
"email": "Log in met e-mailadres"
"email": "Log in met e-mailadres",
"openid": "Log in met Cloudron OpenID"
},
"addAppAction": "App toevoegen",
"addAppproxyAction": "App Proxy toevoegen",
"addApplinkAction": "App link toevoegen",
"filter": {
"clearAll": "Alles verwijderen"
},
"apps": {
"count": "Totaal apps: {{ count }}"
}
},
"main": {
@@ -38,7 +42,8 @@
"save": "Opslaan",
"close": "Sluiten",
"no": "Nee",
"yes": "Ja"
"yes": "Ja",
"delete": "Verwijder"
},
"username": "Gebruikersnaam",
"displayName": "Naam",
@@ -54,7 +59,8 @@
},
"action": {
"reboot": "Herstart",
"logs": "Logbestanden"
"logs": "Logbestanden",
"showLogs": "Toon logbestanden"
},
"clipboard": {
"copied": "Gekopieerd naar klembord",
@@ -78,7 +84,8 @@
"justNow": "zojuist",
"yeserday": "Gisteren",
"minutesAgo": "{{ m }} minuten geleden",
"hoursAgo": "{{ h }} uur geleden"
"hoursAgo": "{{ h }} uur geleden",
"never": "Nooit"
},
"navbar": {
"users": "Gebruikers"
@@ -87,7 +94,9 @@
"enableAction": "Inschakelen",
"statusEnabled": "Ingeschakeld",
"statusDisabled": "Uitgeschakeld",
"loadingPlaceholder": "Laden"
"loadingPlaceholder": "Laden",
"settings": "Instellingen",
"saveAction": "Opslaan"
},
"appstore": {
"title": "App Store",
@@ -160,7 +169,10 @@
"loginAction": "Inloggen",
"createAccountAction": "Account aanmaken",
"switchToSignUpAction": "Nog geen account? Registreer",
"switchToLoginAction": "Al een account? Log in"
"switchToLoginAction": "Al een account? Log in",
"setupWithTokenAction": "Instellen",
"setupToken": "Instel Token",
"titleToken": "Inloggen met Instel Token"
},
"searchPlaceholder": "Zoek voor alternatieven zoals Github, Dropbox, Slack, Trello, …",
"appNotFoundDialog": {
@@ -193,8 +205,7 @@
"invitationTooltip": "Gebruiker uitnodigen",
"setGhostTooltip": "Nabootsen",
"mailmanagerTooltip": "Deze gebruiker kan gebruikers en mailboxen beheren",
"count": "Totaal gebruikers: {{ count }}",
"makeLocalTooltip": "Maak gebruiker lokaal"
"count": "Totaal gebruikers: {{ count }}"
},
"groups": {
"title": "Groepen",
@@ -204,7 +215,7 @@
"newGroupAction": "Nieuwe groep"
},
"settings": {
"title": "Instellingen",
"title": "Gebruiker instellingen",
"require2FACheckbox": "Gebruikers moeten 2FA activeren",
"subscriptionRequired": "Deze functies zijn alleen beschikbaar voor betaalde abonnementen.",
"subscriptionRequiredAction": "Abonnement nemen",
@@ -214,7 +225,6 @@
},
"externalLdap": {
"title": "Verbind met een externe lijst",
"subscriptionRequired": "Deze functie is alleen beschikbaar voor betaalde abonnementen.",
"subscriptionRequiredAction": "Neem nu een abonnement",
"noopInfo": "LDAP authenticatie is niet geconfigureerd.",
"provider": "Aanbieder",
@@ -228,16 +238,17 @@
"groupnameField": "Veld voor groepsnaam",
"server": "Server URL",
"showLogsAction": "Toon logbestanden",
"syncAction": "Synchroniseer",
"syncAction": "Sync",
"configureAction": "Configureer",
"bindUsername": "Bind DN/Username (optioneel)",
"bindPassword": "Bind Password (optioneel)",
"errorSelfSignedCert": "Server gebruikt een ongeldig of zelf-ondertekend certificaat.",
"description": "Cloudron synchroniseert gebruikers en groepen van een extern LDAP of ActiveDirectory server. Wachtwoordverificatie vindt plaats door de externe server. De synchronisatie is niet automatisch en dient handmatig gestart te worden.",
"description": "Deze instelling synchroniseert en authentificeert gebruikers en groepen van een extern LDAP of ActiveDirectory server. De synchronisatie is periodiek maar kan ook handmatig gestart worden.",
"auth": "Authenticatie",
"autocreateUsersOnLogin": "Maak automatisch gebruikers aan na inloggen bij deze Cloudron",
"autocreateUsersOnLogin": "Maak automatisch gebruikers bij inloggen",
"providerOther": "Anders",
"providerDisabled": "Uitgeschakeld"
"providerDisabled": "Uitgeschakeld",
"disableWarning": "De authentificatie-bron van alle bestaande gebruikers zal worden omgezet naar authentificatie via de lokale wachtwoord database."
},
"subscriptionDialog": {
"title": "Abonnement benodigd",
@@ -266,7 +277,9 @@
"errorInvalidUsername": "Dit is geen geldige gebruikersnaam",
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
"fallbackEmailPlaceholder": "Optioneel. Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding"
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding",
"external2FA": "2FA instellingen worden beheerd door een externe authenticatie bron",
"ldapGroups": "LDAP Groepen"
},
"deleteUserDialog": {
"deleteAction": "Verwijder",
@@ -353,17 +366,18 @@
"exposedLdap": {
"ipRestriction": {
"placeholder": "Regelgescheiden IP adres of Subnet",
"description": "De lijstserver kan beperkt worden tot specifieke IP's of bereiken.",
"description": "Beperk de toegang tot de Directory Server tot specifieke IP's of bereiken. Regels die starten met <code>#</code> worden beschouwd als commentaar.",
"label": "Beperk toegang"
},
"enabled": "Ingeschakeld",
"title": "Lijst server",
"description": "Cloudron kan ingezet worden als gebruikerslijstserver voor externe applicaties.",
"title": "Directory Server",
"description": "Cloudron kan ingezet worden als gebruikers Directory Server voor externe applicaties.",
"secret": {
"label": "Koppel wachtwoord",
"description": "Alle LDAP verzoeken moeten geauthentiseerd worden met dit geheim en de gebruiker DN <i>{{ userDN }}</i>",
"url": "Server URL"
}
},
"cloudflarePortWarning": "Cloudflare proxy moet uitgeschakeld zijn op het domein van het dashboard om de LDAP server te kunnen bereiken"
},
"userImportDialog": {
"title": "Importeer gebruikers",
@@ -387,12 +401,6 @@
"all": "Alle gebruikers",
"active": "Actieve gebruikers",
"inactive": "Inactieve gebruikers"
},
"makeLocalDialog": {
"title": "Maak deze gebruiker lokaal",
"description": "De gebruiker wordt hiermee gemigreerd van de externe gebruikerslijst naar die van Cloudron.",
"warning": "Een wachtwoord herstel wordt geïnitieerd om een lokaal wachtwoord in te stellen voor deze gebruiker.",
"submitAction": "Maak lokaal"
}
},
"profile": {
@@ -459,7 +467,10 @@
"changeEmail": {
"title": "Primair e-mailadres aanpassen",
"errorEmailInvalid": "Het e-mailadres is niet geldig",
"errorEmailRequired": "Een geldig e-mailadres is verplicht"
"errorEmailRequired": "Een geldig e-mailadres is verplicht",
"email": "Nieuw e-mailadres",
"errorWrongPassword": "Onjuist wachtwoord",
"password": "Wachtwoord ter bevestiging"
},
"changeFallbackEmail": {
"errorEmailRequired": "Een geldig e-mailadres is verplicht",
@@ -503,7 +514,8 @@
},
"changeBackgroundImage": {
"title": "Stel achtergrond afbeelding in"
}
},
"enable2FANotAvailable": "Niet beschikbaar voor gebruikers met een externe authenticatie bron"
},
"backups": {
"title": "Backups",
@@ -533,7 +545,7 @@
"noApps": "Geen apps",
"cleanupBackups": "Backups opschonen",
"backupNow": "Backup maken",
"stopTask": "Stop {{ taskType === 'backup' ? 'Backup' : 'Cleanup' }}",
"stopTask": "Stop Backup",
"appCount": "{{ appCount }} apps",
"tooltipDownloadBackupConfig": "Download Backup Configuratie",
"tooltipEditBackup": "Bewerk Backup",
@@ -621,7 +633,7 @@
},
"check": {
"noop": "Cloudron backups zijn uitgeschakeld. Zorg ervoor dat deze server op een andere manier wordt geback-upt. Kijk op https://docs.cloudron.io/backups/#storage-providers voor meer informatie.",
"sameDisk": "Cloudron backups staan momenteel op dezelfde schijf als deze Cloudron server. Dit is gevaarlijk en kan leiden tot gegevensverlies als de schijf defect raakt. Kijk op https://docs.cloudron.io/backups/#storage-providers hoe je backups op een externe locatie kan zetten."
"sameDisk": "Backups staan momenteel op dezelfde schijf als Cloudron zelf. Als de disk volloopt met deze backups zal Cloudron niet meer werken. Een defecte disk kan ook leiden tot volledig gegevensverlies. Kijk op https://docs.cloudron.io/backups/#storage-providers hoe je backups op een externe locatie kan zetten."
},
"backupEdit": {
"preserved": {
@@ -629,7 +641,8 @@
"description": "Backup behouden, ongeacht het bewaarbeleid"
},
"title": "Bewerk Backup",
"label": "Label"
"label": "Label",
"remotePath": "Extern pad"
}
},
"branding": {
@@ -644,7 +657,9 @@
},
"changeLogo": {
"title": "Kies een Cloudron-afbeelding"
}
},
"backgroundImage": "Inlogpagina achtergrond afbeelding",
"clearBackgroundImage": "Leegmaken"
},
"emails": {
"title": "E-mail",
@@ -660,7 +675,7 @@
"settings": {
"title": "Instellingen",
"info": "Deze instellingen zijn generiek voor alle domeinen.",
"location": "Mail server locatie",
"location": "Mail Server Locatie",
"maxMailSize": "Maximale e-mail grootte",
"spamFilter": "Spam filtering",
"spamFilterOverview": "{{ blacklistCount }} adres(sen) op de blokkeerlijst.",
@@ -671,7 +686,8 @@
"solrFts": "Zoek volledige tekst (Solr)",
"solrDisabled": "Uitgeschakeld",
"acl": "E-mail ACL",
"aclOverview": "{{ dnsblZonesCount }} DNSBL zone(s)"
"aclOverview": "{{ dnsblZonesCount }} DNSBL zone(s)",
"virtualAllMail": "\"Alle E-mail\" map"
},
"eventlog": {
"title": "E-mail logboek",
@@ -708,7 +724,7 @@
"manualInfo": "Voeg handmatig een A record toe voor {{ domain }} die verwijst naar het IP van deze Cloudron",
"locationPlaceholder": "Leeg laten om hoofddomein te gebruiken",
"title": "E-mail server locatie aanpassen",
"description": "Cloudron zorgt voor de benodigde DNS aanpassingen van alle domeinen en herstart de e-mail server. Desktop & mobiele e-mailprogramma's moeten opnieuw geconfigureerd worden met deze nieuwe locatie als IMAP en SMTP server."
"description": "Dit verhuist de IMAP en SMTP server naar de aangegeven lokatie."
},
"changeMailSizeDialog": {
"title": "Maximale e-mail grootte aanpassen",
@@ -762,6 +778,10 @@
},
"action": {
"queue": "Wachtrij"
},
"changeVirtualAllMailDialog": {
"title": "\"Alle E-mail\" map",
"description": "De \"Alle E-mail\" map is een enkele map die alle e-mails bevat van je mailbox. Deze map kan handig zijn indien een e-mailprogramma \"zoek in alle mappen\" niet ondersteunt."
}
},
"domains": {
@@ -812,7 +832,13 @@
"cloudflareDefaultProxyStatus": "Inschakelen proxy voor nieuwe DNS regels",
"porkbunApikey": "API sleutel",
"porkbunSecretapikey": "Geheime API sleutel",
"bunnyAccessKey": "Bunny toegangssleutel"
"bunnyAccessKey": "Bunny toegangssleutel",
"dnsimpleAccessToken": "Toegangstoken",
"ovhEndpoint": "Eindpunt",
"ovhConsumerKey": "Consumer sleutel",
"ovhAppKey": "Applicatie sleutel",
"ovhAppSecret": "Applicatie geheim",
"deSecToken": "deSEC Token"
},
"title": "Domeinen & Certificaten",
"addDomain": "Domein toevoegen",
@@ -831,7 +857,7 @@
"cancelAction": "Annuleer",
"showLogsAction": "Toon logbestanden",
"title": "Dashboard-domein aanpassen",
"description": "Hierdoor verhuist het Dashboard en de e-mailserver naar het <code>my</code> subdomein van het geselecteerde domein."
"description": "Hierdoor verhuist het Dashboard naar het <code>my</code> subdomein van het geselecteerde domein."
},
"subscriptionRequired": {
"title": "Abonnement verplicht",
@@ -852,7 +878,8 @@
"domainWellKnown": {
"title": "Well-Known locaties van {{ domain }}"
},
"tooltipWellKnown": "Well-Known Locaties instellen"
"tooltipWellKnown": "Well-Known Locaties instellen",
"count": "Totaal domeinen: {{ count }}"
},
"app": {
"email": {
@@ -944,14 +971,14 @@
"resources": {
"memory": {
"title": "Geheugenlimiet",
"description": "Cloudron wijst 50% van deze waarde toe als RAM en 50% als swap.",
"description": "Maximum geheugen dat een app kan gebruiken",
"resizeAction": "Grootte wijzigen",
"error": "Kan geheugenlimiet niet instellen, probeer minder."
},
"cpu": {
"setAction": "Vastleggen",
"title": "CPU Shares",
"description": "Percentage CPU-tijd wanneer het systeem zwaar wordt belast."
"setAction": "Instellen",
"title": "CPU Limiet",
"description": "Maximum percentage CPU dat een app kan gebruiken"
}
},
"storage": {
@@ -960,7 +987,8 @@
"dataDirPlaceholder": "Laat leeg om platformstandaard te gebruiken",
"moveAction": "Verplaats data",
"description": "Als de server onvoldoende schijfruimte heeft, gebruik dit om de app data te verplaatsen naar een <a href=\"/#/volumes\">volume</a>. Alle data daar is onderdeel van de app's backup.",
"diskUsage": "De app gebruikt momenteel {{ size }} aan opslag (sinds {{ date }})."
"diskUsage": "De app gebruikt momenteel {{ size }} aan opslag (sinds {{ date }}).",
"mountTypeWarning": "Het bestemmingsbestandssysteem moet bestandsmachtigingen en eigendom ondersteunen om de verhuizing te laten werken"
},
"mounts": {
"title": "Koppelpunten",
@@ -1012,14 +1040,15 @@
"checkForUpdatesAction": "Controleer op updates",
"customAppUpdateInfo": "Er zijn geen updates beschikbaar voor deze maatwerk app",
"updateAvailableAction": "Update beschikbaar",
"repository": "Pakket Opslagplaats"
"repository": "Pakket Opslagplaats",
"installedAt": "Geïnstalleerd op"
},
"auto": {
"title": "Automatische updates",
"enabled": "Automatische updates zijn momenteel ingeschakeld.",
"disabled": "Automatische updates zijn momenteel uitgeschakeld.",
"disableAction": "Automatische updates uitschakelen",
"enableAction": "Automatische updates inschakelen",
"disableAction": "Uitschakelen",
"enableAction": "Inschakelen",
"description": "Cloudron controleert de App Store periodiek op updates. Als je dit uitschakelt zorg er dan voor dat je updates handmatig installeert."
},
"noUpdates": "Geen nieuwe updates beschikbaar"
@@ -1164,7 +1193,7 @@
"service": "Dienst (start eenmalig)"
},
"title": "Crontab",
"saveAction": "Bewaar",
"saveAction": "Opslaan",
"addCommonPattern": "Voeg gemeenschappelijk patroon toe",
"description": "Eigen app-specifieke cron jobs kunnen hier toegevoegd worden. Let op: standaard cron jobs voor deze applicatie zijn al geïntegreerd in de app en hoef je hier niet te configureren."
},
@@ -1182,17 +1211,34 @@
"label": "Label",
"clearIconAction": "Icoon verwijderen",
"clearIconDescription": "Hiermee wordt geprobeerd de favicon van de app op te halen na opslaan."
},
"servicesTabTitle": "Diensten",
"turn": {
"title": "TURN Instellen",
"enable": "Configureer de app om de ingebouwde TURN server te gebruiken",
"disable": "Configureer de TURN-instellingen van de app niet. De TURN-instellingen van de app worden met rust gelaten. Je kunt het in de app configureren."
},
"redis": {
"title": "Redis configuratie",
"enable": "Configureer de app om Redis te gebruiken",
"disable": "Redis uitschakelen"
},
"infoTabTitle": "Info",
"info": {
"notes": {
"title": "Admin Notities"
}
}
},
"network": {
"title": "Netwerk",
"ip": {
"title": "IP Adres",
"title": "IPv4",
"provider": "Aanbieder",
"interface": "Naam netwerkinterface",
"configure": "Configureer",
"interfaceDescription": "Toon beschikbare apparaten op deze server met:",
"description": "Cloudron gebruikt dit IP adres tijdens het instellen van DNS records.",
"description": "Cloudron gebruikt dit IPv4 adres om de DNS records in te stellen.",
"detected": "gedetecteerd",
"address": "IP adres"
},
@@ -1208,10 +1254,11 @@
},
"dyndns": {
"title": "Dynamische DNS",
"description": "Schakel deze optie in om je DNS records synchroon te houden met je veranderende IP adres. Dit is handig als je Cloudron opgenomen is in een netwerk waarbij het publieke IP adres steeds wisselt zoals in een thuissituatie."
"description": "Schakel deze optie in om je DNS records synchroon te houden met je veranderende IP adres. Dit is handig als je Cloudron opgenomen is in een netwerk waarbij het publieke IP adres steeds wisselt zoals in een thuissituatie.",
"showLogsAction": "Toon logbestanden"
},
"configureIp": {
"title": "Configureer IP aanbieder",
"title": "Configureer IPv4 aanbieder",
"providerGenericDescription": "Het publieke IP adres van deze server wordt automatisch gedetecteerd."
},
"ipv4": {
@@ -1224,7 +1271,13 @@
},
"configureIpv6": {
"title": "Configureer IPv6 aanbieder"
}
},
"trustedIps": {
"description": "HTTP headers van bijbehorende IP adressen worden vertrouwd",
"summary": "{{ trustCount }} IPs vertrouwd",
"title": "Configureer vertrouwde IPs"
},
"trustedIpRanges": "Vertrouwde IPs & bereiken "
},
"services": {
"title": "Diensten",
@@ -1254,7 +1307,7 @@
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Opgezegd en eindigt op",
"subscriptionSetupAction": "Upgrade naar Premium",
"subscriptionChangeAction": "Abonnement wijzigen",
"subscriptionChangeAction": "Beheer abonnement",
"subscriptionReactivateAction": "Abonnement heractiveren",
"title": "Cloudron.io Account",
"description": "Een Cloudron.io account wordt gebruikt voor toegang tot de App Store en om je abonnement te beheren.",
@@ -1340,7 +1393,8 @@
"emailInfo": "(E-mail van het abonnement is {{ email }})",
"sshCheckbox": "Sta toe dat ondersteuningsmedewerkers toegang krijgen tot deze server middels SSH",
"emailVerifyAction": "Verifieer nu",
"emailNotVerified": "Je cloudron.io account e-mail {{ email }} is niet geverifieerd. Verifieer het om support tickets te kunnen openen."
"emailNotVerified": "Je cloudron.io account e-mail {{ email }} is niet geverifieerd. Verifieer het om support tickets te kunnen openen.",
"typeBilling": "Factureringsprobleem"
},
"remoteSupport": {
"title": "Ondersteuning op afstand",
@@ -1349,6 +1403,10 @@
"disableAction": "SSH ondersteuningstoegang uitschakelen",
"enableAction": "SSH ondersteuningstoegang inschakelen",
"description": "Met het inschakelen van deze optie geeft je ondersteuningsmedewerkers toegang tot deze server middels SSH."
},
"help": {
"title": "Hulp",
"description": "Gebruik de volgende bronnen voor hulp en ondersteuning:\n* [Cloudron Forum]({{ forumLink }}) - Gebruik de Support en App specifieke categorieën voor vragen.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
}
},
"system": {
@@ -1373,7 +1431,19 @@
"graphTitle": "Percentage",
"graphSubtext": "Alleen apps die meer dan {{ threshold }} van de CPU gebruiken worden getoond"
},
"selectPeriodLabel": "Selecteer periode"
"selectPeriodLabel": "Selecteer periode",
"info": {
"title": "Info",
"vendor": "Leverancier",
"memory": "Geheugen",
"uptime": "Uptime",
"activationTime": "Cloudron installatie tijd",
"platformVersion": "Platform Versie",
"product": "Product"
},
"graphs": {
"title": "Grafieken"
}
},
"eventlog": {
"title": "Logboek",
@@ -1393,7 +1463,9 @@
"logs": {
"title": "Logbestanden",
"clear": "Leegmaken",
"download": "Download volledige logbestanden"
"download": "Download volledige logbestanden",
"notFoundError": "Geen taak of app gevonden",
"logsGoneError": "Log bestand(en) niet gevonden"
},
"terminal": {
"title": "Terminal",
@@ -1438,7 +1510,8 @@
"renameDialog": {
"title": "Hernoem {{ fileName }}",
"newName": "Nieuwe naam",
"rename": "Hernoem"
"rename": "Hernoem",
"reallyOverwrite": "Een bestand met die naam bestaat al. Wil je het bestaande bestand overschrijven?"
},
"chownDialog": {
"newOwner": "Nieuwe eigenaar",
@@ -1491,7 +1564,8 @@
"paste": "Plakken",
"copy": "Kopiëren",
"cut": "Knippen",
"edit": "Bewerk"
"edit": "Bewerk",
"open": "Open"
},
"mtime": "Bewerkt"
},
@@ -1506,13 +1580,25 @@
},
"newDirectory": {
"errorAlreadyExists": "Bestaat al"
}
},
"uploader": {
"exitWarning": "Uploaden nog bezig. Weet je zeker dat je deze pagina wilt sluiten?",
"uploading": "Uploaden"
},
"extractionInProgress": "Bezig met uitpakken",
"textEditor": {
"undo": "Ongedaan maken",
"redo": "Opnieuw doen",
"save": "Opslaan"
},
"pasteInProgress": "Bezig met plakken",
"deleteInProgress": "Bezig met verwijderen"
},
"email": {
"backAction": "Terug naar e-mail",
"config": {
"title": "E-mailconfiguratie {{ domain }}",
"clientConfiguration": "Configureren E-mail clients"
"clientConfiguration": "Configureren E-mail programma's"
},
"incoming": {
"disableAction": "Uitschakelen",
@@ -1558,7 +1644,7 @@
"incomingPasswordUsage": "Wachtwoord van de eigenaar van de mailbox",
"enabled": "Cloudron e-mailserver is geconfigureerd voor inkomende e-mails voor dit domein.",
"disabled": "Cloudron e-mailserver ontvangt geen inkomende e-mails voor dit domein.",
"howToConnectDescription": "Gebruik onderstaande gegevens om e-mail clients in te stellen."
"howToConnectDescription": "Gebruik onderstaande gegevens om e-mail programma's in te stellen."
},
"outbound": {
"tabTitle": "Uitgaand",
@@ -1628,7 +1714,7 @@
"setupDnsInfo": "Gebruik deze optie om automatisch e-mail gerelateerde DNS records in te stellen. Het nu niet inschakelen kan handig zijn om eerst e-mail boxen aan te maken en <a href=\"{{ importEmailDocsLink }}\">e-mails te importeren</a> voor ingebruikname.",
"enableAction": "Inschakelen",
"description": "Hiermee wordt Cloudron zo geconfigureerd dat e-mails ontvangen worden voor <b>{{ domain }}</b>. In de documentatie staat beschreven welke <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">benodigde poorten</a> ingesteld dienen te worden voor Cloudron Email.",
"cloudflareInfo": "Het domein <code>{{ adminDomain }}</code> wordt beheerd door Cloudflare. Zorg ervoor dat Cloudflare proxying uitgeschakeld is voor <code>{{ mailFqdn }}</code> en ingesteld is op <code>DNS only</code>. Dit is noodzakelijk omdat Cloudflare geen e-mail-proxy kan uitvoeren."
"cloudflareInfo": "Het domein van de mailserver <code>{{ adminDomain }}</code> wordt beheerd door Cloudflare. Zorg ervoor dat Cloudflare proxying uitgeschakeld is voor <code>{{ mailFqdn }}</code> en ingesteld is op <code>DNS only</code>. Dit is noodzakelijk omdat Cloudflare geen e-mail-proxy kan uitvoeren."
},
"disableEmailDialog": {
"title": "E-mail Server voor {{ domain }} uitschakelen?",
@@ -1657,7 +1743,7 @@
},
"addMailinglistDialog": {
"title": "Maillijst toevoegen",
"members": "Lijst leden",
"members": "Ledenlijst",
"membersInfo": "Plaats meerdere e-mailadressen elk op een nieuwe regel",
"membersOnlyCheckbox": "Het versturen van e-mail aan deze lijst beperken tot de leden",
"name": "Naam"
@@ -1685,7 +1771,7 @@
"updateMailinglistDialog": {
"activeCheckbox": "Mailing-lijst is actief"
},
"howToConnectInfoModal": "Configureren e-mail clients",
"howToConnectInfoModal": "Configureren e-mail programma's",
"mailboxImportDialog": {
"title": "Importeer Mailboxen",
"description": "Upload een JSON of CSV bestand met een schema zoals beschreven in onze <a href=\"{{ docsLink }}\" target=\"_blank\">documentatie</a>.",
@@ -1703,7 +1789,10 @@
"password": "Wachtwoord",
"resetPasswordAction": "Herstel wachtwoord",
"2faToken": "2FA Token (indien ingeschakeld)",
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord"
"errorIncorrectCredentials": "Onjuiste gebruikersnaam of wachtwoord",
"errorIncorrect2FAToken": "2FA token is niet geldig",
"errorInternal": "Interne fout, probeer later opnieuw",
"loginWith": "Login met Cloudron"
},
"passwordReset": {
"title": "Wachtwoord herstellen",
@@ -1763,7 +1852,11 @@
"mountStatus": "Koppel status",
"localDirectory": "Lokale map",
"type": "Type",
"remountActionTooltip": "Her-koppel Volume"
"remountActionTooltip": "Her-koppel Volume",
"editVolumeDialog": {
"title": "Bewerk volume {{ name }}"
},
"editActionTooltip": "Bewerk Volume"
},
"lang": {
"it": "Italiaans",
@@ -1777,7 +1870,8 @@
"es": "Spaans",
"ru": "Russisch",
"pt": "Portugees",
"da": "Deens"
"da": "Deens",
"id": "Indonesisch"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Wachtwoord herstellen",
@@ -1837,5 +1931,43 @@
"mounts": {
"description": "Apps kunnen toegang krijgen tot <a href=\"/#/volumes\">volumes</a> via <code>/media/{volume name}</code> directory. Deze data is niet opgenomen in de app backup."
}
}
},
"oidc": {
"newClientDialog": {
"title": "Client toevoegen",
"description": "Nieuwe OpenID Connect client instellingen toevoegen.",
"createAction": "Aanmaken"
},
"client": {
"name": "Naam",
"id": "Client ID",
"secret": "Client geheim",
"signingAlgorithm": "Ondertekeningsalgoritme",
"loginRedirectUri": "Login callback URL (met komma gescheiden indien meer dan één)",
"logoutRedirectUri": "Logout callback URL (optioneel)"
},
"title": "OpenID Connect aanbieder",
"description": "Cloudron kan als een OpenID Connect aanbieder voor interne apps en externe diensten fungeren.",
"editClientDialog": {
"title": "Bewerk Client {{ client }}"
},
"deleteClientDialog": {
"title": "Weet je zeker dat je Client {{ client }} wilt verwijderen?",
"description": "Hiermee worden alle externe OpenID apps met dit Client ID losgekoppeld."
},
"env": {
"discoveryUrl": "Discovery URL",
"logoutUrl": "Logout URL",
"profileEndpoint": "Profiel Eindpunt",
"keysEndpoint": "Sleutels Eindpunt",
"tokenEndpoint": "Token Eindpunt",
"authEndpoint": "Auth Eindpunt"
},
"clients": {
"title": "Clients",
"newClient": "Nieuwe Client",
"empty": "Nog geen Clients"
}
},
"automation": "Automatisering"
}
-156
View File
@@ -1,156 +0,0 @@
{
"apps": {
"title": "As Minhas Aplicações",
"noApps": {
"description": "Que tal instalar algumas? Vê a <a href=\"{{ appStoreLink }}\">Loja de Aplicações</a>",
"title": "Sem aplicações instaladas!"
},
"groupsFilterHeader": "Selecionar Grupo",
"addApplinkAction": "Adicionar Applink",
"noAccess": {
"title": "Não tem acesso a nenhuma aplicação.",
"description": "Assim que tiver, elas vão aparecer aqui."
},
"configActionTooltip": "Configurar",
"logsActionTooltip": "Eventos",
"infoActionTooltip": "Informação",
"adminPageActionTooltip": "Página de Adminstração",
"searchPlaceholder": "Pesquisar Aplicações",
"stateFilterHeader": "Todos os Estados",
"tagsFilterHeader": "Etiquetas: {{ tags }}",
"tagsFilterHeaderAll": "Todas as Etiquetas",
"domainsFilterHeader": "Todos os Domínios",
"auth": {
"sso": "Entrar com as credenciais Cloudron",
"nosso": "Entrar com conta dedicada",
"email": "Entrar com endereço de email"
},
"addAppAction": "Adicionar Aplicação",
"addAppproxyAction": "Adicionar Appproxy",
"filter": {
"clearAll": "Limpar Tudo"
}
},
"main": {
"displayName": "Nome de Apresentação",
"rebootDialog": {
"warning": "Reiniciar o servidor irá causar que todas as aplicações instaladas neste Cloudron fiquem indisponíveis temporariamente!",
"description": "Utilize isto para aplicar atualizações de segurança ou se experienciar comportamento inesperado. Todas as aplicações e serviços em execução neste Cloudron vão iniciar automaticamente quando o reinício estiver completo.",
"title": "Realmente reiniciar o servidor?",
"rebootAction": "Reiniciar agora"
},
"offline": "O Cloudron está offline. A ligar novamente…",
"dialog": {
"cancel": "Cancelar",
"save": "Guardar",
"close": "Fechar",
"no": "Não",
"yes": "Sim"
},
"logout": "Terminar Sessão",
"username": "Nome de Utilizador",
"actions": "Ações",
"table": {
"date": "Data"
},
"pagination": {
"next": "seguinte",
"prev": "anterior",
"perPageSelector": "Mostrar {{ n }} por página"
},
"action": {
"reboot": "Reiniciar",
"logs": "Eventos"
},
"clipboard": {
"copied": "Copiado para a área de transferência",
"clickToCopy": "Clique para copiar",
"clickToCopyBackupId": "Clique para copiar o ID da cópia de segurança"
},
"searchPlaceholder": "Pesquisar",
"multiselect": {
"selected": "{{ n }} selecionados",
"select": "Selecionar",
"filterPlaceholder": "Escreva para filtrar opções"
},
"prettyDate": {
"justNow": "agora mesmo",
"yeserday": "Ontem",
"minutesAgo": "{{ m }} minutos atrás",
"hoursAgo": "{{ h }} horas atrás"
},
"navbar": {
"users": "Utilizadores"
},
"disableAction": "Desativar",
"enableAction": "Ativar",
"statusEnabled": "Ativado",
"statusDisabled": "Desativado"
},
"appstore": {
"category": {
"analytics": "Estatísticas",
"game": "Jogos",
"project": "Gestão de Projetos",
"all": "Tudo",
"popular": "Popular",
"newApps": "Novas Aplicações",
"chat": "Chat",
"blog": "Blog",
"document": "Documentos",
"crm": "CRM",
"forum": "Fórum",
"gallery": "Galeria",
"finance": "Finanças",
"git": "Alojamento de Código",
"email": "Email",
"hosting": "Alojamento Web",
"media": "Multimédia",
"learning": "Aprendizagem",
"notes": "Notas",
"sync": "Sincronização de Ficheiros",
"wiki": "Wiki",
"vpn": "VPN",
"federated": "Federados"
},
"installDialog": {
"lastUpdated": "Última atualização a {{ date }}",
"locationPlaceholder": "Deixe em branco para usar o domínio de raiz",
"userManagementNone": "Esta aplicação tem a sua própria gestão de utilizadores. Esta definição determina se a aplicação está ou não visível no painel do utilizador.",
"memoryRequirement": "Requere pelo menos {{ size }} de memória",
"location": "Localização",
"manualWarning": "Adicione um registo A manualmente para <b>{{ location }}</b> apontando para o endereço IP público deste Cloudron",
"userManagement": "Gestão de utilizadores",
"userManagementMailbox": "Todos os utilizadores com uma caixa de correio neste Cloudron têm acesso.",
"userManagementLeaveToApp": "Deixar a gestão de utilizadores para a aplicação",
"userManagementAllUsers": "Permitir todos os utilizadores deste Cloudron",
"installAnywayAction": "Instalar na mesma",
"doInstallAction": "Instalar {{ dnsOverwrite ? 'e sobrescrever DNS' : '' }}",
"userManagementSelectUsers": "Apenas permitir os seguintes utilizadores e grupos",
"errorUserManagementSelectAtLeastOne": "Selecione pelo menos um utilizador ou grupo",
"users": "Utilizadores",
"groups": "Grupos",
"configuredForCloudronEmail": "Esta aplicação está pré-configurada para ser utilizada com o <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email do Cloudron</a>.",
"lowOnResources": "Este Cloudron está baixo em recursos.",
"pleaseUpgradeServer": "Por favor, atualize para um servidor com mais memória. Em alternativa, liberte recursos removendo aplicações que não utiliza.",
"subscriptionRequired": "Para instalar mais aplicação, uma subscrição paga é necessária.",
"setupSubscriptionAction": "Configurar Subscrição",
"installAction": "Instalar",
"cloudflarePortWarning": "O proxy do Cloudflare deve estar desativado para o domínio da aplicação para que possa aceder a esta porta",
"titleAndVersion": "Esta aplicação inclui {{ title }} {{ version }}"
},
"title": "Loja de Aplicações",
"searchPlaceholder": "Pesquise por alternativas como Github, Dropbox, Slack, Trello, …",
"noAppsFound": "Nenhuma aplicação encontrada.",
"appMissing": "Falta uma aplicação? Contacte-nos.",
"unstable": "Instável",
"appNotFoundDialog": {
"description": "Não existe nenhuma aplicação <b>{{ appId }}</b> com a versão <b>{{ version }}</b>.",
"title": "Aplicação não encontrada"
},
"accountDialog": {
"titleSignUp": "Registar com Cloudron.io",
"titleLogin": "Entrar com Cloudron.io"
}
}
}
+196 -63
View File
@@ -8,7 +8,8 @@
"auth": {
"sso": "Войдите, используя учётную запись Cloudron",
"email": "Войдите, используя email",
"nosso": "Войдите, используя Вашу учётную запись"
"nosso": "Войдите, используя Вашу учётную запись",
"openid": "Войти с помощью Cloudron OpenID"
},
"noAccess": {
"description": "После открытия доступа приложения отобразятся здесь.",
@@ -29,6 +30,9 @@
"addApplinkAction": "Добавить App Link",
"filter": {
"clearAll": "Очистить все"
},
"apps": {
"count": "Всего приложений: {{ count }}"
}
},
"main": {
@@ -48,7 +52,8 @@
"justNow": "только что",
"yeserday": "Вчера",
"minutesAgo": "{{ m }} минут назад",
"hoursAgo": "{{ h }} часов назад"
"hoursAgo": "{{ h }} часов назад",
"never": "Никогда"
},
"logout": "Выйти",
"dialog": {
@@ -56,7 +61,8 @@
"save": "Сохранить",
"close": "Закрыть",
"no": "Нет",
"yes": "Да"
"yes": "Да",
"delete": "Удалить"
},
"username": "Имя пользователя",
"displayName": "Отображаемое имя",
@@ -72,7 +78,8 @@
},
"action": {
"reboot": "Перезагрузка",
"logs": "Логи"
"logs": "Логи",
"showLogs": "Показать логи"
},
"searchPlaceholder": "Поиск",
"multiselect": {
@@ -87,7 +94,9 @@
"enableAction": "Включить",
"statusEnabled": "Включено",
"statusDisabled": "Выключено",
"loadingPlaceholder": "Загрузка"
"loadingPlaceholder": "Загрузка",
"settings": "Настройки",
"saveAction": "Сохранить"
},
"appstore": {
"category": {
@@ -157,7 +166,10 @@
"loginAction": "Логин",
"switchToSignUpAction": "Ещё нет учётной записи? Зарегистрироваться",
"createAccountAction": "Создать учётную запись",
"switchToLoginAction": "Уже есть учётная запись? Войти"
"switchToLoginAction": "Уже есть учётная запись? Войти",
"setupWithTokenAction": "Настройка",
"setupToken": "Настроить Токен",
"titleToken": "Войти с Настроенным Токеном"
},
"title": "Магазин приложений",
"noAppsFound": "Приложения не найдены.",
@@ -191,8 +203,7 @@
"invitationTooltip": "Пригласить пользователя",
"setGhostTooltip": "Обезличить",
"mailmanagerTooltip": "Этот пользователь может управлять другими пользователями и почтовыми ящиками",
"count": "Всего пользователей: {{ count }}",
"makeLocalTooltip": "Сделать пользователя локальным"
"count": "Всего пользователей: {{ count }}"
},
"title": "Каталог пользователей",
"newUserAction": "Новый пользователь",
@@ -204,7 +215,7 @@
"externalLdapTooltip": "Из внешнего LDAP каталога"
},
"settings": {
"title": "Настройки",
"title": "Настройки пользователя",
"allowProfileEditCheckbox": "Разрешить пользователям редактировать своё имя и адрес электронной почты",
"require2FACheckbox": "Требовать от пользователей настройки 2FA",
"subscriptionRequired": "Данные функции доступны только в платной подписке.",
@@ -213,14 +224,13 @@
"require2FAWarning": "Сперва настройте 2FA, чтобы иметь доступ к аккаунту в будущем."
},
"externalLdap": {
"description": "Cloudron будет синхронизировать пользователей и группы с внешнего сервера LDAP или ActiveDirectory. Проверка пароля для аутентификации таких пользователей выполняется на внешнем сервере. Синхронизация не запускается автоматически, ее нужно активировать вручную.",
"description": "Эта настройка будет сихронизировать и идентифицировать пользователй и группы из внешнего сервера LDAP или AcriveDirectory. Синхронизация запускается с периодичностью, но также может быть запущена вручную.",
"bindPassword": "Привязать пароль (необязательно)",
"bindUsername": "Привязать Уникальное имя (DN)/Имя пользователя (необязательно)",
"title": "Подключиться к удалённому каталогу",
"subscriptionRequired": "Данная функция доступна только в платной подписке.",
"subscriptionRequiredAction": "Настроить подписку сейчас",
"noopInfo": "LDAP аутентификация не настроена.",
"provider": "Источник",
"provider": "Поставщик",
"server": "URL сервера",
"acceptSelfSignedCert": "Принимать самоподписанный сертификат",
"baseDn": "Корневой элемент",
@@ -230,14 +240,15 @@
"groupBaseDn": "Групповой корневой элемент",
"groupFilter": "Фильтр группы",
"groupnameField": "Поле с именем группы",
"auth": "Войти",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа в Cloudron",
"auth": "Авторизоваться",
"autocreateUsersOnLogin": "Автоматически создавать пользователей после их входа",
"showLogsAction": "Показать логи",
"syncAction": "Синхронизировать",
"configureAction": "Настроить",
"errorSelfSignedCert": "Сервер использует недействительный или самоподписанный сертификат.",
"providerOther": "Другое",
"providerDisabled": "Отключить"
"providerDisabled": "Отключить",
"disableWarning": "Источник аутентификации будет сброшен до локальных паролей для всех активных пользователей."
},
"subscriptionDialog": {
"title": "Требуется подписка",
@@ -259,14 +270,16 @@
"errorEmailRequired": "Требуется адрес электронной почты",
"errorInvalidUsername": "Неверное имя пользователя",
"usernamePlaceholder": "Необязательно. Если не указано, пользователь может выбрать во время регистрации",
"displayName": "Показать имя",
"displayName": "Отображаемое имя",
"email": "Электронная почта",
"primaryEmail": "Основной адрес электронной почты",
"recoveryEmail": "Пароль восстановления электронной почты",
"recoveryEmail": "Электронная почта для восстановления пароля",
"errorDisplayNameRequired": "Требуется имя",
"activeCheckbox": "Пользователь активен",
"fallbackEmailPlaceholder": "Необязательно. Если не указано, будет использоваться основной почтовый ящик",
"displayNamePlaceholder": "Необязательно. Если не указано, пользователь может указать во время регистрации"
"displayNamePlaceholder": "Необязательно. Если не указано, пользователь может указать во время регистрации",
"external2FA": "Настройка 2FA осуществляется внешним ресурсом аутентификации",
"ldapGroups": "Группы LDAP"
},
"deleteUserDialog": {
"title": "Удалить пользователя {{ username }}",
@@ -290,7 +303,7 @@
"description": "Ссылка для сброса пароля отправлена на электронную почту {{ email }}:",
"sendEmailLinkAction": "Отправить ссылку пользователю по электронной почте",
"emailSent": "Отправлено",
"newLinkAction": "Отправить ссылку для сброса пароля",
"newLinkAction": "Отправить ссылку для сброса",
"reset2FAAction": "Сбросить 2FA",
"sendAction": "Отправить письмо",
"descriptionLink": "Скопировать ссылку для сброса пароля",
@@ -353,7 +366,7 @@
"exposedLdap": {
"title": "Сервер LDAP",
"ipRestriction": {
"description": "Сервер каталогов может быть ограничен для определённого круга IP адресов.",
"description": "Ограничьте доступ к серверу каталогов только для определённого круга IP-адресов и диапазонов. Строки, начинающиеся с <code>#</code>, будут считаться комментарием.",
"placeholder": "IP-адреса или подсети, разделённые строками",
"label": "Ограничить доступ"
},
@@ -363,7 +376,8 @@
"label": "Привязать пароль",
"description": "Все запросы LDAP должны быть идентифицированы при помощи данного секрета и уникального имени пользователя (DN) <i>{{ userDN }}</i>",
"url": "URL сервера"
}
},
"cloudflarePortWarning": "Для доступа к LDAP серверу через домен панели управления проксирование Cloudflare должно быть выключено"
},
"userImportDialog": {
"title": "Импорт пользователей",
@@ -387,12 +401,6 @@
"all": "Все пользователи",
"active": "Активные пользователи",
"inactive": "Неактивные пользователи"
},
"makeLocalDialog": {
"title": "Установить этого пользователя локально",
"description": "Данное действие перенесёт пользователя с внешней директории LDAP в Cloudron.",
"warning": "Для создания локального пароля пользователя его прежний пароль будет сброшен.",
"submitAction": "Сделать локальным"
}
},
"profile": {
@@ -405,7 +413,7 @@
"changePassword": {
"currentPassword": "Текущий пароль",
"errorPasswordInvalid": "Пароль должен быть не менее 8 и не более 265 символов",
"title": "Изменить пароль",
"title": "Изменить ваш пароль",
"newPassword": "Новый пароль",
"newPasswordRepeat": "Повторите новый пароль",
"errorPasswordRequired": "Требуется пароль",
@@ -459,7 +467,10 @@
"changeEmail": {
"title": "Изменить главный адрес электронной почты",
"errorEmailInvalid": "Неверный адрес электронной почты",
"errorEmailRequired": "Требуется действительный адрес электронной почты"
"errorEmailRequired": "Требуется действительный адрес электронной почты",
"email": "Новый адрес электронной почты",
"password": "Пароль для подтверждения",
"errorWrongPassword": "Неверный пароль"
},
"changeFallbackEmail": {
"title": "Изменить пароль электронной почты восстановления",
@@ -503,7 +514,8 @@
},
"changeBackgroundImage": {
"title": "Установить фоновое изображение"
}
},
"enable2FANotAvailable": "Недоступно для пользователей из удалённых источников"
},
"app": {
"uninstallDialog": {
@@ -527,7 +539,8 @@
"packageVersion": "Версия контейнера",
"lastUpdated": "Обновлен",
"checkForUpdatesAction": "Проверить обновления",
"repository": "Репозиторий"
"repository": "Репозиторий",
"installedAt": "Установлено"
},
"auto": {
"description": "Cloudron периодически проверяет Магазин приложений на наличие обновлений. Если Вы выключаете автоматические обновления, не забывайте применять их вручную.",
@@ -626,12 +639,12 @@
"title": "Лимит памяти",
"error": "Не получилось установить лимит памяти, попробуйте меньшее значение.",
"resizeAction": "Изменить",
"description": "Cloudron выделяет 50% этого значения из оперативной памяти и 50% из swap."
"description": "Максимальное количество ОЗУ, которое может использовать приложение."
},
"cpu": {
"setAction": "Установить",
"title": "Доля CPU",
"description": "Процент времени CPU, когда система находится под нагрузкой."
"setAction": "Масштабировать",
"title": "Лимит CPU",
"description": "Максимальный процент CPU, который может быть задействован в работе приложения"
}
},
"storage": {
@@ -640,7 +653,8 @@
"moveAction": "Переместить данные",
"dataDirPlaceholder": "Оставьте пустым, чтобы сохранить настройку по умолчанию",
"description": "Если на диске заканчивается место, вы можете перенести данные приложения в <a href=\"/#/volumes\">том</a>. Любые данные по этому пути станут частью резервной копии приложения.",
"diskUsage": "Приложение использует {{ size }} хранилища (по состоянию на {{ date }})."
"diskUsage": "Приложение использует {{ size }} хранилища (по состоянию на {{ date }}).",
"mountTypeWarning": "Чтобы перемещение прошло успешно конечная файловая система должна поддерживать разрешения и права доступа к файлам"
},
"mounts": {
"volume": "Том",
@@ -833,6 +847,23 @@
"label": "Метка",
"clearIconAction": "Очистить иконку",
"clearIconDescription": "Это действие попытается загрузить favicon после сохранения."
},
"servicesTabTitle": "Службы",
"turn": {
"title": "Настроить TURN",
"enable": "Настроить использование встроенного TURN сервера в приложении",
"disable": "Не настраивать TURN сервер для данного приложения. Вы можете настроить его самостоятельно внутри самого приложения."
},
"redis": {
"title": "Настроить Redis",
"enable": "Настроить использование Redis в приложении",
"disable": "Отключить Redis"
},
"infoTabTitle": "Информация",
"info": {
"notes": {
"title": "Заметки администратора"
}
}
},
"backups": {
@@ -848,7 +879,7 @@
"remount": "Перемонтировать хранилище"
},
"listing": {
"stopTask": "Остановить {{ taskType === 'backup' ? 'Backup' : 'Cleanup' }}",
"stopTask": "Остановить Backup",
"title": "Список резервных копий",
"noBackups": "Ещё не было создано ни одной резервной копии.",
"version": "Версия",
@@ -951,7 +982,7 @@
},
"check": {
"noop": "Резервное копирование Cloudron выключено. Пожалуйста, убедитесь, что на сервере настроены альтернативные способы резервного копирования. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers .",
"sameDisk": "Cloudron сохраняет резервные копии на том же диске, где находится он сам. Это опасно, и может привести к потере данных в случае ошибки диска. Советуем ознакомиться с информацией по ссылке https://docs.cloudron.io/backups/#storage-providers для выбора облачного поставщика."
"sameDisk": "В настоящий момент резервные копии сохраняются на системный диск с установленным Cloudron. Обратите внимание, что при полном заполнении диска бэкапами, Cloudron прекратит свою работу. Также, в случае поломки диска, вы можете полностью потерять доступ к вашим данным и бэкапам. Советуем ознакомиться с документацией https://docs.cloudron.io/backups/#storage-providers для выбора внешнего хранилища бэкапов."
},
"backupEdit": {
"title": "Редактировать резервную копию",
@@ -959,7 +990,8 @@
"preserved": {
"description": "Хранить резервную копию, игнорируя политику хранения",
"tooltip": "Также будет сохранена почта и {{ appsLength } резервных копий."
}
},
"remotePath": "Удаленный путь"
}
},
"branding": {
@@ -974,7 +1006,9 @@
},
"changeLogo": {
"title": "Выбрать изображение Cloudron"
}
},
"backgroundImage": "Фоновое изображение экрана входа",
"clearBackgroundImage": "Очистить"
},
"emails": {
"title": "Электронная почта",
@@ -1001,7 +1035,8 @@
"acl": "Почтовый ACL (Access Control List)",
"maxMailSize": "Максимальный размер письма",
"solrFts": "Полный поиск по тексту (Solr)",
"aclOverview": "{{ dnsblZonesCount }} DNSBL зон"
"aclOverview": "{{ dnsblZonesCount }} DNSBL зон",
"virtualAllMail": "Папка \"Вся почта\""
},
"eventlog": {
"title": "Журнал событий электронной почты",
@@ -1035,7 +1070,7 @@
},
"changeDomainDialog": {
"title": "Изменить расположение сервера электронной почты",
"description": "Cloudron внесет необходимые изменения в DNS во всех доменах и перезапустит почтовый сервер. Настольные и мобильные почтовые клиенты должны быть повторно настроены для использования нового расположения сервера IMAP и SMTP.",
"description": "Данное действие перенесёт IMAP и SMTP сервер в указанное расположение.",
"location": "Расположение",
"locationPlaceholder": "Оставьте пустым, чтобы использовать основной домен",
"manualInfo": "Вручную добавьте A запись для {{ domain }}, указав публичный IP Вашего Cloudron"
@@ -1092,12 +1127,16 @@
},
"action": {
"queue": "Очередь"
},
"changeVirtualAllMailDialog": {
"title": "Папка \"Вся почта\"",
"description": "Папка \"Вся почта\" содержит все электронные письма из вашего почтового ящика. Данная папка может быть полезна в том случае, когда ваш почтовый клиент не поддерживает рекурсивный поиск по папкам."
}
},
"network": {
"ip": {
"title": "IP Адрес",
"description": "Cloudron будет использовать данный IP адрес для настройки записей DNS.",
"title": "IPv4",
"description": "Cloudron будет использовать данный IPv4 адрес для настройки A записей DNS.",
"provider": "Источник",
"interface": "Имя сетевого интерфейса",
"configure": "Настроить",
@@ -1118,10 +1157,11 @@
},
"dyndns": {
"title": "Динамический DNS",
"description": "Включите эту опцию, чтобы синхронизировать все ваши DNS-записи с изменяющимся IP-адресом. Это полезно, когда Cloudron работает в сети с часто меняющимся общедоступным IP-адресом, например, в домашних сетях."
"description": "Включите эту опцию, чтобы синхронизировать все ваши DNS-записи с изменяющимся IP-адресом. Это полезно, когда Cloudron работает в сети с часто меняющимся общедоступным IP-адресом, например, в домашних сетях.",
"showLogsAction": "Показать логи"
},
"configureIp": {
"title": "Настроить источник IP",
"title": "Настроить поставщика IPv4",
"providerGenericDescription": "Публичный IP адрес сервера будет обнаружен автоматически."
},
"ipv4": {
@@ -1134,7 +1174,13 @@
},
"configureIpv6": {
"title": "Настройка IPv6"
}
},
"trustedIps": {
"summary": "{{ trustCount }} IP доверены",
"title": "Настроить доверенные IP",
"description": "HTTP заголовки от совпадающих IP адресов будут доверены"
},
"trustedIpRanges": "Доверенные IP и диапазоны "
},
"services": {
"title": "Службы",
@@ -1167,7 +1213,7 @@
"cloudronId": "Cloudron ID",
"subscriptionEndsAt": "Отменена и завершена",
"subscriptionSetupAction": "Обновить до Premium",
"subscriptionChangeAction": "Изменить подписку",
"subscriptionChangeAction": "Управление подпиской",
"subscriptionReactivateAction": "Реактивировать подписку",
"emailNotVerified": "Электронная почта не подтверждена"
},
@@ -1250,7 +1296,8 @@
"emailInfo": "(Электронная почта подписки - {{ email }})",
"subscriptionRequired": "Тикеты поддержки доступны только в платной подписке.",
"subscriptionRequiredDescription": "Вы можете найти ответы на свои вопросы в нашей <a href=\"{{ supportViewLink }}\" target=\"_blank\">документации</a>или попросить помощи на <a href=\"{{ forumLink }}\" target=\"_blank\">форуме</a>.",
"emailNotVerified": "Ваш адрес электронной почты {{ email }} в cloudron.io не подтверждён. Пожалуйста, подтвердите его для доступа к тикетам поддержки."
"emailNotVerified": "Ваш адрес электронной почты {{ email }} в cloudron.io не подтверждён. Пожалуйста, подтвердите его для доступа к тикетам поддержки.",
"typeBilling": "Проблема с выставлением счетов"
},
"remoteSupport": {
"subscriptionRequired": "Удалённая поддержка доступна только в платной подписке.",
@@ -1259,6 +1306,10 @@
"enableAction": "Включить SSH доступ",
"title": "Удалённая поддержка",
"description": "Выберите эту опцию, чтобы позволить сотрудникам поддержки подключиться к Вашему серверу через SSH."
},
"help": {
"title": "Помощь",
"description": "Для поддержки и помощи, пожалуйста, воспользуйтесь следующими ресурсами:\n* [Форум Cloudron]({{ forumLink }}) - пожалуйста, задавайте вопросы в соответствующих темах Поддержки или конкретных приложений.\n* [Документация Cloudron & База знаний]({{ docsLink }})\n* [Создание сторонних приложений и API]({{ packagingLink }})\n"
}
},
"system": {
@@ -1283,7 +1334,19 @@
"graphTitle": "Процент",
"graphSubtext": "Отображаются приложения, использующие более {{ threshold }} CPU"
},
"selectPeriodLabel": "Выберите период"
"selectPeriodLabel": "Выберите период",
"info": {
"platformVersion": "Версия Платформы",
"product": "Продукт",
"vendor": "Поставщик",
"memory": "Память",
"uptime": "Аптайм",
"activationTime": "Время создания Cloudron",
"title": "Информация"
},
"graphs": {
"title": "Графики"
}
},
"eventlog": {
"title": "Журнал",
@@ -1310,7 +1373,7 @@
"changeAction": "Изменить домен",
"cancelAction": "Отменить",
"showLogsAction": "Показать логи",
"description": "Данное действие переместит панель управления и сервер электронной почты на <code>my</code> поддомен выбранного домена."
"description": "Данное действие переместит панель управления на <code>my</code> поддомен выбранного домена."
},
"subscriptionRequired": {
"title": "Требуется подписка",
@@ -1363,7 +1426,14 @@
"hetznerToken": "Токен Hetzner",
"cloudflareDefaultProxyStatus": "Активировать прокси для новых DNS записей",
"porkbunApikey": "API Ключ",
"porkbunSecretapikey": "Secret API Ключ"
"porkbunSecretapikey": "Secret API Ключ",
"bunnyAccessKey": "Ключ доступа Bunny",
"dnsimpleAccessToken": "Токен доступа",
"ovhEndpoint": "Конечная точка",
"ovhConsumerKey": "Ключ пользователя",
"ovhAppKey": "Ключ приложения",
"ovhAppSecret": "Секрет приложения",
"deSecToken": "deSEC Токен"
},
"addDomain": "Добавить домен",
"removeDialog": {
@@ -1380,7 +1450,8 @@
"domainWellKnown": {
"title": "Общеизвестные расположения {{ domain }}"
},
"tooltipWellKnown": "Установить общеизвестные расположения"
"tooltipWellKnown": "Установить общеизвестные расположения",
"count": "Всего доменов: {{ count }}"
},
"notifications": {
"title": "Уведомления",
@@ -1392,7 +1463,9 @@
"logs": {
"title": "Логи",
"clear": "Очистить обзор",
"download": "Скачать полные логи"
"download": "Скачать полные логи",
"notFoundError": "Задача или приложение не существует",
"logsGoneError": "Файл(ы) журнала не найден(ы)"
},
"terminal": {
"title": "Терминал",
@@ -1434,7 +1507,8 @@
"renameDialog": {
"newName": "Новое имя",
"rename": "Переименовать",
"title": "Переименовать {{ fileName }}"
"title": "Переименовать {{ fileName }}",
"reallyOverwrite": "Файл с таким именем уже существует. Хотите перезаписать его?"
},
"chownDialog": {
"newOwner": "Новый владелец",
@@ -1477,7 +1551,8 @@
"cut": "Вырезать",
"paste": "Вставить",
"selectAll": "Выбрать все",
"copy": "Скопировать"
"copy": "Скопировать",
"open": "Открыть"
},
"symlink": "Символическая ссылка на {{ target }}",
"mtime": "Изменён"
@@ -1505,7 +1580,19 @@
},
"status": {
"restartingApp": "перезапускаем приложение"
}
},
"extractionInProgress": "Идёт извлечение",
"uploader": {
"exitWarning": "Загрузка ещё не завершена. Вы уверены, что хотите закрыть страницу?",
"uploading": "Загружаем"
},
"textEditor": {
"undo": "Отменить операцию",
"redo": "Повторить операцию",
"save": "Сохранить"
},
"pasteInProgress": "Выполняется копирование / перемещение",
"deleteInProgress": "Выполняется удаление"
},
"email": {
"outbound": {
@@ -1544,8 +1631,8 @@
"title": "Включить электронную почту для {{ domain }}?",
"setupDnsCheckbox": "Установить почтовые DNS записи",
"enableAction": "Включить",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Прости ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен только режим <code>DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"description": "Данный параметр настроит Cloudron на получение писем для <b>{{ domain }}</b>. Рекомендуем ознакомиться с <a href=\"{{ requiredPortsDocsLink }}\" target=\"_blank\">документацией</a> для открытия необходимых почтовому серверу портов.",
"cloudflareInfo": "Почтовый домен <code>{{ adminDomain }}</code> управляется при помощи Cloudflare. Пожалуйста, удостоверьтесь, что проксирование для <code>{{ mailFqdn}}</code> отключено, и активен режим <code>только DNS</code>. Это необходимо, так как Cloudflare не проксирует электронную почту.",
"setupDnsInfo": "Используйте данную опцию, чтобы автоматически настроить относящиеся к электронной почте записи DNS. Вы можете не отмечать её сразу, чтобы предварительно создать почтовые ящики и <a href=\"{{ importEmailDocsLink }}\">импортировать письма</a>."
},
"backAction": "Вернуться к электронной почте",
@@ -1702,7 +1789,10 @@
"loginTo": "Войти в",
"username": "Имя пользователя",
"2faToken": "2FA Токен (если включен)",
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль"
"errorIncorrectCredentials": "Неправильное имя пользователя или пароль",
"errorIncorrect2FAToken": "Неверный 2FA токен",
"errorInternal": "Внутренняя ошибка, попробуйте позже",
"loginWith": "Войти с Cloudron"
},
"passwordReset": {
"title": "Сброс пароля",
@@ -1762,7 +1852,11 @@
"title": "Тома",
"hostPath": "Назначение",
"description": "Тома - локальные или удаленные файловые системы. Они могут быть использованы для хранения данных приложений или для создания общей директории для нескольких приложений.",
"localDirectory": "Локальный каталог"
"localDirectory": "Локальный каталог",
"editVolumeDialog": {
"title": "Редактирование тома {{ name }}"
},
"editActionTooltip": "Редактировать том"
},
"lang": {
"en": "Английский",
@@ -1776,7 +1870,8 @@
"zh_Hans": "Китайский (Упрощенный)",
"es": "Испанский",
"ru": "Русский",
"pt": "Португальский"
"pt": "Португальский",
"da": "Датский"
},
"setupAccount": {
"username": "Имя пользователя",
@@ -1835,5 +1930,43 @@
"mounts": {
"description": "Приложения могут получить доступ к смонтированным <a href=\"/#/volumes\">томам</a> по пути <code>/media/{имя тома}</code>. Данные таких томов не будут включаться в резервные копии приложения."
}
}
},
"oidc": {
"newClientDialog": {
"createAction": "Создать",
"title": "Добавить клиента",
"description": "Добавить настройки нового клиента OpenID connect."
},
"client": {
"name": "Имя",
"id": "ID Клиента",
"secret": "Секрет",
"signingAlgorithm": "Метод подписи",
"loginRedirectUri": "URL обратного вызова (если больше одного, отделите их запятой)",
"logoutRedirectUri": "URL обратного вызова для выхода из системы (необязательно)"
},
"clients": {
"title": "Клиенты",
"newClient": "Новый клиент",
"empty": "Клиенты не найдены"
},
"title": "Поставщик OpenID Сonnect",
"description": "Cloudron может выступать в качестве поставщика OpenID connect для внутренних приложений и внешних сервисов.",
"editClientDialog": {
"title": "Редактировать клиента {{ client }}"
},
"deleteClientDialog": {
"title": "Вы точно хотите удалить клиента {{ client }}?",
"description": "Это действие отключит все внешние OpenID приложения, использующие данный клиент ID, от Cloudron."
},
"env": {
"discoveryUrl": "URL обнаружения",
"logoutUrl": "URL выхода из системы",
"profileEndpoint": "Конечная точка профиля",
"keysEndpoint": "Конечная точка ключей",
"tokenEndpoint": "Конечная точка токена",
"authEndpoint": "Конечная точка аутентификации"
}
},
"automation": "Автоматизация"
}
+193 -53
View File
@@ -18,12 +18,18 @@
"title": "Chưa có app cài đặt!",
"description": "Cài đặt một vài app nhé? Hãy xem trong <a href=\"{{ appStoreLink }}\">Cửa hàng App</a>"
},
"groupsFilterHeader": "Chọn nhóm",
"groupsFilterHeader": "Tất cả Nhóm",
"auth": {
"email": "Đăng nhập bằng email",
"sso": "Đăng nhập với tên & mật khẩu trên Cloudron",
"nosso": "Đăng nhập vào tài khoản riêng"
}
},
"addAppAction": "Thêm App",
"addApplinkAction": "Thêm đường link App",
"filter": {
"clearAll": "Xoá tất cả"
},
"addAppproxyAction": "Thêm proxy cho app"
},
"main": {
"logout": "Thoát",
@@ -32,7 +38,8 @@
"save": "Lưu",
"close": "Đóng",
"no": "Không",
"yes": "Có"
"yes": "Có",
"delete": "Xoá"
},
"username": "Tên đăng nhập",
"displayName": "Tên hiển thị",
@@ -42,11 +49,13 @@
"pagination": {
"prev": "trước",
"next": "tiếp",
"perPageSelector": "Hiển thị {{ n }} trên một trang"
"perPageSelector": "Hiển thị {{ n }} trên một trang",
"itemCount": "Đã tìm thấy {{ count }}"
},
"action": {
"reboot": "Khởi động lại",
"logs": "Log"
"logs": "Log",
"showLogs": "Hiển thị log"
},
"clipboard": {
"clickToCopy": "Bấm để copy",
@@ -79,7 +88,10 @@
"users": "Người dùng"
},
"enableAction": "Bật",
"disableAction": "Tắt"
"disableAction": "Tắt",
"loadingPlaceholder": "Đang tải",
"settings": "Cài đặt",
"saveAction": "Lưu"
},
"appstore": {
"title": "Cửa hàng App",
@@ -134,7 +146,8 @@
"configuredForCloudronEmail": "App này đã được cấu hình sẵn để sử dụng với <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron Email</a>.",
"doInstallAction": "Tải về {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
"cloudflarePortWarning": "Cần tắt proxy Cloudflare để tên miền app này có thể truy cập được vào cổng",
"titleAndVersion": "App này đóng gói phần mềm {{ title }} {{ version }}"
"titleAndVersion": "App này đóng gói phần mềm {{ title }} {{ version }}",
"portReadOnly": "chỉ-đọc"
},
"appNotFoundDialog": {
"title": "Không tìm thấy app",
@@ -226,7 +239,6 @@
"subscriptionRequiredAction": "Cài đặt gói đăng ký ngay",
"description": "Cloudron sẽ đồng bộ người dùng và nhóm từ server LDAP hay ActiveDirectory bên ngoài. Xác minh mật khẩu cho người dùng được dựa trên server ngoài. Việc đồng bộ hoá không được chạy tự động mà cần được khởi động bằng tay.",
"title": "Kết nối thư mục ngoài",
"subscriptionRequired": "Tính năng này chỉ có trong gói trả phí.",
"providerOther": "Khác",
"providerDisabled": "Đã tắt"
},
@@ -247,8 +259,7 @@
"invitationTooltip": "Mời Người dùng",
"setGhostTooltip": "Nhập vai",
"count": "Tổng ng dùng: {{ count }}",
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư",
"makeLocalTooltip": "Người dùng địa phương"
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư"
},
"settings": {
"saveAction": "Lưu",
@@ -256,7 +267,7 @@
"subscriptionRequired": "Chức năng này chỉ có trong gói trả phí.",
"require2FACheckbox": "Yêu cầu người dùng cài đặt Mã xác minh 2 bước",
"allowProfileEditCheckbox": "Cho phép người dùng chỉnh sửa tên và email",
"title": "Cài đặt",
"title": "Cài đặt Người dùng",
"require2FAWarning": "Hãy cài đặt Mã xác minh 2 Bước cho tài khoản của bạn trước đề phòng bị khoá ra khỏi TK."
},
"groups": {
@@ -328,8 +339,9 @@
"label": "Giới hạn quyền truy cập"
},
"secret": {
"label": "Mã bí mật",
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>"
"label": "Mật khẩu bind",
"description": "Tất cả những yêu cầu LDAP cần phải được xác minh với mã bí mật này và tên người dùng user DN <i>{{ userDN }}</i>",
"url": "URL máy chủ"
}
},
"userImportDialog": {
@@ -352,12 +364,6 @@
"all": "Tất cả Người dùng",
"active": "Những người dùng đang hoạt động"
},
"makeLocalDialog": {
"description": "Chức năng này sẽ di chuyển người dùng từ chỉ mục ngoài vào trong Cloudron.",
"title": "Người dùng địa phương",
"warning": "Phần đặt lại mật khẩu sẽ được kích hoạt để đặt một mật khẩu địa phương cho người dùng này.",
"submitAction": "Địa phương hoá"
},
"setGhostDialog": {
"generatePassword": "Tạo mật khẩu",
"title": "Tạo mật khẩu để nhập vai người dùng {{ username }}",
@@ -435,7 +441,8 @@
"description": "Mã API mới:",
"copyNow": "Xin copy mã API này bây giờ. Nó sẽ không được hiển thị lại vì lý do an ninh.",
"generateToken": "Tạo mã API",
"name": "Tên cho mã API"
"name": "Tên cho mã API",
"access": "Truy cập API"
},
"enable2FAAction": "Bật xác minh hai bước",
"primaryEmail": "Email chính",
@@ -458,7 +465,10 @@
"name": "Tên",
"expiresAt": "Hết hiệu lực vào",
"lastUsed": "Lần dùng cuối",
"neverUsed": "chưa từng dùng"
"neverUsed": "chưa từng dùng",
"readonly": "Chỉ đọc",
"scope": "Mức độ bao phủ",
"readwrite": "Đọc và Ghi"
},
"loginTokens": {
"title": "Mã đăng nhập",
@@ -540,7 +550,7 @@
"mountPoint": "Điểm mount",
"noopNote": "Lựa chọn này sẽ làm hỏng tính năng sao lưu và khôi phục của Cloudron và chỉ nên dùng khi test hệ thống. Xin đảm bảo rằng server được sao lưu toàn bộ bằng những phương tiện khác.",
"format": "Định dạng lưu trữ",
"encryptedFilenames": "Mã hoá tên tập tin",
"encryptedFilenames": "Tên tập tin đã mã hoá",
"chown": "Hệ thống tập tin bên ngoài có hỗ trợ chown",
"username": "Tên đăng nhập",
"server": "IP hoặc hostname máy chủ",
@@ -552,7 +562,8 @@
"user": "Người dùng",
"privateKey": "Mật mã riêng",
"diskPath": "Đường dẫn đến ổ đĩa",
"cifsSealSupport": "Dùng mã hoá SEAL. Cần SMB thấp nhất là phiên bản v3"
"cifsSealSupport": "Dùng mã hoá SEAL. Cần SMB thấp nhất là phiên bản v3",
"encryptFilenames": "Mã hoá tên tập tin"
},
"cleanupBackups": {
"description": "Các bản sao lưu được dọn sạch tự động dựa trên thời gian lưu giữ. Thao tác này sẽ xoá ngay lập tức các bản sao lưu đang có.",
@@ -584,7 +595,7 @@
"title": "Log"
},
"listing": {
"stopTask": "Dừng {{ taskType === 'backup' ? 'Backup' : 'Cleanup' }}",
"stopTask": "Dừng Backup",
"backupNow": "Sao lưu ngay bây giờ",
"cleanupBackups": "Dọn sạch bản sao lưu",
"tooltipDownloadBackupConfig": "Tải xuống cấu hình bản sao lưu",
@@ -624,7 +635,9 @@
"password": "Mật khẩu",
"username": "Tên đăng nhập",
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
"loginTo": "Đăng nhập vào"
"loginTo": "Đăng nhập vào",
"errorIncorrect2FAToken": "Mã bảo mật 2 Bước không đúng",
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau"
},
"setupAccount": {
"username": "Tên đăng nhập",
@@ -763,7 +776,8 @@
"noAliases": "Không có tên gọi khác nào được chỉnh.",
"aliases": "Tên gọi khác",
"owner": "Chủ hộp thư",
"title": "Chỉnh sửa hộp thư {{ name }}@{{ domain }}"
"title": "Chỉnh sửa hộp thư {{ name }}@{{ domain }}",
"enableStorageQuota": "Bật giới hạn lưu trữ"
},
"addMailboxDialog": {
"owner": "Chủ hộp thư",
@@ -846,7 +860,8 @@
},
"dyndns": {
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
"title": "DNS động"
"title": "DNS động",
"showLogsAction": "Hiển thị log"
},
"firewall": {
"configure": {
@@ -879,7 +894,13 @@
},
"configureIpv6": {
"title": "Cài đặt nhà cung cấp IPv6"
}
},
"trustedIps": {
"summary": "{{ trustCount }} địa chỉ IP được tin tưởng",
"description": "Những HTTP header từ những địa chỉ IP trùng khớp sẽ được chấp thuận cho qua",
"title": "Thiết lập những địa chỉ IP đáng tin cậy"
},
"trustedIpRanges": "Địa chỉ IP & Vùng được tin cậy "
},
"emails": {
"typeFilterHeader": "Tất cả sự kiện",
@@ -914,7 +935,7 @@
"locationPlaceholder": "Để trống để dùng tên miền gốc",
"location": "Vị trí",
"title": "Thay đổi vị trí đặt mail server",
"description": "Cloudron sẽ thay đổi những giá trị DNS cần thiết cho tất cả tên miền và khởi động lại mail server. Những client nhận mail trên máy tính hay điện thoại cần được cài đặt lại để sử dụng vị trí mới này làm IMAP và SMTP server."
"description": "Hành động này sẽ di chuyển server IMAP và SMTP đến vị trí được xác định."
},
"eventlog": {
"searchPlaceholder": "Tìm kiếm",
@@ -933,7 +954,10 @@
"queued": "Xếp hàng",
"outgoing": "Gửi mail ra",
"incoming": "Nhận mail vào",
"deferred": "Trì hoãn lại"
"deferred": "Trì hoãn lại",
"overQuotaInfo": "Hộp thư {{ mailbox }} đã đầy {{ quotaPercent }}%",
"underQuotaInfo": "Hộp thư {{ mailbox }} đã rơi xuống còn {{ quotaPercent }}% của hạn mức",
"quota": "Hạn mức hộp thư"
},
"empty": "Log sự kiện hiện đang trống.",
"details": "Chi tiết",
@@ -950,8 +974,8 @@
"solrEnabled": "Đã bật",
"solrDisabled": "Đã tắt",
"changeDomainProgress": "Thay đổi tên miền email:",
"spamFilterOverview": "{{ blacklistCount }} email có trong danh sách đen.",
"location": "Nơi đặt mail server",
"spamFilterOverview": "{{ blacklistCount }} email có trong danh sách bị chặn.",
"location": "Nơi đặt máy chủ mail",
"spamFilter": "Lọc spam",
"maxMailSize": "Kích cỡ mail tối đa",
"info": "Các cài đặt này áp dụng cho tất cả các tên miền.",
@@ -981,6 +1005,19 @@
"dnsblZonesInfo": "Địa chỉ IP đang muốn kết nối đến được dò tìm trong những danh sách IP bị chặn này",
"dnsblZonesPlaceholder": "Tên vùng (ghi xuống dòng)",
"title": "Đổi danh sách quản lý truy cập mail"
},
"queue": {
"empty": "Danh sách mail chờ đang trống",
"title": "Danh sách mail chờ gửi",
"rcptTo": "Gửi cho",
"mailFrom": "Đến từ",
"details": "Chi tiết",
"discardTooltip": "Bỏ qua",
"queueTime": "Thời gian chờ",
"resendTooltip": "Gửi lại ngay"
},
"action": {
"queue": "Cho vào hàng chờ gửi sau"
}
},
"branding": {
@@ -1009,10 +1046,11 @@
"selectPeriodLabel": "Chọn khoảng thời gian",
"cpuUsage": {
"graphTitle": "Phần trăm sử dụng",
"title": "Dung lượng CPU"
"title": "Dung lượng CPU",
"graphSubtext": "Chỉ những app sử dụng hơn {{ threshold }} cpu mới được hiển thị"
},
"systemMemory": {
"graphSubtext": "Các giá trị bộ nhớ riêng từng app không hiển thị chồng lên nhau",
"graphSubtext": "Chỉ những app sử dụng hơn {{ threshold }} bộ nhớ mới được hiển thị",
"title": "Bộ nhớ hệ thống"
},
"diskUsage": {
@@ -1020,7 +1058,11 @@
"diskContent": "Ổ đĩa {{ type }} này hiện chứa",
"usageInfo": "Còn {{ available | prettyDiskSize }}</b> trống trong tổng <b>{{ size | prettyDiskSize }}</b>",
"mountedAt": "{{ filesystem }} <small>được gắn ở</small> {{ mountpoint }}",
"title": "Dung lượng ổ đĩa"
"title": "Dung lượng ổ đĩa",
"usedInfo": "{{ used }} đã dùng trong tổng {{ size }}",
"volumeContent": "Ổ đĩa này thuộc volume <code>{{ name }}</code>",
"uninstalledApp": "App đã xoá",
"diskSpeed": "Tốc độ: {{ speed }} MB/s"
},
"title": "Hệ thống"
},
@@ -1175,7 +1217,8 @@
"download": "Tải xuống",
"extract": "Giải nén tại đây",
"chown": "Đổi quyền sở hữu",
"rename": "Đổi tên"
"rename": "Đổi tên",
"open": "Mở"
},
"name": "Tên",
"symlink": "Liên kết symlink đến {{ target }}",
@@ -1233,7 +1276,19 @@
},
"removeDialog": {
"reallyDelete": "Chắc chắn xoá?"
}
},
"uploader": {
"exitWarning": "Vẫn đang tải lên. Bạn có chắc muốn đóng trang này?",
"uploading": "Đang tải lên"
},
"textEditor": {
"undo": "Hoàn tác",
"redo": "Xóa hoàn tác",
"save": "Lưu"
},
"extractionInProgress": "Đang giải nén",
"pasteInProgress": "Đang dán",
"deleteInProgress": "Đang xoá"
},
"terminal": {
"contextmenu": {
@@ -1265,7 +1320,9 @@
"logs": {
"download": "Tải xuống tất cả log",
"clear": "Làm sạch phần xem log",
"title": "Log"
"title": "Log",
"notFoundError": "Không có tác vụ hay app đó",
"logsGoneError": "Tập tin log không được tìm thấy"
},
"notifications": {
"clearAll": "Xoá hết",
@@ -1323,7 +1380,11 @@
"wellKnownDescription": "Những giá trị nhập vào này sẽ được dùng bởi Cloudron để phản hồi về những đường link <code>/.well-known/</code>. Lưu ý rằng một app cần được đang chạy cài đặt sẵn trên tên miền gốc <code>{{ domain }}</code> để tính năng này có thể hoạt động được. Xem phần <a href=\"{{docsLink}}\" target=\"_blank\">hướng dẫn sử dụng</a> để biết thêm thông tin.",
"vultrToken": "Mật mã Vultr",
"jitsiHostname": "Vị trí Jitsi",
"hetznerToken": "Mật mã Hetzner"
"hetznerToken": "Mật mã Hetzner",
"cloudflareDefaultProxyStatus": "Bật tính năng proxy cho những bản ghi DNS mới",
"porkbunSecretapikey": "Mã bí mật API",
"bunnyAccessKey": "Mã truy cập Bunny",
"porkbunApikey": "Key API"
},
"subscriptionRequired": {
"description": "Để thêm tên miền, hãy đăng ký gói trả phí.",
@@ -1358,7 +1419,8 @@
"domainWellKnown": {
"title": "Những vị trí Well-Known của {{ domain }}"
},
"tooltipWellKnown": "Cài đặt những vị trí Well-Known"
"tooltipWellKnown": "Cài đặt những vị trí Well-Known",
"count": "Tổng số tên miền: {{ count }}"
},
"app": {
"appInfo": {
@@ -1423,7 +1485,8 @@
"time": "Tạo ra lúc",
"packageVersion": "Phiên bản đóng gói",
"description": "Bản sao lưu là những bản chụp snapshot hoàn chỉnh của app. Bạn có thể dùng các bản sao lưu để khôi phục hoặc nhân bản app này.",
"title": "Bản sao lưu"
"title": "Bản sao lưu",
"downloadBackupTooltip": "Tải bản sao lưu"
}
},
"updates": {
@@ -1443,8 +1506,10 @@
"packageVersion": "Phiên bản đóng gói",
"appId": "ID của app",
"description": "Tên app và phiên bản",
"title": "Thông tin app"
}
"title": "Thông tin app",
"repository": "Repo của bản đống gói"
},
"noUpdates": "Không có phiên bản mới"
},
"security": {
"robots": {
@@ -1456,7 +1521,8 @@
"saveAction": "Lưu",
"title": "Chính sách an ninh nội dung",
"description": "Cài đặt lựa chọn này sẽ ghi chèn lên những CSP header gửi từ app này ra"
}
},
"hstsPreload": "Bật HSTS preload cho trang web này và tất cả tên miền phụ"
},
"email": {
"csp": {
@@ -1490,7 +1556,10 @@
"24h": "24 tiếng trước",
"12h": "12 tiếng trước",
"6h": "6 tiếng"
}
},
"diskTitle": "Dung lượng ổ đĩa",
"diskIOTotal": "tổng: đọc {{ read }} / ghi {{ write }}",
"networkIOTotal": "tổng: vào {{ inbound }} / ra {{ outbound }}"
},
"storage": {
"mounts": {
@@ -1499,13 +1568,20 @@
"noMounts": "Không có volume được gắn thêm.",
"volume": "Volume",
"title": "Thư mục mount thêm",
"readOnly": "Chỉ cho phép đọc"
"readOnly": "Chỉ cho phép đọc",
"permissions": {
"readOnly": "Chỉ cho phép đọc",
"readWrite": "Đọc và ghi",
"label": "Quyền cấp phép"
}
},
"appdata": {
"moveAction": "Chuyển dữ liệu",
"dataDirPlaceholder": "Để trống để dùng giá trị mặc định của hệ thống",
"description": "Nếu hệ thống đang chạy sắp hết dung lượng ổ đĩa, hãy dùng chức năng này để dời những dữ liệu của app sang qua <a href=\"/#/volumes\">volume</a>. Bất cứ dữ liệu nào trong đây đều được sao lưu như một phần trong tổng thể app.",
"title": "Thư mục Dữ liệu"
"title": "Thư mục Dữ liệu",
"diskUsage": "App hiện đang dùng {{ size }} trong bộ lưu trữ (tính đến ngày{{ date }}).",
"mountTypeWarning": "Hệ thống tập tin điểm cuối phải hỗ trợ quyền cấp phép và sở hữu cho tập tin để có thể di chuyển dữ liệu"
}
},
"resources": {
@@ -1614,7 +1690,7 @@
"setupSubscriptionAction": "Cài đặt gói đăng ký",
"skipBackupCheckbox": "Bỏ qua sao lưu",
"subscriptionExpired": "Gói đăng ký Cloudron của bạn đã hết hạn. Xin cài đặt một gói đăng ký để cập nhật app.",
"changelogHeader": "Những thay đổi trong phiên bản mới {{ version}}:",
"changelogHeader": "Những thay đổi trong phiên bản dóng gói mới {{ version}}:",
"unstableWarning": "Bản cập nhật này là phiên bản ra mắt sớm và chưa được ổn định. Xin lưu ý rủi ro khi cập nhật.",
"title": "Cập nhật {{ app }}"
},
@@ -1622,7 +1698,8 @@
"importAction": "Nhập vào",
"uploadAction": "Tải lên cấu hình bản sao lưu",
"description": "Những dữ liệu được tạo ra tính từ thời điểm này và lần sao lưu cuối cùng sẽ bị mất vĩnh viễn. Bạn nên tạo một bản sao lưu của những dữ liệu hiện tại trước khi thực hiện việc nhập vào.",
"title": "Nhập bản sao lưu vào"
"title": "Nhập bản sao lưu vào",
"remotePath": "Đường dẫn bản sao lưu"
},
"repairDialog": {
"retryAction": "Thử lại {{ task }}",
@@ -1661,7 +1738,30 @@
"eventlogTabTitle": "Log sự kiện",
"sftpInfoAction": "Quyền truy cập SFPT",
"cronTabTitle": "Tác vụ lặp lai cron",
"forumUrlAction": "Cần trợ giúp? Hãy hỏi thử trên diễn đàn nhé"
"forumUrlAction": "Cần trợ giúp? Hãy hỏi thử trên diễn đàn nhé",
"servicesTabTitle": "Dịch vụ",
"turn": {
"title": "Cài đặt TURN",
"enable": "Thiết lập app để sử dụng máy chủ TURN được cài sẵn",
"disable": "Không thiết lập TURN cho app này. Các cài đặt TURN cho app được giữ nguyên. Bạn có thể tuỳ chỉnh thêm trong app."
},
"redis": {
"title": "Thiết lập Redis",
"enable": "Thiết lập app sử dụng Redis"
},
"addApplinkDialog": {
"title": "Thêm link app bên ngoài"
},
"editApplinkDialog": {
"deleteAction": "Xoá",
"title": "Chỉnh sửa link app"
},
"applinks": {
"clearIconDescription": "Hệ thống sẽ lấy favicon của app sau khi bạn bấm lưu.",
"upstreamUri": "Đường dẫn bên ngoài",
"label": "Nhãn",
"clearIconAction": "Xoá biểu tượng"
}
},
"volumes": {
"name": "Tên volume",
@@ -1688,7 +1788,7 @@
},
"removeVolumeActionTooltip": "Xoá volume",
"openFileManagerActionTooltip": "Mở Quản lý tập tin",
"hostPath": ường dẫn mount",
"hostPath": iểm đến",
"addVolumeAction": "Thêm volume",
"updateVolumeDialog": {
"title": "Cập nhật Volume {{ volume }}"
@@ -1720,7 +1820,9 @@
"de": "Tiếng Đức",
"en": "Tiếng Anh",
"es": "Tiếng Tây Ban Nha",
"ru": "Tiếng Nga"
"ru": "Tiếng Nga",
"da": "Tiếng Đan Mạch",
"pt": "Tiếng Bồ Đào Nha"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
@@ -1767,5 +1869,43 @@
"mounts": {
"description": "Các app có thể truy cập vào <a href=\"/#/volumes\">những volume</a> được mount lên thông qua thư mục <code>/media/{volume name}</code>. Dữ liệu này không được bao gồm trong phần bản sao lưu của app."
}
}
},
"oidc": {
"newClientDialog": {
"title": "Thêm client",
"description": "Thêm cài đặt client kết nối OpenID mới.",
"createAction": "Tạo"
},
"client": {
"loginRedirectUri": "Đường dẫn callback khi đăng nhập (viết cách ra bởi dấu phẩy nếu có nhiều hơn một)",
"name": "Tên",
"id": "ID client",
"secret": "Mật khẩu client",
"signingAlgorithm": "Thuật toán ký mã hoá",
"logoutRedirectUri": "Đường dẫn callback khi đăng nhập (không bắt buộc)"
},
"description": "Cloudron có thể làm nhà cung cấp kết nối OpenID cho các app trong và ngoài hệ thống.",
"clients": {
"title": "Client",
"newClient": "Thêm client mới",
"empty": "Chưa có client"
},
"title": "Nhà cung cấp kết nối OpenID",
"editClientDialog": {
"title": "Chỉnh sửa client {{ client }}"
},
"deleteClientDialog": {
"title": "Chắc chắn muốn xoá client {{ client }}?",
"description": "Thao tác này sẽ ngắt kết nối tất cả app OpenID bên ngoài có trong Cloudron sử dụng ID client này."
},
"env": {
"discoveryUrl": "Đường dẫn Tìm kiếm",
"logoutUrl": "Đường dẫn đăng xuất",
"profileEndpoint": "Điểm cuối hồ sơ",
"keysEndpoint": "Điểm cuối mật mã",
"authEndpoint": "Điểm cuối Auth",
"tokenEndpoint": "Điểm cuối token"
}
},
"automation": "Tự động hoá"
}
+1 -9
View File
@@ -157,7 +157,7 @@
"appCount": "{{ appCount }} 个应用",
"cleanupBackups": "清理备份",
"backupNow": "现在备份",
"stopTask": "停止 {{ taskType === 'backup' ? '备份' : '清理' }}",
"stopTask": "停止 备份",
"tooltipDownloadBackupConfig": "下载备份配置",
"tooltipPreservedBackup": "该备份将会被保留",
"tooltipEditBackup": "编辑备份"
@@ -405,7 +405,6 @@
"empty": "没有用户",
"resetPasswordTooltip": "重设密码",
"transferOwnershipTooltip": "转让所有权",
"makeLocalTooltip": "设为本地用户",
"invitationTooltip": "邀请用户",
"setGhostTooltip": "模拟该用户",
"mailmanagerTooltip": "该用户可以管理用户和邮箱",
@@ -429,7 +428,6 @@
},
"externalLdap": {
"title": "连接外部用户目录",
"subscriptionRequired": "这个功能仅在付费订阅后可用。",
"subscriptionRequiredAction": "现在就设置订阅",
"noopInfo": "LDAP 认证未配置。",
"provider": "Provider",
@@ -549,12 +547,6 @@
"setPassword": "设置密码",
"generatePassword": "生成密码"
},
"makeLocalDialog": {
"title": "将该用户改为本地用户",
"warning": "会为该用户触发一次密码重置来设置本地密码。",
"description": "该操作将会将用户从外部用户目录迁移到 Cloudron。",
"submitAction": "设为本地用户"
},
"exposedLdap": {
"secret": {
"label": "密钥",
+319 -118
View File
@@ -12,12 +12,47 @@
<div ng-bind-html="app.manifest.postInstallMessage | markdown2html"></div>
</div>
<div class="modal-footer">
<div class="form-group pull-left" ng-show="postInstallMessage.openApp">
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="postInstallMessage.confirmed">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ postInstallMessage.confirmed ? ('https://' + app.fqdn) : '' }}" target="_blank" ng-disabled="!postInstallMessage.confirmed" ng-click="postInstallMessage.submit()" ng-show="postInstallMessage.openApp">{{ 'app.appInfo.openAction' | tr:{ app: app.manifest.title } }}</a>
</div>
</div>
</div>
</div>
<!-- Modal postinstall confirm -->
<div class="modal fade" id="appPostInstallConfirmModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appPostInstallConfirm.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-info-icon"/>
<h5 class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}
<span class="app-info-meta text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<br/>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<br/>
</h5>
</div>
<div class="modal-body">
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
{{ item.message }}
</div>
</div>
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
<div ng-bind-html="appPostInstallConfirm.app.manifest.postInstallMessage | markdown2html"></div>
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
</div>
<div class="modal-footer">
<div class="form-group pull-left">
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
<label class="control-label" for="appPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ postInstallMessage.confirmed ? ('https://' + app.fqdn) : '' }}" target="_blank" ng-disabled="!postInstallMessage.confirmed" ng-click="postInstallMessage.submit()" ng-show="postInstallMessage.openApp">{{ 'app.appInfo.openAction' | tr:{ app: app.manifest.title } }}</a>
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
</div>
</div>
</div>
@@ -28,7 +63,7 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#ftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
<h4>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#sftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
</div>
<div class="modal-body">
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
@@ -69,6 +104,46 @@
</div>
</div>
<!-- Modal backup details -->
<div class="modal fade" id="backupDetailsModal" tabindex="-1" role="dialog">
<div class="modal-dialog" style="width: 750px">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'backups.backupDetails.title' | tr }}</h4>
</div>
<div class="modal-body">
<div class="row">
<div class="col-xs-1 text-muted">{{ 'backups.backupDetails.id' | tr }}:</div>
<div class="col-xs-11 text-right">{{ backupDetails.backup.id }}</div>
</div>
<div class="row">
<div class="col-xs-2 text-muted">{{ 'backups.backupEdit.label' | tr }}:</div>
<div class="col-xs-10 text-right">{{ backupDetails.backup.label }}</div>
</div>
<div class="row">
<div class="col-xs-2 text-muted">{{ 'backups.backupEdit.remotePath' | tr }}:</div>
<div class="col-xs-10 text-right">{{ backupDetails.backup.remotePath }}</div>
</div>
<div class="row">
<div class="col-xs-2 text-muted">{{ 'backups.backupDetails.date' | tr }}:</div>
<div class="col-xs-10 text-right">{{ backupDetails.backup.creationTime | prettyLongDate }}</div>
</div>
<div class="row">
<div class="col-xs-2 text-muted">{{ 'backups.backupDetails.version' | tr }}:</div>
<div class="col-xs-10 text-right">v{{ backupDetails.backup.packageVersion }}</div>
</div>
<div class="row">
<div class="col-xs-2 text-muted">{{ 'backups.backupDetails.format' | tr }}:</div>
<div class="col-xs-10 text-right">{{ backupDetails.backup.format }}</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal edit individual backup (label and retention sec) -->
<div class="modal fade" id="editBackupModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -260,32 +335,45 @@
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.mountPoint }" ng-show="importBackup.provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="importBackup.provider === 'mountpoint'">
<label class="control-label" for="inputImportMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputImportMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="importBackup.provider === 'mountpoint'">
</div>
<!-- S3/Minio/SOS/GCS/SSHFS/CIFS/NFS/B2/Mountpoint -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.prefix }" ng-show="importBackup.provider !== 'filesystem'">
<label class="control-label" for="inputImportBackupPrefix">{{ 'backups.configureBackupStorage.prefix' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.prefix" id="inputImportBackupPrefix" name="prefix" ng-disabled="importBackup.busy" placeholder="Prefix for backup file names">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.host" id="configureBackupHost" name="host" ng-disabled="importBackup.busy" placeholder="Server IP or hostname" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.host" id="importBackupHost" name="host" ng-disabled="importBackup.busy" placeholder="Server IP or hostname" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="importBackup.mountOptions.port" id="configureBackupPort" name="port" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="importBackup.mountOptions.port" id="importBackupPort" name="port" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="importBackup.provider === 'cifs'">
<label>
<input type="checkbox" ng-model="importBackup.mountOptions.seal">{{ 'backups.configureBackupStorage.cifsSealSupport' | tr }}</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="importBackup.busy" placeholder="/share" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.remoteDir" id="importBackupRemoteDir" name="remoteDir" ng-disabled="importBackup.busy" placeholder="/share" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- EXT4/XFS -->
<div class="form-group" ng-show="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'" ng-class="{ 'has-error': importBackup.error.diskPath }">
<label class="control-label" for="configureBackupDiskPath">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.diskPath" id="configureBackupDiskPath" name="diskPath" ng-disabled="importBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'">
<label class="control-label" for="importBackupDiskPath">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.diskPath" id="importBackupDiskPath" name="diskPath" ng-disabled="importBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="importBackup.provider === 'ext4' || importBackup.provider === 'xfs'">
</div>
<!-- remotePath contains the prefix as well -->
@@ -296,26 +384,26 @@
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.username" id="configureBackupUsername" name="username" ng-disabled="importBackup.busy">
<label class="control-label" for="importBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.username" id="importBackupUsername" name="username" ng-disabled="importBackup.busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ importBackup.provider }})</label>
<input type="password" class="form-control" ng-model="importBackup.mountOptions.password" id="configureBackupPassword" name="password" ng-disabled="importBackup.busy" password-reveal>
<label class="control-label" for="importBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ importBackup.provider }})</label>
<input type="password" class="form-control" ng-model="importBackup.mountOptions.password" id="importBackupPassword" name="password" ng-disabled="importBackup.busy" password-reveal>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.user" id="configureBackupUser" name="user" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
<label class="control-label" for="importBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.user" id="importBackupUser" name="user" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="importBackup.mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'"></textarea>
<label class="control-label" for="importBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="importBackup.mountOptions.privateKey" id="importBackupPrivateKey" name="privateKey" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': importBackup.error.region }" ng-show="importBackup.provider === 's3'">
@@ -368,6 +456,11 @@
<select class="form-control" name="region" id="inputimportBackupVultrRegion" ng-model="importBackup.endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': importBackup.error.region }" ng-show="importBackup.provider === 'contabo-objectstorage'">
<label class="control-label" for="inputimportBackupContaboRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputimportBackupContaboRegion" ng-model="importBackup.endpoint" ng-options="a.value as a.name for a in contaboRegions" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'contabo-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': importBackup.error.accessKeyId }" ng-show="s3like(importBackup.provider)">
<label class="control-label" for="inputImportBackupAccessKeyId">{{ 'backups.configureBackupStorage.s3AccessKeyId' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.accessKeyId" id="inputImportBackupAccessKeyId" name="accessKeyId" ng-disabled="importBackup.busy" ng-required="s3like(importBackup.provider)">
@@ -533,17 +626,17 @@
<p class="text-small text-warning" ng-show="clone.domain.provider === 'noop' || clone.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((clone.subdomain ? clone.subdomain + '.' : '') + clone.domain.domain) }"></p>
<div class="has-error text-center" ng-show="clone.error.port">{{ clone.error.port }}</div>
<div ng-repeat="(env, info) in clone.portBindingsInfo">
<div ng-repeat="(env, info) in clone.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!clone.itemName{{$index}}.$dirty && clone.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portBindingsEnabled[env]">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="clone.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
<input type="number" class="form-control" ng-model="clone.portBindings[env]" ng-disabled="!clone.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="clone.ports[env]" ng-disabled="!clone.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="clone.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -581,9 +674,9 @@
<i ng-hide="app.installationState === 'pending_start' || app.installationState === 'pending_stop'" class="fas" ng-class="{ 'fa-power-off': !uninstall.startButton, 'fa-play': uninstall.startButton }"></i>
</button>
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-sm btn-default" ng-href="{{ '/logs.html?appId=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.logsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-align-left"></i></a>
<a class="btn btn-sm btn-default" ng-if="app.type !== APP_TYPES.PROXIED" ng-href="{{ '/terminal.html?id=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.terminalActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fa fa-terminal"></i></a>
<a class="btn btn-sm btn-default" ng-if="app.manifest.addons.localstorage" ng-href="{{ '/filemanager.html?type=app&id=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a>
<a class="btn btn-sm btn-default" ng-href="{{ '/frontend/logs.html?appId=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.logsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-align-left"></i></a>
<a class="btn btn-sm btn-default" ng-if="app.type !== APP_TYPES.PROXIED" ng-href="{{ '/frontend/terminal.html?id=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.terminalActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fa fa-terminal"></i></a>
<a class="btn btn-sm btn-default" ng-if="app.manifest.addons.localstorage" ng-href="{{ '/frontend/filemanager.html#/home/app/' + app.id }}" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a>
</div>
<div class="dropdown" style="display: inline-block">
<button class="btn btn-sm btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'app.docsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom">
@@ -627,11 +720,13 @@
<div class="row app-configure-links-container" ng-show="view">
<div class="col-sm-2">
<div class="app-configure-links">
<div ng-click="setView('info')" ng-class="{ 'active': view === 'info' }">{{ 'app.infoTabTitle' | tr }}</div>
<div ng-click="setView('display')" ng-class="{ 'active': view === 'display' }">{{ 'app.displayTabTitle' | tr }}</div>
<div ng-click="setView('location')" ng-class="{ 'active': view === 'location' }" ng-show="app.accessLevel === 'admin'">{{ 'app.locationTabTitle' | tr }}</div>
<div ng-click="setView('proxy')" ng-class="{ 'active': view === 'proxy' }" ng-show="app.type === APP_TYPES.PROXIED">Proxy</div>
<div ng-click="setView('access')" ng-class="{ 'active': view === 'access' }" ng-show="app.accessLevel === 'admin'">{{ 'app.accessControlTabTitle' | tr }}</div>
<div ng-click="setView('resources')" ng-class="{ 'active': view === 'resources' }" ng-show="app.type !== APP_TYPES.PROXIED">{{ 'app.resourcesTabTitle' | tr }}</div>
<div ng-click="setView('services')" ng-class="{ 'active': view === 'services' }" ng-show="app.type !== APP_TYPES.PROXIED && (app.manifest.addons.turn.optional || app.manifest.addons.redis.optional)">{{ 'app.servicesTabTitle' | tr }}</div>
<div ng-click="setView('storage')" ng-class="{ 'active': view === 'storage' }" ng-show="app.accessLevel === 'admin' && app.type !== APP_TYPES.PROXIED">{{ 'app.storageTabTitle' | tr }}</div>
<div ng-click="setView('graphs')" ng-class="{ 'active': view === 'graphs' }" ng-show="app.type !== APP_TYPES.PROXIED">{{ 'app.graphsTabTitle' | tr }}</div>
<div ng-click="setView('security')" ng-class="{ 'active': view === 'security' }">{{ 'app.securityTabTitle' | tr }}</div>
@@ -644,7 +739,101 @@
<div ng-click="setView('uninstall')" ng-class="{ 'active': view === 'uninstall' }" ng-show="app.accessLevel === 'admin'">{{ 'app.uninstallTabTitle' | tr }}</div>
</div>
</div>
<div class="col-sm-8 card-container">
<div class="card" ng-show="view === 'info'">
<p>
<label class="control-label">{{ 'app.updates.info.title' | tr }}</label>
<a href="" class="pull-right" ng-click="info.showDoneChecklist = true" ng-show="info.hasOldChecklist && !info.showDoneChecklist">Show Checklist</a>
<a href="" class="pull-right" ng-click="info.showDoneChecklist = false" ng-show="info.showDoneChecklist">Hide Checklist</a>
</p>
<div ng-repeat="(key, item) in app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
{{ item.message }}
<button class="btn btn-xs btn-default" style="margin-left: 10px;" ng-click="info.checklistAck(item, key)">Done</button>
</div>
</div>
<div ng-repeat="(key, item) in app.checklist" ng-show="info.showDoneChecklist">
<div class="checklist-item checklist-item-acknowledged" ng-show="item.acknowledged">
{{ item.message }}<br/>
<span class="text-muted text-small">{{ item.changedBy }} {{ item.changedAt | prettyDate }}</span>
</div>
</div>
<div style="margin-top: 10px"></div>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.description' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId">{{ app.manifest.title }} {{ app.upstreamVersion }}</span>
<span ng-show="!app.appStoreId">{{ app.manifest.dockerImage }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.installedAt' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.creationTime | prettyDate }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
</div>
</div>
<br/>
<p><label class="control-label">{{ 'app.info.notes.title' | tr }}</label><i ng-show="!info.notes.editing" class="info-edit-indicator fa fa-pencil-alt" ng-click="info.notes.edit()"></i></p>
<div class="row">
<div class="col-md-12" ng-show="!info.notes.busy">
<div ng-show="!info.notes.editing">
<div ng-show="info.notes.content" ng-bind-html="info.notes.content | markdown2html"></div>
<div ng-show="!info.notes.content" class="text-muted hand" ng-click="info.notes.edit()">{{ info.notes.placeholder }}</div>
</div>
<div ng-show="info.notes.editing" class="text-right">
<textarea id="adminNotesTextarea" ng-trim="false" style="white-space: pre-wrap; margin-bottom: 5px" ng-model="info.notes.content" class="form-control" rows="10"></textarea>
<button class="btn btn-default" ng-click="info.notes.dismiss()" ng-disabled="info.notes.busySave">{{ 'main.dialog.cancel' | tr }}</button>
<button class="btn btn-success" ng-click="info.notes.submit()" ng-disabled="info.notes.busySave"><i class="fa fa-circle-notch fa-spin" ng-show="info.notes.busySave"></i> {{ 'app.display.saveAction' | tr }}</button>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
</div>
</div>
</div>
<div class="card" ng-show="view === 'display'">
<div class="row">
<div class="col-md-12">
@@ -665,7 +854,7 @@
</div>
<div id="previewIcon" class="app-custom-icon" ng-click="display.showCustomIconSelector()">
<img ng-src="{{ display.iconUrl() || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)"/>
<div class="overlay"></div>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
<a href="" style="font-weight: normal;" ng-click="display.resetCustomIcon()">{{ 'app.display.iconResetAction' | tr }}</a>
<input type="file" id="iconFileInput" style="display: none" accept="image/png"/>
@@ -678,8 +867,7 @@
</div>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="display.submit()" ng-disabled="(!display.icon.data && !displayForm.$dirty) || display.$invalid || display.busy"><i class="fa fa-circle-notch fa-spin" ng-show="display.busy"></i> {{ 'app.display.saveAction' | tr }}</button>
</div>
<button class="btn btn-outline btn-primary pull-right" ng-click="display.submit()" ng-disabled="(!display.icon.data && !displayForm.$dirty) || display.$invalid || display.busy"><i class="fa fa-circle-notch fa-spin" ng-show="display.busy"></i> {{ 'app.display.saveAction' | tr }}</button> </div>
</div>
</div>
@@ -744,17 +932,18 @@
</div>
<div class="has-error text-center" ng-show="location.error.port">{{ location.error.port }}</div>
<div ng-repeat="(env, info) in location.portBindingsInfo">
<div ng-repeat="(env, info) in location.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!portInfo_form.itemName{{$index}}.$dirty && location.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portBindingsEnabled[env]">
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
<span ng-show="info.portCount" style="display: block; float: right">{{ location.ports[env] }} to {{ location.ports[env] + info.portCount - 1 }} ({{ info.portCount }} ports)</span>
</label>
<input type="number" class="form-control" ng-model="location.portBindings[env]" ng-disabled="!location.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<input type="number" class="form-control" ng-model="location.ports[env]" ng-disabled="!location.portsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -919,12 +1108,12 @@
<form role="form" name="resourcesForm" ng-submit="resources.submitMemoryLimit()" autocomplete="off">
<div class="form-group">
<label class="control-label" for="memoryLimit">{{ 'app.resources.memory.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#memory-limit" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ resources.memoryLimit | prettyBinarySize:'Default (256 MiB)' }}</b></label>
<div style="padding: 0 10px;">
<slider id="memoryLimit" ng-model="resources.memoryLimit" step="134217728" tooltip="hide" ticks="resources.memoryTicks" ticks-snap-bounds="67108864"></slider>
</div>
<p>{{ 'app.resources.memory.description' | tr }}</p>
<input type="range" id="memoryLimit" ng-model="resources.memoryLimit" step="134217728" min="{{ resources.memoryTicks[0] }}" max="{{ resources.memoryTicks[resources.memoryTicks.length-1] }}" list="memoryLimitTicks" />
<datalist id="memoryLimitTicks">
<option ng-repeat="limit in resources.memoryTicks" value="{{ limit }}"></option>
</datalist>
</div>
<input class="ng-hide" type="submit" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy"/>
</form>
</div>
</div>
@@ -933,36 +1122,88 @@
<span ng-show="resources.error.memoryLimit" class="text-danger">{{ 'app.resources.memory.error' | tr }}</span>
</div>
<div class="col-md-4 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitMemoryLimit()" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="resources.busy"></i> {{ 'app.resources.memory.resizeAction' | tr }}
</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitMemoryLimit()" ng-disabled="resources.memoryLimit === resources.currentMemoryLimit || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">{{ 'app.resources.memory.resizeAction' | tr }}</button>
</div>
</div>
<hr/>
<div class="row">
<div class="col-md-12">
<form role="form" name="resourcesForm" ng-submit="resources.submitCpuShares()" autocomplete="off">
<form role="form" name="resourcesForm" ng-submit="resources.submitCpuQuota()" autocomplete="off">
<fieldset>
<div class="form-group">
<label class="control-label" for="cpuShares">{{ 'app.resources.cpu.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#cpu-shares" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ (resources.cpuShares * 100 / 1024 | number:0) + ' %' }}</b></label>
<label class="control-label" for="cpuQuota">{{ 'app.resources.cpu.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#cpu-quota" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ resources.cpuQuota + ' %' }}</b></label>
<p>{{ 'app.resources.cpu.description' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="cpuShares" ng-model="resources.cpuShares" ticks="[32, 256, 512, 768, 1024]" step="32" ticks-snap-bounds="32" min="32" max="1024" tooltip="hide"></slider>
</div>
<input type="range" id="cpuQuota" ng-model="resources.cpuQuota" step="1" min="1" max="100"/>
<datalist id="cpuQuotaTicks">
<option value="25"></option>
<option value="50"></option>
<option value="75"></option>
</datalist>
</div>
<input class="ng-hide" type="submit" ng-disabled="resources.cpuShares === resources.currentCpuShares || resourcesForm.$invalid || resources.busyCpuShares"/>
</fieldset>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitCpuShares()" ng-disabled="resources.cpuShares === resources.currentCpuShares || resourcesForm.$invalid || resources.busyCpuShares || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="resources.busyCpuShares"></i> {{ 'app.resources.cpu.setAction' | tr }}
<button class="btn btn-outline btn-primary pull-right" ng-click="resources.submitCpuQuota()" ng-disabled="resources.cpuQuota === resources.currentCpuQuota || resourcesForm.$invalid || resources.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">{{ 'app.resources.cpu.setAction' | tr }}</button>
</div>
</div>
</div>
<div class="card" ng-show="view === 'services'">
<div class="row" ng-show="app.manifest.addons.turn.optional">
<div class="col-md-12">
<label class="control-label">{{ 'app.turn.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#turn" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="radio">
<label>
<input type="radio" ng-model="services.enableTurn" value="1"> {{ 'app.turn.enable' | tr }}
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="services.enableTurn" value="0"> {{ 'app.turn.disable' | tr }}
</label>
</div>
</div>
<div class="col-md-12 text-right">
<br/>
<button class="btn btn-outline btn-primary pull-right" ng-click="services.submitTurn()" ng-disabled="app.enableTurn === services.enableTurn || services.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="services.busy"></i> {{ 'main.saveAction' | tr }}
</button>
</div>
</div>
<hr ng-show="app.manifest.addons.turn.optional && app.manifest.addons.redis.optional">
<div class="row" ng-show="app.manifest.addons.redis.optional">
<div class="col-md-12">
<label class="control-label">{{ 'app.redis.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#redis" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="radio">
<label>
<input type="radio" ng-model="services.enableRedis" value="1"> {{ 'app.redis.enable' | tr }}
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="services.enableRedis" value="0"> {{ 'app.redis.disable' | tr }}
</label>
</div>
</div>
<div class="col-md-12 text-right">
<br/>
<button class="btn btn-outline btn-primary pull-right" ng-click="services.submitRedis()" ng-disabled="app.enablRedis === services.enableRedis || services.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="services.busy"></i> {{ 'main.saveAction' | tr }}
</button>
</div>
</div>
</div>
<div class="card" ng-show="view === 'storage'">
@@ -973,6 +1214,7 @@
<p ng-bind-html="'app.storage.appdata.description' | tr:{ storagePath: ('/home/yellowtent/appsdata/' + app.id) }"></p>
<form role="form" name="storageDataDirForm" ng-submit="storage.submitDataDir()" autocomplete="off">
<select class="form-control" ng-model="storage.location" ng-options="location.displayName for location in storage.locationOptions track by location.id"></select>
<p class="text-warning" ng-show="storage.location.type === 'volume' && storage.location.mountType === 'mountpoint'" ng-bind-html="'app.storage.appdata.mountTypeWarning' | tr"></p>
<br/>
@@ -985,6 +1227,7 @@
<input class="ng-hide" type="submit" ng-disabled="!storageDataDirForm.$dirty || storageDataDirForm.$invalid || storage.busyDataDir || app.error || app.taskId"/>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
@@ -1021,6 +1264,7 @@
</select>
</td>
<td class="text-right no-wrap" style="vertical-align: middle">
<a class="btn btn-xs btn-default" ng-href="{{ '/frontend/filemanager.html#/home/volume/' + mount.volume.id }}" target="_blank" uib-tooltip="{{ 'volumes.openFileManagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
<button class="btn btn-danger btn-xs" ng-click="storage.delMount($event, $index)"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
@@ -1222,7 +1466,8 @@
<thead>
<tr>
<th class="col-md-2">{{ 'eventlog.time' | tr }}</th> <!-- "minutes ago" takes space -->
<th class="col-md-8">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-6">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2" style="text-align: right;">
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showPrevPage()" ng-disabled="eventlog.busy || eventlog.currentPage <= 1"><i class="fa fa-angle-double-left"></i></button>
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showNextPage()" ng-disabled="eventlog.busy || eventlog.perPage > eventlog.eventLogs.length"><i class="fa fa-angle-double-right"></i></button>
@@ -1232,10 +1477,14 @@
<tbody ng-repeat="eventLog in eventlog.eventLogs">
<tr ng-click="eventlog.showDetails(eventLog)" class="hand">
<td><span uib-tooltip="{{ eventLog.raw.creationTime | prettyLongDate }}" class="arrow">{{ eventLog.raw.creationTime | prettyDate }}</span></td>
<td>{{ eventLog.source }}</td>
<td style="word-wrap: anywhere;" colspan="2" ng-bind-html="eventLog.details"></td>
</tr>
<tr ng-show="eventlog.activeEventLog === eventLog">
<td colspan="3"><pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre></td>
<td colspan="4">
<p ng-show="eventLog.raw.source.ip">Source IP: <code>{{ eventLog.raw.source.ip }}</code></p>
<pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre>
</td>
</tr>
</tbody>
</table>
@@ -1306,76 +1555,28 @@
</div>
<div class="card" ng-show="view === 'updates'">
<p><label class="control-label">{{ 'app.updates.info.title' | tr }}</label></p>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">{{ 'app.updates.info.description' | tr }}</span>
</div>
<div class="col-xs-8 text-right">
<span ng-show="app.appStoreId">{{ app.manifest.title }} {{ app.upstreamVersion }}</span>
<span ng-show="!app.appStoreId">{{ app.manifest.dockerImage }}</span>
</div>
</div>
<p><label class="control-label">{{ 'app.updatesTabTitle' | tr }}</label></p>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.appId' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.id }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.packageVersion' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="app.appStoreId"><a ng-href="/#/appstore/{{app.manifest.id}}?version={{app.manifest.version}}">{{ app.manifest.id }}@{{ app.manifest.version }}</a></span>
<span ng-show="!app.appStoreId">{{ app.manifest.version }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.updates.info.lastUpdated' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ app.updateTime | prettyDate }}</span>
<div class="col-md-12">
<p>
<span ng-bind-html="'app.updates.auto.description' | tr:{ appStoreLink: 'https://www.cloudron.io/store/index.html' }"></span>
<span ng-show="app.appStoreId && updates.enableAutomaticUpdate" class="text-success">{{ 'app.updates.auto.enabled' | tr }}</span>
<span ng-show="app.appStoreId && !updates.enableAutomaticUpdate" class="text-danger">{{ 'app.updates.auto.disabled' | tr }}</span>
<span ng-show="!app.appStoreId" class="text-danger">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</p>
</div>
</div>
<br/>
<div class="row" ng-show="app.appStoreId">
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="!updates.busyCheck && !(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version)">{{ 'app.updates.noUpdates' | tr }}</span>
</div>
<div class="col-md-6 text-right">
<button type="button" class="btn" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" ng-hide="!(config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update') || app.taskId" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="updates.check()" ng-disabled="updates.busyCheck"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
</div>
</div>
<hr/>
<div class="row" ng-show="!app.appStoreId">
<div class="row">
<div class="col-md-12">
<span class="text-danger pull-right">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</div>
</div>
<div ng-show="app.appStoreId" class="row">
<div class="col-md-12">
<label class="control-label">{{ 'app.updates.auto.title' | tr }}</label>
<p>{{ 'app.updates.auto.description' | tr }}</p>
</div>
<div class="col-md-6" style="line-height: 34px;">
<span class="text-success" ng-show="app.enableAutomaticUpdate">{{ 'app.updates.auto.enabled' | tr }}</span>
<span class="text-danger" ng-hide="app.enableAutomaticUpdate">{{ 'app.updates.auto.disabled' | tr }}</span>
</div>
<div class="col-md-6">
<button class="btn btn-primary pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="{ 'btn-danger': app.enableAutomaticUpdate }" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ app.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
<button class="btn pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-class="updates.enableAutomaticUpdate ? 'btn-danger' : 'btn-success'" ng-click="updates.toggleAutomaticUpdates()" ng-disabled="updates.busyAutomaticUpdates || !app.appStoreId"><i class="fa fa-circle-notch fa-spin" ng-show="updates.busyAutomaticUpdates"></i> {{ updates.enableAutomaticUpdate ? ('app.updates.auto.disableAction' | tr) : ('app.updates.auto.enableAction' | tr) }} </button>
<!-- check for updates button is always visible -->
<button class="btn btn-default btn-outline pull-right" uib-tooltip="{{ app.appStoreId ? '' : 'Not available for custom apps' }}" ng-click="updates.check()" ng-disabled="updates.busyCheck || !app.appStoreId"><i class="fas fa-sync-alt fa-spin" ng-show="updates.busyCheck"></i><i class="fas fa-sync-alt" ng-hide="updates.busyCheck"></i> {{ 'settings.updates.checkForUpdatesAction' | tr }}</button>
<!-- show update button only if update available -->
<button class="btn pull-right" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update' && !app.taskId" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updateDialog.updateAction' | tr }}</button>
</div>
</div>
</div>
@@ -1401,8 +1602,8 @@
<tr ng-repeat="backup in backups.backups">
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<!-- <td><div class="hand clipboard" data-clipboard-text="{{ backup.id }}" uib-tooltip="{{ copyBackupIdDone ? ('main.clipboard.copied' | tr) : ('main.clipboard.clickToCopyBackupId' | tr) }}" tooltip-placement="right"><i class="fa fa-copy"></i></div></td> -->
<td><div>v{{ backup.packageVersion }}</div></td>
<td><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td ng-click="backupDetails.show(backup)" class="hand"><div>v{{ backup.packageVersion }}</div></td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td class="text-center" style="vertical-align: bottom">
<div class="dropdown">
<button class="btn btn-xs btn-default dropdown-toggle" type="button" id="dropdownMenu1" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
+264 -75
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular */
/* global angular, localStorage, document, FileReader */
/* global $ */
/* global async */
/* global RSTATES */
@@ -10,7 +10,8 @@
/* global Clipboard */
/* global SECRET_PLACEHOLDER */
/* global APP_TYPES, STORAGE_PROVIDERS, BACKUP_FORMATS */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR */
/* global onAppClick */
angular.module('Application').controller('AppController', ['$scope', '$location', '$translate', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $interval, $route, $routeParams, Client) {
$scope.s3Regions = REGIONS_S3;
@@ -23,6 +24,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.ionosRegions = REGIONS_IONOS;
$scope.upcloudRegions = REGIONS_UPCLOUD;
$scope.vultrRegions = REGIONS_VULTR;
$scope.contaboRegions = REGIONS_VULTR;
$scope.storageProviders = STORAGE_PROVIDERS;
@@ -75,6 +77,31 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
});
};
$scope.appPostInstallConfirm = {
app: {},
message: '',
confirmed: false,
show: function (app) {
$scope.appPostInstallConfirm.app = app;
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
$scope.appPostInstallConfirm.confirmed = false;
$('#appPostInstallConfirmModal').modal('show');
return false; // prevent propagation and default
},
submit: function () {
if (!$scope.appPostInstallConfirm.confirmed) return;
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
$('#appPostInstallConfirmModal').modal('hide');
}
};
$scope.postInstallMessage = {
confirmed: false,
openApp: false,
@@ -109,6 +136,73 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
};
$scope.info = {
showDoneChecklist: false,
hasOldChecklist: false,
notes: {
busy: true,
busySave: false,
editing: false,
content: '',
placeholder: 'Add admin notes here...',
edit: function () {
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = true;
setTimeout(function () { document.getElementById('adminNotesTextarea').focus(); }, 1);
},
dismiss: function () {
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
},
submit: function () {
$scope.info.notes.busySave = true;
// skip saving if unchanged from postInstall
if ($scope.info.notes.content === $scope.app.manifest.postInstallMessage) {
$scope.info.notes.busySave = false;
$scope.info.notes.editing = false;
return;
}
Client.configureApp($scope.app.id, 'notes', { notes: $scope.info.notes.content }, function (error) {
if (error) return console.error('Failed to save notes.', error);
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.busySave = false;
$scope.info.notes.editing = false;
});
});
}
},
show: function () {
$scope.info.hasOldChecklist = !!Object.keys($scope.app.checklist).find((k) => { return $scope.app.checklist[k].acknowledged; });
$scope.info.notes.content = $scope.app.notes === null ? $scope.app.manifest.postInstallMessage : $scope.app.notes;
$scope.info.notes.editing = false;
$scope.info.notes.busy = false;
},
checklistAck(item, key) {
item.acknowledged = true;
// item.acknowledged = !item.acknowledged;
Client.ackAppChecklistItem($scope.app.id, key, item.acknowledged, function (error) {
if (error) return console.error('Failed to ack checklist item.', error);
$scope.info.hasOldChecklist = true;
refreshApp($scope.app.id);
});
}
};
$scope.display = {
busy: false,
error: {},
@@ -211,9 +305,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
redirectDomains: [],
aliasDomains: [],
portBindings: {},
portBindingsEnabled: {},
portBindingsInfo: {},
ports: {},
portsEnabled: {},
portInfo: {},
addRedirectDomain: function (event) {
event.preventDefault();
@@ -275,18 +369,18 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
});
$scope.location.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.portInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.redirectDomains = app.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
$scope.location.aliasDomains = app.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
// fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port
for (var env in $scope.location.portBindingsInfo) {
for (var env in $scope.location.portInfo) {
if (app.portBindings && app.portBindings[env]) {
$scope.location.portBindings[env] = app.portBindings[env];
$scope.location.portBindingsEnabled[env] = true;
$scope.location.ports[env] = app.portBindings[env].hostPort;
$scope.location.portsEnabled[env] = true;
} else {
$scope.location.portBindings[env] = $scope.location.portBindingsInfo[env].defaultValue || 0;
$scope.location.portBindingsEnabled[env] = false;
$scope.location.ports[env] = $scope.location.portInfo[env].defaultValue || 0;
$scope.location.portsEnabled[env] = false;
}
}
},
@@ -306,11 +400,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports from portBindings
var portBindings = {};
for (var env in $scope.location.portBindings) {
if ($scope.location.portBindingsEnabled[env]) {
portBindings[env] = $scope.location.portBindings[env];
// only use enabled ports
var ports = {};
for (var env in $scope.location.ports) {
if ($scope.location.portsEnabled[env]) {
ports[env] = $scope.location.ports[env];
}
}
@@ -318,7 +412,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
overwriteDns: !!overwriteDns,
subdomain: $scope.location.subdomain,
domain: $scope.location.domain.domain,
portBindings: portBindings,
ports: ports,
secondaryDomains: secondaryDomains,
redirectDomains: $scope.location.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}),
aliasDomains: $scope.location.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };})
@@ -513,43 +607,49 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
busy: false,
currentMemoryLimit: 0,
memoryLimit: 0,
memoryLimit: 0, // RAM
memoryTicks: [],
busyCpuShares: false,
currentCpuShares: 0,
cpuShares: 0,
currentCpuQuota: 0,
cpuQuota: 0,
show: function () {
var app = $scope.app;
$scope.resources.busy = true;
$scope.resources.error = {};
$scope.resources.currentMemoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024);
$scope.resources.memoryLimit = $scope.resources.currentMemoryLimit;
$scope.resources.currentCpuShares = $scope.resources.cpuShares = app.cpuShares;
Client.getAppLimits(app.id, function (error, limits) {
if (error) return console.error(error);
Client.memory(function (error, result) {
if (error) console.error(error);
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
// create ticks starting from manifest memory limit. the memory limit here is just RAM
$scope.resources.memoryTicks = [];
var npow2 = Math.pow(2, Math.ceil(Math.log(limits.memory.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
if (i >= (app.manifest.memoryLimit/1024/1024 || 0)) $scope.resources.memoryTicks.push(i * 1024 * 1024);
}
if (app.manifest.memoryLimit && $scope.resources.memoryTicks[0] !== app.manifest.memoryLimit) {
$scope.resources.memoryTicks.unshift(app.manifest.memoryLimit);
// we max system memory and current app memory for the case where the user configured the app on another server with more resources
var nearest256m = Math.ceil(Math.max(result.memory, $scope.resources.currentMemoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = app.manifest.memoryLimit || (256 * 1024 * 1024);
for (var i = startTick; i <= nearest256m; i *= 2) {
$scope.resources.memoryTicks.push(i);
}
});
// for firefox widget update
$timeout(function() {
$scope.resources.currentCpuQuota = $scope.resources.cpuQuota = app.cpuQuota;
$scope.resources.memoryLimit = $scope.resources.currentMemoryLimit;
$scope.resources.busy = false;
}, 500);
},
submitMemoryLimit: function () {
$scope.resources.busy = true;
$scope.resources.error = {};
var memoryLimit = $scope.resources.memoryLimit === $scope.resources.memoryTicks[0] ? 0 : $scope.resources.memoryLimit;
Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit: memoryLimit }, function (error) {
const tmp = parseInt($scope.resources.memoryLimit);
const memoryLimit = tmp === $scope.resources.memoryTicks[0] ? 0 : tmp;
Client.configureApp($scope.app.id, 'memory_limit', { memoryLimit }, function (error) {
if (error && error.statusCode === 400) {
$scope.resources.busy = false;
$scope.resources.error.memoryLimit = true;
@@ -567,24 +667,72 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
});
},
submitCpuShares: function () {
$scope.resources.busyCpuShares = true;
submitCpuQuota: function () {
$scope.resources.busy = true;
$scope.resources.error = {};
Client.configureApp($scope.app.id, 'cpu_shares', { cpuShares: $scope.resources.cpuShares }, function (error) {
Client.configureApp($scope.app.id, 'cpu_quota', { cpuQuota: parseInt($scope.resources.cpuQuota) }, function (error) {
if (error) return Client.error(error);
$scope.resources.currentCpuShares = $scope.resources.cpuShares;
$scope.resources.currentCpuQuota = $scope.resources.cpuQuota;
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
$timeout(function () { $scope.resources.busyCpuShares = false; }, 1000);
$timeout(function () { $scope.resources.busy = false; }, 1000);
});
});
},
};
$scope.services = {
error: {},
busy: false,
enableTurn: '1', // curse of radio buttons
enableRedis: '1',
show: function () {
var app = $scope.app;
$scope.services.error = {};
$scope.services.enableTurn = app.enableTurn ? '1' : '0';
$scope.services.enableRedis = app.enableRedis ? '1' : '0';
},
submitTurn: function () {
$scope.services.busy = true;
$scope.services.error = {};
Client.configureApp($scope.app.id, 'turn', { enable: $scope.services.enableTurn === '1' }, function (error) {
if (error && error.statusCode === 400) {
$scope.services.busy = false;
$scope.services.error.turn = true;
return;
}
if (error) return Client.error(error);
$timeout(function () { $scope.services.busy = false; }, 1000);
});
},
submitRedis: function () {
$scope.services.busy = true;
$scope.services.error = {};
Client.configureApp($scope.app.id, 'redis', { enable: $scope.services.enableRedis === '1' }, function (error) {
if (error && error.statusCode === 400) {
$scope.services.busy = false;
$scope.services.error.redis = true;
return;
}
if (error) return Client.error(error);
$timeout(function () { $scope.services.busy = false; }, 1000);
});
},
};
$scope.storage = {
error: {},
@@ -613,7 +761,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
];
$scope.volumes.forEach(function (volume) {
$scope.storage.locationOptions.push({ id: volume.id, type: 'volume', value: volume.id, displayName: 'Volume - ' + volume.name });
$scope.storage.locationOptions.push({ id: volume.id, type: 'volume', value: volume.id, displayName: 'Volume - ' + volume.name, mountType: volume.mountType });
});
$scope.storage.location = $scope.storage.locationOptions.find(function (l) { return l.id === (app.storageVolumeId || 'default'); });
@@ -639,8 +787,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.storage.error.storageVolumePrefix = error.message;
$scope.storage.busyDataDir = false;
return;
} else if (error) {
Client.error(error);
$scope.storage.busyDataDir = false;
return;
}
if (error) return Client.error(error);
$scope.storageDataDirForm.$setPristine();
@@ -1136,21 +1287,27 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
busyUpdate: false,
busyAutomaticUpdates: false,
skipBackup: false,
enableAutomaticUpdate: true,
show: function () {
$scope.updates.skipBackup = false;
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
},
toggleAutomaticUpdates: function () {
$scope.updates.busyAutomaticUpdates = true;
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.app.enableAutomaticUpdate }, function (error) {
Client.configureApp($scope.app.id, 'automatic_update', { enable: !$scope.updates.enableAutomaticUpdate }, function (error) {
if (error) return Client.error(error);
refreshApp($scope.app.id, function (error) {
if (error) console.error(error);
$scope.updates.busyAutomaticUpdates = false;
$timeout(function () {
console.log($scope.updates.enableAutomaticUpdate, $scope.app.enableAutomaticUpdate);
$scope.updates.enableAutomaticUpdate = $scope.app.enableAutomaticUpdate;
$scope.updates.busyAutomaticUpdates = false;
}, 2000);
});
});
},
@@ -1183,6 +1340,15 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
};
$scope.backupDetails = {
backup: null,
show: function (backup) {
$scope.backupDetails.backup = backup;
$('#backupDetailsModal').modal('show');
}
};
$scope.backups = {
busy: false,
busyCreate: false,
@@ -1259,7 +1425,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
|| provider === 'exoscale-sos' || provider === 'digitalocean-spaces'
|| provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'cloudflare-r2'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage'
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2';
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2'
|| provider === 'contabo-objectstorage';
};
$scope.mountlike = function (provider) {
@@ -1274,7 +1441,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
provider: '',
bucket: '',
prefix: '',
mountPoint: '',
mountPoint: '', // for mountpoint
accessKeyId: '',
secretAccessKey: '',
gcsKey: { keyFileName: '', content: '' },
@@ -1285,7 +1452,17 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
remotePath: '',
password: '',
encryptedFilenames: true,
mountOptions: {}, // host, port, username, password, remoteDir, diskPath, user, privateKey
mountOptions: {
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
user: '',
seal: true,
port: 22,
privateKey: ''
},
encrypted: false, // helps with ng-required when backupConfig is read from file
clearForm: function () {
@@ -1303,7 +1480,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.importBackup.password = '';
$scope.importBackup.encryptedFilenames = true;
$scope.importBackup.remotePath = '';
$scope.importBackup.mountOptions = {};
$scope.importBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' };
},
submit: function () {
@@ -1323,7 +1500,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
// only set provider specific fields, this will clear them in the db
if ($scope.s3like(backupConfig.provider)) {
backupConfig.bucket = $scope.importBackup.bucket;
backupConfig.prefix = '';
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.accessKeyId = $scope.importBackup.accessKeyId;
backupConfig.secretAccessKey = $scope.importBackup.secretAccessKey;
@@ -1357,6 +1534,10 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
} else if (backupConfig.provider === 'vultr-objectstorage') {
backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'contabo-objectstorage') {
backupConfig.region = $scope.contaboRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
} else if (backupConfig.provider === 'upcloud-objectstorage') {
var m = /^.*\.(.*)\.upcloudobjects.com$/.exec(backupConfig.endpoint);
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
@@ -1366,7 +1547,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.importBackup.bucket;
backupConfig.prefix = '';
backupConfig.prefix = $scope.importBackup.prefix;
try {
var serviceAccountKey = JSON.parse($scope.importBackup.gcsKey.content);
backupConfig.projectId = serviceAccountKey.project_id;
@@ -1386,10 +1567,10 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
} else if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') {
backupConfig.mountOptions = $scope.importBackup.mountOptions;
backupConfig.prefix = '';
backupConfig.prefix = $scope.importBackup.prefix;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountOptions = {};
backupConfig.mountPoint = $scope.mountPoint;
backupConfig.prefix = $scope.importBackup.prefix;
backupConfig.mountPoint = $scope.importBackup.mountPoint;
} else if (backupConfig.provider === 'filesystem') {
var parts = remotePath.split('/');
remotePath = parts.pop() || parts.pop(); // removes any trailing slash. this is basename()
@@ -1453,6 +1634,10 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$('#importBackupModal').modal('hide');
// clear potential post-install flag
$scope.app.pendingPostInstallConfirmation = false;
delete localStorage['confirmPostInstall_' + $scope.app.id];
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
@@ -1609,9 +1794,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
secondaryDomains: {},
needsOverwrite: false,
overwriteDns: false,
portBindings: {},
portBindingsInfo: {},
portBindingsEnabled: {},
ports: {},
portsEnabled: {},
portInfo: {},
show: function (backup) {
var app = $scope.app;
@@ -1633,11 +1818,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
$scope.clone.portBindingsInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
$scope.clone.portInfo = angular.extend({}, backup.manifest.tcpPorts, backup.manifest.udpPorts); // Portbinding map only for information
// set default ports
for (var env in $scope.clone.portBindingsInfo) {
$scope.clone.portBindings[env] = $scope.clone.portBindingsInfo[env].defaultValue || 0;
$scope.clone.portBindingsEnabled[env] = true;
for (var env in $scope.clone.portInfo) {
$scope.clone.ports[env] = $scope.clone.portInfo[env].defaultValue || 0;
$scope.clone.portsEnabled[env] = true;
}
$('#appCloneModal').modal('show');
@@ -1654,11 +1839,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.clone.portBindings) {
if ($scope.clone.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.clone.portBindings[env];
// only use enabled ports
var finalPorts = {};
for (var env in $scope.clone.ports) {
if ($scope.clone.portsEnabled[env]) {
finalPorts[env] = $scope.clone.ports[env];
}
}
@@ -1666,7 +1851,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
subdomain: $scope.clone.subdomain,
domain: $scope.clone.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
ports: finalPorts,
backupId: $scope.clone.backup.id,
overwriteDns: $scope.clone.overwriteDns
};
@@ -2040,7 +2225,15 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
Object.keys($scope.backupConfig).forEach(function (k) {
if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = $scope.backupConfig[k];
var v = $scope.backupConfig[k];
if (v && typeof v === 'object') { // to hide mountOptions.password and the likes
tmp[k] = {};
Object.keys(v).forEach(function (j) {
if (v[j] !== SECRET_PLACEHOLDER) tmp[k][j] = v[j];
});
} else {
if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = v;
}
});
var filename = 'app-backup-config-' + (new Date()).toISOString().replace(/:|T/g,'-').replace(/\..*/,'') + ' (' + $scope.app.fqdn + ')' + '.json';
@@ -2055,16 +2248,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var backupConfig;
try {
backupConfig = JSON.parse(result.target.result);
let prefix = backupConfig.prefix;
backupConfig.prefix = ''; // so it can clear the form as well when we apply keys below
if (backupConfig.provider === 'filesystem') { // patch the remotePath to have the full path
backupConfig.remotePath = (prefix ? prefix + '/' : '') + backupConfig.backupFolder + '/' + backupConfig.remotePath;
if (backupConfig.provider === 'filesystem') { // this allows a user to upload a backup to server and import easily with an absolute path
backupConfig.remotePath = backupConfig.backupFolder + '/' + backupConfig.remotePath;
delete backupConfig.backupFolder;
} else {
backupConfig.remotePath = (prefix ? prefix + '/' : '') + backupConfig.remotePath;
}
} catch (e) {
console.error('Unable to parse backup config');
console.error('Unable to parse backup config', e);
return;
}
@@ -2090,7 +2279,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
if ($routeParams.view) { // explicit route in url bar
$scope.setView($routeParams.view, true /* skipViewShow */);
} else { // default
$scope.setView($scope.app.error ? 'repair' : 'display', true /* skipViewShow */);
$scope.setView($scope.app.error ? 'repair' : 'info', true /* skipViewShow */);
}
function done() {
+109 -36
View File
@@ -1,31 +1,33 @@
<!-- Modal postinstall confirm -->
<div class="modal fade" id="appPostInstallConfirmModal" tabindex="-1" role="dialog">
<div class="modal fade" id="appsPostInstallConfirmModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appPostInstallConfirm.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-info-icon"/>
<h5 class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}
<span class="app-info-meta text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<div class="app-info-title">
{{ appPostInstallConfirm.app.manifest.title }}<br/>
<span class="text-muted text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<br/>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl" class="text-small"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
<br/>
</h5>
</div>
</div>
<div class="modal-body">
<!--
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
<div ng-bind-html="appPostInstallConfirm.app.manifest.postInstallMessage | markdown2html"></div>
-->
<div ng-bind-html="appPostInstallConfirm.message | markdown2html"></div>
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
<div ng-repeat="item in appPostInstallConfirm.app.checklist">
<div class="checklist-item" ng-hide="item.acknowledged">
{{ item.message }}
</div>
</div>
</div>
<div class="modal-footer">
<div class="form-group pull-left">
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
<label class="control-label" for="appPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
<a class="btn btn-success" ng-href="{{ 'https://' + appPostInstallConfirm.app.fqdn }}" target="_blank" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
</div>
</div>
</div>
@@ -57,7 +59,7 @@
</div>
<div id="previewIcon" class="app-custom-icon" ng-click="applinksEdit.showCustomIconSelector()">
<img ng-src="{{ applinksEdit.iconUrl() || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)"/>
<div class="overlay"></div>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
<a href="" style="font-weight: normal;" ng-click="applinksEdit.resetCustomIcon()">{{ 'app.applinks.clearIconAction' | tr }}</a> - <span class="text-small">{{ 'app.applinks.clearIconDescription' | tr }}</span>
<input type="file" id="applinksEditIconFileInput" style="display: none" accept="image/png"/>
@@ -130,34 +132,36 @@
</div>
</div>
<h1 class="view-header" ng-show="installedApps.length > 0">
<h1 class="view-header" ng-show="installedApps.length > 0" style="padding-right: 0;">
{{ 'apps.title' | tr }}
<div class="view-header-search-bar">
<form class="form-inline">
<div class="input-group">
<input type="text" class="form-control" style="width: 300px" placeholder="{{ 'apps.searchPlaceholder' | tr }}" id="appSearch" ng-model="appSearch"/>
<input type="text" class="form-control" style="width: 300px" placeholder="{{ 'apps.searchPlaceholder' | tr }} ( / )" id="appSearch" ng-model="appSearch"/>
<span class="input-group-btn">
<button class="btn btn-default" type="button" ng-class="{ 'active': showFilter, 'btn-warning': showFilter || selectedTags.length || selectedState.state || !selectedGroup._unset || !selectedDomain._alldomains }" ng-click="showFilter = !showFilter"><i class="fas fa-filter"></i></button>
<button class="btn btn-default" type="button" ng-class="{ 'active': showFilter }" ng-click="toggleFilter()"><i class="fas fa-filter"></i></button>
</span>
</div>
</form>
</div>
<div ng-show="showFilter" class="view-header-filter-bar">
<form class="form-inline">
<multiselect ng-model="selectedGroup" ng-show="user.isAtLeastAdmin && groups.length > 1" ms-header="{{ selectedGroup.name }}" options="group.name for group in groups" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedState" ng-show="user.isAtLeastAdmin" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ selectedState }}" options="state.label for state in states" data-multiple="false"></multiselect>
<multiselect ng-model="selectedTags" ng-show="user.isAtLeastAdmin && tags.length > 0" ms-header="{{ 'apps.tagsFilterHeaderAll' | tr }}" ms-selected="{{ 'apps.tagsFilterHeader' | tr:{ tags: selectedTags.join(', ') } }}" options="tag for tag in tags" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedDomain" data-compare-by="domain" ms-selected="{{ selectedDomain.domain }}" options="domain.domain for domain in filterDomains" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<button class="btn btn-warning" ng-disabled="!selectedTags.length && !selectedState.state && selectedGroup._unset && selectedDomain._alldomains" ng-click="clearAllFilter()">{{ 'apps.filter.clearAll' | tr }}</button>
<button class="btn btn-default" type="button" ng-click="toggleView()"><i class="fas" ng-class="{ 'fa-list': view === VIEWS.GRID, 'fa-grip': view === VIEWS.LIST }"></i></button>
</form>
</div>
</h1>
<div ng-show="showFilter" class="view-header-filter-bar">
<form class="form-inline">
<multiselect ng-model="selectedGroup" ng-show="user.isAtLeastAdmin && groups.length > 1" ms-header="{{ selectedGroup.name }}" options="group.name for group in groups" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedState" ng-show="user.isAtLeastAdmin" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ selectedState }}" options="state.label for state in states" data-multiple="false"></multiselect>
<multiselect ng-model="selectedTags" ng-show="user.isAtLeastAdmin && tags.length > 0" ms-header="{{ 'apps.tagsFilterHeaderAll' | tr }}" ms-selected="{{ 'apps.tagsFilterHeader' | tr:{ tags: selectedTags.join(', ') } }}" options="tag for tag in tags" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<multiselect ng-model="selectedDomain" data-compare-by="domain" ms-selected="{{ selectedDomain.domain }}" options="domain.domain for domain in filterDomains" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<!-- <button class="btn btn-primary" ng-disabled="!selectedTags.length && !selectedState.state && selectedGroup._unset && selectedDomain._alldomains" ng-click="clearAllFilter()">{{ 'apps.filter.clearAll' | tr }}</button> -->
</form>
</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="app-grid">
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:'fqdn'">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/display" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<div class="app-grid" ng-show="view === VIEWS.GRID">
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:orderByFilter">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}" tooltip-append-to-body="true">
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/info" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<div ng-show="app.type === APP_TYPES.LINK && isOperator(app)" ng-click="applinksEdit.show(app)" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></div>
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank">
<div class="grid-item-top">
@@ -182,19 +186,88 @@
</div>
<div class="usermanagement-indicator" ng-show="app.type !== APP_TYPES.LINK">
<i class="fas fa-user" ng-show="app.ssoAuth && !app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.sso' | tr }}" tooltip-placement="right"></i>
<i class="fa-brands fa-openid" ng-show="app.ssoAuth && app.manifest.addons.oidc" uib-tooltip="{{ 'apps.auth.openid' | tr }}" tooltip-placement="right"></i>
<i class="fas fa-user" ng-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" uib-tooltip="{{ 'apps.auth.sso' | tr }}" tooltip-placement="right"></i>
<i class="far fa-user" ng-show="!app.ssoAuth && !app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.nosso' | tr }}" tooltip-placement="right"></i>
<i class="fas fa-envelope" ng-show="app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.email' | tr }}" tooltip-placement="right"></i>
</div>
</div>
</a>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-click="showAppConfigure(app, 'updates')" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')">
<i class="fa fa-arrow-up fa-inverse"></i>
</div>
</div>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-click="showAppConfigure(app, 'updates')" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')" uib-tooltip="Update Available">
<i class="fa fa-arrow-up fa-inverse"></i>
</div>
<div class="app-checklist-badge" ng-click="showAppConfigure(app, 'info')" ng-show="pendingChecklistItems(app)">
{{ pendingChecklistItems(app) }}
</div>
</div>
</div>
<div class="app-list card card-large" ng-show="view === VIEWS.LIST">
<table class="table table-hover" style="margin: 0;">
<thead>
<tr>
<th style="width: 32px" class="hand" ng-click="setOrderBy('status')"><i ng-show="orderBy === 'status'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 32px">&nbsp;</th>
<th style="width: 35%" class="hand" ng-click="setOrderBy('location')">{{ 'app.display.label' | tr }} <i ng-show="orderBy === 'location'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 30%" class="hand hide-mobile" ng-click="setOrderBy('app')">App Title<i ng-show="orderBy === 'app'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width: 32px">&nbsp;</th>
<th style="width: 32px" class="hand hide-mobile text-center" ng-click="setOrderBy('sso')"><i class="fas fa-user-lock"></i> <i ng-show="orderBy === 'sso'" class="fas fa-arrow-{{ orderByReverse ? 'up' : 'down' }}-long"></i></th>
<th style="width:160px" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr class="app-list-item" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:orderByFilter:orderByReverse" uib-tooltip="{{ app | appProgressMessage }}">
<td class="elide-table-cell">
<i class="fa fa-circle" ng-class="app | installationStateClass" uib-tooltip="{{ app | installationStateLabel }}"></i>
</td>
<td class="elide-table-cell app-list-app-link-cell">
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank" class="app-list-app-link">
<img ng-src="{{ app.iconUrl || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)" class="app-list-item-icon"/>
</a>
</td>
<td class="elide-table-cell app-list-app-link-cell">
<a ng-href="{{ app | applicationLink }}" ng-click="onAppClick(app, $event)" target="_blank" class="app-list-app-link">
<span style="font-size: 16px;">{{ app.label || app.subdomain || app.fqdn }}</span><br/>
<span class="text-muted text-small">{{ app.fqdn.indexOf('http') === 0 ? app.fqdn : 'https://'+app.fqdn }}</span>
</a>
</td>
<td class="elide-table-cell hide-mobile">{{ app.manifest.title || 'App Link' }}</td>
<td class="elide-table-cell hide-mobile text-center">
<a class="badge badge-danger" ng-show="pendingChecklistItems(app)" ng-href="#/app/{{ app.id}}/info">{{ pendingChecklistItems(app) }}</a>
</td>
<td class="elide-table-cell hide-mobile text-center">
<div ng-show="app.type !== APP_TYPES.LINK">
<i class="fa-brands fa-openid" ng-show="app.ssoAuth && app.manifest.addons.oidc" uib-tooltip="{{ 'apps.auth.openid' | tr }}"></i>
<i class="fas fa-user" ng-show="app.ssoAuth && (!app.manifest.addons.oidc && !app.manifest.addons.email)" uib-tooltip="{{ 'apps.auth.sso' | tr }}"></i>
<i class="far fa-user" ng-show="!app.ssoAuth && !app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.nosso' | tr }}"></i>
<i class="fas fa-envelope" ng-show="app.manifest.addons.email" uib-tooltip="{{ 'apps.auth.email' | tr }}"></i>
</div>
</td>
<td class="elide-table-cell text-right">
<span ng-show="isOperator(app)">
<a class="btn btn-xs btn-success" style="padding: 1px 7px;" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && (app | installSuccess) && !(app.error || app.runState === 'stopped')" ng-href="#/app/{{ app.id}}/updates" uib-tooltip="Update Available"><i class="fa fa-arrow-up"></i></a>
<div class="btn-group btn-group-xs" role="group">
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.LINK" ng-href="{{ '/frontend/logs.html?appId=' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.logsActionTooltip' | tr }}"><i class="fas fa-align-left"></i></a>
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.PROXIED && app.type !== APP_TYPES.LINK" ng-href="{{ '/frontend/terminal.html?id=' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.terminalActionTooltip' | tr }}"><i class="fa fa-terminal"></i></a>
<a class="btn btn-xs btn-default" ng-show="app.manifest.addons.localstorage" ng-href="{{ '/frontend/filemanager.html#/home/app/' + app.id }}" target="_blank" tooltip-append-to-body="true" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
</div>
<button class="btn btn-xs btn-default" ng-show="app.type === APP_TYPES.LINK" ng-click="applinksEdit.show(app)" uib-tooltip="Configure Applink"><i class="fa fa-cog"></i></button>
<a class="btn btn-xs btn-default" ng-show="app.type !== APP_TYPES.LINK" ng-href="#/app/{{ app.id}}/info" uib-tooltip="Configure App"><i class="fa fa-cog"></i></a>
</span>
</td>
</tr>
</tbody>
</table>
<br/>
<div>
{{ 'apps.apps.count' | tr:{ count: (installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch).length } }}
</div>
</div>
</div>
+91 -22
View File
@@ -4,6 +4,7 @@
/* global $:false */
/* global APP_TYPES */
/* global onAppClick */
/* global localStorage, document, FileReader */
angular.module('Application').controller('AppsController', ['$scope', '$translate', '$interval', '$location', 'Client', function ($scope, $translate, $interval, $location, Client) {
var ALL_DOMAINS_DOMAIN = { _alldomains: true, domain: 'All Domains' }; // dummy record for the single select filter
@@ -32,6 +33,15 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.showFilter = false;
$scope.filterActive = false;
$scope.VIEWS = {
GRID: 'grid',
LIST: 'list'
};
$scope.view = $scope.VIEWS.GRID;
$scope.orderBy = 'location'; // or app, status, sso
$scope.orderByReverse = false;
$scope.allUsers = [];
$scope.allGroups = [];
@@ -45,6 +55,59 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
if (tr['app.states.updateAvailable']) $scope.states[3].label = tr['app.states.updateAvailable'];
});
$scope.pendingChecklistItems = function (app) {
if (!app.checklist) return 0;
return Object.keys(app.checklist).filter(function (key) { return !app.checklist[key].acknowledged; }).length;
};
$scope.setOrderBy = function (by) {
if (by === $scope.orderBy) {
$scope.orderByReverse = !$scope.orderByReverse;
} else {
$scope.orderBy = by;
$scope.orderByReverse = false;
}
localStorage.appsOrderBy = by;
if ($scope.orderByReverse) localStorage.appsOrderByReverse = true;
else localStorage.removeItem('appsOrderByReverse');
};
// for sorting/grouping
$scope.orderByFilter = function (item) {
if ($scope.orderBy === 'app') return item.manifest.title || 'App Link';
if ($scope.orderBy === 'status') return item.installationState + '-' + item.runState;
if ($scope.orderBy === 'sso') return item.sso;
return item.label || item.fqdn;
};
$scope.setView = function (view) {
if (view !== $scope.VIEWS.LIST && view !== $scope.VIEWS.GRID) return;
$scope.view = view;
localStorage.appsView = view;
};
$scope.toggleView = function () {
$scope.view = $scope.view === $scope.VIEWS.GRID ? $scope.VIEWS.LIST : $scope.VIEWS.GRID;
localStorage.appsView = $scope.view;
};
$scope.toggleFilter = function () {
$scope.showFilter = !$scope.showFilter;
if ($scope.showFilter) localStorage.appsShowFilter = true;
else localStorage.removeItem('appsShowFilter');
// clear on hide
if (!$scope.showFilter) {
$scope.selectedState = $scope.states[0];
$scope.selectedTags = [];
$scope.selectedGroup = GROUP_ACCESS_UNSET;
$scope.selectedDomain = ALL_DOMAINS_DOMAIN;
}
};
$scope.$watch('selectedTags', function (newVal, oldVal) {
if (newVal === oldVal) return;
@@ -74,35 +137,24 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$scope.onAppClick = function (app, $event) { onAppClick(app, $event, $scope.isOperator(app), $scope); };
$scope.clearAllFilter = function () {
$scope.selectedState = $scope.states[0];
$scope.selectedTags = [];
$scope.selectedGroup = GROUP_ACCESS_UNSET;
$scope.selectedDomain = ALL_DOMAINS_DOMAIN;
};
$scope.appPostInstallConfirm = {
app: {},
message: '',
confirmed: false,
show: function (app) {
$scope.appPostInstallConfirm.app = app;
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
$scope.appPostInstallConfirm.confirmed = false;
$('#appPostInstallConfirmModal').modal('show');
$('#appsPostInstallConfirmModal').modal('show');
return false; // prevent propagation and default
},
submit: function () {
if (!$scope.appPostInstallConfirm.confirmed) return;
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
$('#appPostInstallConfirmModal').modal('hide');
$('#appsPostInstallConfirmModal').modal('hide');
}
};
@@ -187,21 +239,19 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
accessRestriction.groups = $scope.applinksEdit.accessRestriction.groups.map(function (g) { return g.id; });
}
var icon;
if ($scope.applinksEdit.icon.data === '__original__') { // user reset the icon
icon = '';
} else if ($scope.applinksEdit.icon.data) { // user loaded custom icon
icon = $scope.applinksEdit.icon.data.replace(/^data:image\/[a-z]+;base64,/, '');
}
var data = {
upstreamUri: $scope.applinksEdit.upstreamUri,
label: $scope.applinksEdit.label,
accessRestriction: accessRestriction,
icon: icon,
tags: $scope.applinksEdit.tags.split(' ').map(function (t) { return t.trim(); }).filter(function (t) { return !!t; })
};
if ($scope.applinksEdit.icon.data === '__original__') { // user reset the icon
data.icon = '';
} else if ($scope.applinksEdit.icon.data) { // user loaded custom icon
data.icon = $scope.applinksEdit.icon.data.replace(/^data:image\/[a-z]+;base64,/, '');
}
Client.updateApplink($scope.applinksEdit.id, data, function (error) {
$scope.applinksEdit.busyEdit = false;
@@ -253,6 +303,12 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
});
});
$scope.setView(localStorage.appsView);
$scope.orderBy = localStorage.appsOrderBy || 'location';
$scope.orderByReverse = !!localStorage.appsOrderByReverse;
$scope.showFilter = !!localStorage.appsShowFilter;
if (!$scope.user.isAtLeastAdmin) return;
// load local settings and apply tag filter
@@ -302,7 +358,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
// setup all the dialog focus handling
['applinksAddModal', 'applinksEditModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
$(this).find('autofocus]:first').focus();
});
});
@@ -313,4 +369,17 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
});
$('.modal-backdrop').remove();
function keyboardHandler(event) {
if (event.key === '/') {
document.getElementById('appSearch').focus();
event.preventDefault();
}
}
document.addEventListener('keydown', keyboardHandler);
$scope.$on('$destroy', function () {
document.removeEventListener('keydown', keyboardHandler);
});
}]);
+104 -36
View File
@@ -69,17 +69,17 @@
</div>
<div class="has-error text-center" ng-show="appInstall.error.port">{{ appInstall.error.port }}</div>
<div ng-repeat="(env, info) in appInstall.portBindingsInfo">
<div ng-repeat="(env, info) in appInstall.portInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!appInstallForm.itemName{{$index}}.$dirty && appInstall.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portBindingsEnabled[env]">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portsEnabled[env]">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}. {{info.portCount >=1 ? (info.portCount + ' ports. ') : ''}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
</sup>
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
</label>
<input type="number" class="form-control" ng-model="appInstall.portBindings[env]" ng-disabled="!appInstall.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<input type="number" class="form-control" ng-model="appInstall.ports[env]" ng-disabled="!appInstall.portsEnabled[env]" ng-readonly="info.readOnly" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="appInstall.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
@@ -281,8 +281,9 @@
<!-- appstore login -->
<div ng-show="ready && !validSubscription" class="container card card-small appstore-login ng-cloak">
<div class="col-md-12 text-center">
<h1 ng-show="appstoreLogin.register">{{ 'appstore.accountDialog.titleSignUp' | tr }}</h1>
<h1 ng-hide="appstoreLogin.register">{{ 'appstore.accountDialog.titleLogin' | tr }}</h1>
<h1 ng-show="appstoreLogin.setupType === 'signup'">{{ 'appstore.accountDialog.titleSignUp' | tr }}</h1>
<h1 ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.titleLogin' | tr }}</h1>
<h1 ng-show="appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.titleToken' | tr }}</h1>
</div>
<div class="col-md-12 text-center">
<p>{{ 'appstore.accountDialog.description' | tr }}</p>
@@ -293,54 +294,121 @@
<div class="col-md-12">
<br/>
<form name="appstoreLoginForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div ng-show="appstoreLogin.setupType === 'signup'">
<form name="appstoreSignupForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.generic }">
<div class="form-group" ng-class="{ 'has-error': (appstoreSignupForm.email.$dirty && appstoreSignupForm.email.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.email' | tr }}</label>
<input type="email" class="form-control" ng-model="appstoreLogin.email" id="inputAppstoreLoginEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!appstoreLoginForm.email.$dirty && appstoreLogin.error.email) || (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.email">
<small class="text-danger" ng-show="appstoreLogin.error.email">{{ appstoreLogin.error.email }}</small>
<input type="email" class="form-control" ng-model="appstoreLogin.email" id="inputAppstoreSignupEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!appstoreSignupForm.email.$dirty && appstoreLogin.error.email) || (appstoreSignupForm.email.$dirty && appstoreSignupForm.email.$invalid) || appstoreLogin.error.email">
<small class="text-danger" ng-show="appstoreLogin.error.email">{{ appstoreLogin.error.email }}</small>
</div>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!appstoreLoginForm.password.$dirty && appstoreLogin.error.password) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid) || appstoreLogin.error.generic }">
<div class="form-group" ng-class="{ 'has-error': (!appstoreSignupForm.password.$dirty && appstoreLogin.error.signupPassword) || (appstoreSignupForm.password.$dirty && appstoreSignupForm.password.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.password' | tr }}</label>
<input type="password" class="form-control" ng-model="appstoreLogin.password" id="inputAppstoreSignupPassword" name="password" required password-reveal>
<div class="control-label" ng-show="(!appstoreSignupForm.password.$dirty && appstoreLogin.error.signupPassword) || (appstoreSignupForm.password.$dirty && appstoreSignupForm.password.$invalid)">
<small ng-show="!appstoreSignupForm.password.$dirty && appstoreLogin.error.signupPassword">{{ 'appstore.accountDialog.errorWrongPassword' | tr }}</small>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted" ng-required="true"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreSignupForm.$invalid || appstoreLogin.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> {{ 'appstore.accountDialog.createAccountAction' | tr }}
</button>
</center>
</form>
</div>
<div ng-show="appstoreLogin.setupType === 'login'">
<form name="appstoreLoginForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.email' | tr }}</label>
<input type="email" class="form-control" ng-model="appstoreLogin.email" name="email" required autofocus>
<div class="control-label" ng-show="(!appstoreLoginForm.email.$dirty && appstoreLogin.error.email) || (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.email">
<small class="text-danger" ng-show="appstoreLogin.error.email">{{ appstoreLogin.error.email }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!appstoreLoginForm.password.$dirty && appstoreLogin.error.loginPassword) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">{{ 'appstore.accountDialog.password' | tr }}</label>
<input type="password" class="form-control" ng-model="appstoreLogin.password" id="inputAppstoreLoginPassword" name="password" required password-reveal>
<div class="control-label" ng-show="(!appstoreLoginForm.password.$dirty && appstoreLogin.error.password) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid)">
<small ng-show="!appstoreLoginForm.password.$dirty && appstoreLogin.error.password">{{ 'appstore.accountDialog.errorWrongPassword' | tr }}</small>
<div class="control-label" ng-show="(!appstoreLoginForm.password.$dirty && appstoreLogin.error.loginPassword) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid)">
<small ng-show="!appstoreLoginForm.password.$dirty && appstoreLogin.error.loginPassword">{{ 'appstore.accountDialog.errorWrongPassword' | tr }}</small>
</div>
</div>
</div>
<div class="form-group" ng-hide="appstoreLogin.register" ng-class="{ 'has-error': appstoreLogin.error.totpToken }">
<div class="form-group" ng-class="{ 'has-error': appstoreLogin.error.totpToken }">
<label class="control-label">{{ 'appstore.accountDialog.2faToken' | tr }}</label>
<input type="text" class="form-control" ng-model="appstoreLogin.totpToken" id="inputAppstoreLoginTotpToken" name="totpToken">
<div class="control-label" ng-show="appstoreLogin.error.totpToken">
<small ng-show="appstoreLogin.error.totpToken">{{ appstoreLogin.error.totpToken }}</small>
<small ng-show="appstoreLogin.error.totpToken">{{ appstoreLogin.error.totpToken }}</small>
</div>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreLoginForm.$invalid || appstoreLogin.busy || !appstoreLogin.termsAccepted">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> <span ng-hide="appstoreLogin.register">{{ 'appstore.accountDialog.loginAction' | tr }}</span><span ng-show="appstoreLogin.register">{{ 'appstore.accountDialog.createAccountAction' | tr }}</span>
</button>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted" ng-required="true"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreLoginForm.$invalid || appstoreLogin.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> {{ 'appstore.accountDialog.loginAction' | tr }}
</button>
</center>
</form>
</div>
<div ng-show="appstoreLogin.setupType === 'setupToken'">
<form name="appstoreSetupTokenForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': appstoreLogin.error.setupToken }">
<label class="control-label">{{ 'appstore.accountDialog.setupToken' | tr }}</label>
<input type="text" class="form-control" ng-model="appstoreLogin.setupToken" id="inputAppstoreSetupToken" name="setupToken" ng-required="true">
<div class="control-label" ng-show="appstoreLogin.error.setupToken">
<small ng-show="appstoreLogin.error.setupToken">{{ appstoreLogin.error.setupToken }}</small>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted" ng-required="true"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
</label>
</div>
<br/>
<a href="" ng-click="appstoreLogin.register = true" ng-hide="appstoreLogin.register">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
<a href="" ng-click="appstoreLogin.register = false" ng-show="appstoreLogin.register">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
</center>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreSetupTokenForm.$invalid || appstoreLogin.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="appstoreLogin.busy"></i> {{ 'appstore.accountDialog.setupWithTokenAction' | tr }}
</button>
</center>
</form>
</div>
</form>
<br/>
<center>
<a href="" ng-click="appstoreLogin.setupType = 'signup'" ng-show="appstoreLogin.setupType === 'login'">{{ 'appstore.accountDialog.switchToSignUpAction' | tr }}</a>
<a href="" ng-click="appstoreLogin.setupType = 'login'" ng-show="appstoreLogin.setupType === 'signup' || appstoreLogin.setupType === 'setupToken'">{{ 'appstore.accountDialog.switchToLoginAction' | tr }}</a>
<span ng-show="appstoreLogin.setupType !== 'setupToken'"> or <a href="" ng-click="appstoreLogin.setupType = 'setupToken'">use a setup token</a></span>
</center>
</div>
</div>
+36 -28
View File
@@ -1,6 +1,6 @@
'use strict';
/* global angular:false */
/* global angular:false, document, window, localStorage, FileReader */
/* global $:false */
/* global async */
/* global ERROR */
@@ -25,7 +25,6 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.cachedCategory = ''; // used to cache the selected category while searching
$scope.searchString = '';
$scope.validSubscription = false;
$scope.unstableApps = false;
$scope.subscription = {};
$scope.memory = null; // { memory, swap }
@@ -45,6 +44,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
// If new categories added make sure the translation below exists
$scope.categories = [
{ id: 'analytics', icon: 'fa fa-chart-line', label: 'Analytics'},
{ id: 'automation', icon: 'fa fa-robot', label: 'Automation'},
{ id: 'blog', icon: 'fa fa-font', label: 'Blog'},
{ id: 'chat', icon: 'fa fa-comments', label: 'Chat'},
{ id: 'crm', icon: 'fab fa-connectdevelop', label: 'CRM'},
@@ -64,6 +64,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
{ id: 'notes', icon: 'fa fa-sticky-note', label: 'Notes'},
{ id: 'project', icon: 'fas fa-project-diagram', label: 'Project Management'},
{ id: 'sync', icon: 'fa fa-sync-alt', label: 'File Sync'},
{ id: 'voip', icon: 'fa fa-headset', label: 'VoIP'},
{ id: 'vpn', icon: 'fa fa-user-secret', label: 'VPN'},
{ id: 'wiki', icon: 'fab fa-wikipedia-w', label: 'Wiki'},
];
@@ -121,7 +122,8 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: '',
domain: null, // object and not the string
secondaryDomains: {},
portBindings: {},
ports: {},
portsEnabled: {},
mediaLinks: [],
certificateFile: null,
certificateFileName: '',
@@ -146,7 +148,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appInstall.subdomain = '';
$scope.appInstall.domain = null;
$scope.appInstall.secondaryDomains = {};
$scope.appInstall.portBindings = {};
$scope.appInstall.ports = {};
$scope.appInstall.state = 'appInfo';
$scope.appInstall.mediaLinks = [];
$scope.appInstall.certificateFile = null;
@@ -175,13 +177,13 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
var app = $scope.appInstall.app;
var DEFAULT_MEMORY_LIMIT = 1024 * 1024 * 256;
var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM+Swap
var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM
var used = Client.getInstalledApps().reduce(function (prev, cur) {
if (cur.runState === RSTATES.STOPPED) return prev;
return prev + (cur.memoryLimit || cur.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT);
}, 0);
var totalMemory = ($scope.memory.memory + $scope.memory.swap) * 1.5;
var totalMemory = $scope.memory.memory * 2;
var available = (totalMemory || 0) - used;
var enoughResourcesAvailable = (available - needed) >= 0;
@@ -217,9 +219,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
$scope.appInstall.portBindingsInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.portBindings = {}; // This is the actual model holding the env:port pair
$scope.appInstall.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag
$scope.appInstall.portInfo = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts); // Portbinding map only for information
$scope.appInstall.ports = {}; // This holds the env:port pair
$scope.appInstall.portsEnabled = {}; // This holds the enabled/disabled flag
var manifest = app.manifest;
$scope.appInstall.optionalSso = !!manifest.optionalSso;
@@ -231,8 +233,8 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
// set default ports
var allPorts = angular.extend({}, $scope.appInstall.app.manifest.tcpPorts, $scope.appInstall.app.manifest.udpPorts);
for (var env in allPorts) {
$scope.appInstall.portBindings[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portBindingsEnabled[env] = true;
$scope.appInstall.ports[env] = allPorts[env].defaultValue || 0;
$scope.appInstall.portsEnabled[env] = true;
}
$('#appInstallModal').modal('show');
@@ -252,11 +254,11 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appInstall.portBindings) {
if ($scope.appInstall.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.appInstall.portBindings[env];
// only use enabled ports from ports
var finalPorts = {};
for (var env in $scope.appInstall.ports) {
if ($scope.appInstall.portsEnabled[env]) {
finalPorts[env] = $scope.appInstall.ports[env];
}
}
@@ -272,7 +274,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
subdomain: $scope.appInstall.subdomain || '',
domain: $scope.appInstall.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
ports: finalPorts,
accessRestriction: finalAccessRestriction,
cert: $scope.appInstall.certificateFile,
key: $scope.appInstall.keyFile,
@@ -415,22 +417,24 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
email: '',
password: '',
totpToken: '',
register: true,
setupType: 'login',
termsAccepted: false,
setupToken: '',
submit: function () {
$scope.appstoreLogin.error = {};
$scope.appstoreLogin.busy = true;
Client.registerCloudron($scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.register, function (error) {
var func = $scope.appstoreLogin.setupToken ? Client.registerCloudronWithSetupToken.bind(null, $scope.appstoreLogin.setupToken) : Client.registerCloudron.bind(null, $scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.setupType === 'register');
func(function (error) {
if (error) {
$scope.appstoreLogin.busy = false;
if (error.statusCode === 409) {
$scope.appstoreLogin.error.email = 'An account with this email already exists';
$scope.appstoreLogin.password = '';
$scope.appstoreLoginForm.email.$setPristine();
$scope.appstoreLoginForm.password.$setPristine();
$scope.appstoreSignupForm.email.$setPristine();
$scope.appstoreSignupForm.password.$setPristine();
$('#inputAppstoreLoginEmail').focus();
} else if (error.statusCode === 412) {
if (error.message.indexOf('TOTP token missing') !== -1) {
@@ -441,7 +445,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appstoreLogin.totpToken = '';
setTimeout(function () { $('#inputAppstoreLoginTotpToken').focus(); }, 0);
} else {
$scope.appstoreLogin.error.password = 'Wrong email or password';
$scope.appstoreLogin.error.loginPassword = 'Wrong email or password';
$scope.appstoreLogin.password = '';
$('#inputAppstoreLoginPassword').focus();
$scope.appstoreLoginForm.password.$setPristine();
@@ -453,11 +457,18 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$scope.appstoreLogin.password = '';
$scope.appstoreLoginForm.email.$setPristine();
$scope.appstoreLoginForm.password.$setPristine();
$scope.appstoreSignupForm.email.$setPristine();
$scope.appstoreSignupForm.password.$setPristine();
$('#inputAppstoreLoginEmail').focus();
} else {
console.error(error);
$scope.appstoreLogin.error.generic = error.message;
}
} else if (error.statusCode === 402) {
$scope.appstoreLogin.error.setupToken = 'Invalid or expired setup token';
$scope.appstoreLogin.setupToken = '';
$scope.appstoreSetupTokenForm.setupToken.$setPristine();
$('#inputAppstoreSetupToken').focus();
} else {
console.error(error);
$scope.appstoreLogin.error.generic = error.message || 'Please retry later';
@@ -777,10 +788,12 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
getSubscription(function (error, validSubscription) {
if (error) console.error('Failed to get subscription.', error);
// autofocus login
if (!validSubscription) setTimeout(function () { $('[name=appstoreLoginForm]').find('[autofocus]:first').focus(); }, 1000);
$scope.validSubscription = validSubscription;
$scope.ready = true;
// refresh everything in background
Client.getAppstoreApps(function (error) { if (error) console.error('Failed to fetch apps.', error); });
Client.refreshConfig(); // refresh domain, user, group limit etc
@@ -827,10 +840,5 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
});
});
// autofocus if appstore login is shown
$scope.$watch('validSubscription', function (newValue/*, oldValue */) {
if (!newValue) setTimeout(function () { $('[name=appstoreLoginForm]').find('[autofocus]:first').focus(); }, 1000);
});
$('.modal-backdrop').remove();
}]);
+94 -63
View File
@@ -14,6 +14,10 @@
<div class="col-xs-2 text-muted">{{ 'backups.backupEdit.label' | tr }}:</div>
<div class="col-xs-10 text-right">{{ backupDetails.backup.label }}</div>
</div>
<div class="row">
<div class="col-xs-2 text-muted">{{ 'backups.backupEdit.remotePath' | tr }}:</div>
<div class="col-xs-10 text-right">{{ backupDetails.backup.remotePath }}</div>
</div>
<div class="row">
<div class="col-xs-2 text-muted">{{ 'backups.backupDetails.date' | tr }}:</div>
<div class="col-xs-10 text-right">{{ backupDetails.backup.creationTime | prettyLongDate }}</div>
@@ -28,8 +32,10 @@
</div>
<br/>
<p class="text-muted">{{ 'backups.backupDetails.list' | tr:{ appCount: backupDetails.backup.contents.length } }}:</p>
<span ng-repeat="app in backupDetails.backup.contents | orderBy:['label','fqdn']">
<a ng-href="/#/app/{{app.id}}/backups">{{ app.label || app.fqdn }}</a><span ng-hide="$last">,</span>
<span ng-repeat="content in backupDetails.backup.contents | orderBy:['label','fqdn']">
<a ng-if="content.fqdn" ng-href="/#/app/{{content.id}}/backups">{{ content.label || content.fqdn }}</a>
<a ng-if="!content.fqdn" ng-href="/#/eventlog?search={{content.id}}">{{ content.id }}</a>
<span ng-hide="$last">,</span>
</span>
</div>
<div class="modal-footer">
@@ -101,47 +107,47 @@
<div class="modal-body">{{ 'backups.cleanupBackups.description' | tr }}</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="createBackup.startCleanup()">{{ 'backups.cleanupBackups.cleanupNow' | tr }}</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="cleanupBackups.start()">{{ 'backups.cleanupBackups.cleanupNow' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- modal backup config -->
<div class="modal fade" id="configureScheduleAndRetentionModal" tabindex="-1" role="dialog">
<!-- modal backup schedule config -->
<div class="modal fade" id="backupPolicyModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'backups.configureBackupSchedule.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="configureScheduleAndRetentionForm" role="form" novalidate ng-submit="configureScheduleAndRetention.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="configureScheduleAndRetention.error">{{ configureScheduleAndRetention.error.generic }}</p>
<form name="backupPolicyForm" role="form" novalidate ng-submit="backupPolicy.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="backupPolicy.error">{{ backupPolicy.error.generic }}</p>
<div class="form-group">
<label class="control-label" for="backupSchedule">{{ 'backups.configureBackupSchedule.schedule' | tr }}</label>
<p ng-bind-html="'backups.configureBackupSchedule.scheduleDescription' | tr"></p>
<div class="row" style="margin-left: 20px;">
<div class="col-md-5" ng-class="{ 'has-error': !configureScheduleAndRetention.days.length }">
{{ 'backups.configureBackupSchedule.days' | tr }}: <multiselect id="backupSchedule" class="input-sm stretch" ng-model="configureScheduleAndRetention.days" options="a.name for a in cronDays" data-multiple="true" ng-required></multiselect>
<div class="col-md-5" ng-class="{ 'has-error': !backupPolicy.days.length }">
{{ 'backups.configureBackupSchedule.days' | tr }}: <multiselect id="backupSchedule" class="input-sm stretch" ng-model="backupPolicy.days" options="a.name for a in cronDays" data-multiple="true" ng-required></multiselect>
</div>
<div class="col-md-5" ng-class="{ 'has-error': !configureScheduleAndRetention.hours.length }">
{{ 'backups.configureBackupSchedule.hours' | tr }}: <multiselect class="input-sm stretch" ng-model="configureScheduleAndRetention.hours" options="a.name for a in cronHours" data-multiple="true"></multiselect>
<div class="col-md-5" ng-class="{ 'has-error': !backupPolicy.hours.length }">
{{ 'backups.configureBackupSchedule.hours' | tr }}: <multiselect class="input-sm stretch" ng-model="backupPolicy.hours" options="a.name for a in cronHours" data-multiple="true"></multiselect>
</div>
</div>
</div>
<div class="form-group">
<label class="control-label" for="backupRetention">{{ 'backups.configureBackupSchedule.retentionPolicy' | tr }}</label>
<select class="form-control" id="backupRetention" ng-model="configureScheduleAndRetention.retentionPolicy" ng-options="a.value as a.name for a in retentionPolicies"></select>
<select class="form-control" id="backupRetention" ng-model="backupPolicy.retention" ng-options="a.value as a.name for a in backupRetentions"></select>
</div>
</form>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="configureScheduleAndRetention.submit()" ng-disabled="!configureScheduleAndRetention.valid() || configureScheduleAndRetention.busy"><i class="fa fa-circle-notch fa-spin" ng-show="configureScheduleAndRetention.busy"></i><span> {{ 'main.dialog.save' | tr }}</span></button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="backupPolicy.submit()" ng-disabled="!backupPolicy.valid() || backupPolicy.busy"><i class="fa fa-circle-notch fa-spin" ng-show="backupPolicy.busy"></i><span> {{ 'main.dialog.save' | tr }}</span></button>
</div>
</div>
</div>
@@ -173,7 +179,7 @@
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.mountPoint || (configureBackupForm.mountPoint.$dirty && !configureBackup.mountPoint) }" ng-show="configureBackup.provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="configureBackup.busy" placeholder="/mnt/backups" ng-required="configureBackup.provider === 'mountpoint'">
<p ng-show="configureBackup.provider === 'mointpoint'" ng-bind-html="'backups.configureBackupStorage.mountPointDescription' | tr:{ providerDocsLink: 'https://docs.cloudron.io/backups/#'+configureBackup.provider }"></p>
<p ng-show="configureBackup.provider === 'mountpoint'" ng-bind-html="'backups.configureBackupStorage.mountPointDescription' | tr:{ providerDocsLink: 'https://docs.cloudron.io/backups/#'+configureBackup.provider }"></p>
</div>
<!-- CIFS/NFS/SSHFS -->
@@ -207,10 +213,16 @@
<input type="password" class="form-control" ng-model="configureBackup.mountOptions.password" id="configureBackupPassword" name="cifsPassword" ng-disabled="configureBackup.busy" password-reveal>
</div>
<!-- EXT4 -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.diskPath || !configureBackup.mountOptions.diskPath }" ng-show="configureBackup.provider === 'ext4' || configureBackup.provider === 'xfs'">
<!-- EXT4/XFS -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.diskPath || !configureBackup.mountOptions.diskPath }" ng-show="configureBackup.provider === 'xfs' || configureBackup.provider === 'ext4'">
<label class="control-label" for="inputConfigureDiskPath">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="configureBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="configureBackup.provider === 'ext4' || configureBackup.provider === 'xfs'">
<input type="text" class="form-control" ng-model="configureBackup.mountOptions.diskPath" id="inputConfigureDiskPath" name="diskPath" ng-disabled="configureBackup.busy" placeholder="/dev/disk/by-uuid/uuid" ng-required="configureBackup.provider === 'xfs' || configureBackup.provider === 'ext4'">
</div>
<!-- Disk -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.diskPath || !configureBackup.mountOptions.diskPath }" ng-show="configureBackup.provider === 'disk'">
<label class="control-label">{{ 'backups.configureBackupStorage.diskPath' | tr }}</label>
<select class="form-control" ng-model="configureBackup.disk" ng-options="item as item.label for item in configureBackup.blockDevices track by item.path" ng-required="configureBackup.provider === 'disk'"></select>
</div>
<!-- SSHFS -->
@@ -325,6 +337,11 @@
<select class="form-control" name="region" id="inputConfigureBackupVultrRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in vultrRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'vultr-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'contabo-objectstorage'">
<label class="control-label" for="inputConfigureBackupContaboRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupContaboRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in contaboRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'contabo-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.accessKeyId }" ng-show="s3like(configureBackup.provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">{{ 'backups.configureBackupStorage.s3AccessKeyId' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
@@ -373,48 +390,46 @@
</div>
<a href="" ng-click="configureBackup.advancedVisible = true" ng-hide="configureBackup.advancedVisible">{{ 'backups.configureBackupStorage.advancedSettings' | tr }}</a>
<div uib-collapse="!configureBackup.advancedVisible">
<div uib-collapse="!configureBackup.advancedVisible">
<div class="form-group">
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'800 MB' }}</b></label>
<label class="control-label" for="sliderConfigureBackupMemoryLimit">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'1024 MB' }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.memoryLimitDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" step="134217728" tooltip="hide" ticks="configureBackup.memoryTicks" ticks-snap-bounds="67108864"></slider>
</div>
<input type="range" id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" step="{{ 256*1024*1024 }}" min="{{ MIN_MEMORY_LIMIT }}" max="{{ MAX_MEMORY_LIMIT }}" />
</div>
<div class="form-group" ng-show="s3like(configureBackup.provider)">
<label class="control-label">{{ 'backups.configureBackupStorage.uploadPartSize' | tr }}: <b>{{ configureBackup.uploadPartSize | prettyBinarySize:'Default (50 MiB)' }}</b></label>
<label class="control-label" for="sliderConfigureBackupUploadPartSize">{{ 'backups.configureBackupStorage.uploadPartSize' | tr }}: <b>{{ configureBackup.uploadPartSize | prettyBinarySize:'Default (50 MiB)' }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.uploadPartSizeDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupUploadPartSize" ng-model="configureBackup.uploadPartSize" step="1048576" tooltip="hide" ticks="configureBackup.uploadPartSizeTicks" ticks-snap-bounds="2097152"></slider>
</div>
<input type="range" id="sliderConfigureBackupUploadPartSize" ng-model="configureBackup.uploadPartSize" list="uploadPartSizeTicks" step="{{ 1024*1024 }}" min="{{ 1024*1024 }}" max="{{ 1024*1024*1024 }}" />
<datalist id="uploadPartSizeTicks">
<option value="{{ 1024*1024 }}"></option>
<option value="{{ 64*1024*1024 }}"></option>
<option value="{{ 128*1024*1024 }}"></option>
<option value="{{ 256*1024*1024 }}"></option>
<option value="{{ 512*1024*1024 }}"></option>
<option value="{{ 1024*1024*1024 }}"></option>
</datalist>
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && configureBackup.provider !== 'noop'">
<label class="control-label">{{ 'backups.configureBackupStorage.uploadConcurrency' | tr }}: <b>{{ configureBackup.syncConcurrency }}</b></label>
<label class="control-label" for="sliderConfigureBackupSyncConcurrency">{{ 'backups.configureBackupStorage.uploadConcurrency' | tr }}: <b>{{ configureBackup.syncConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.uploadConcurrencyDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupSyncConcurrency" ng-model="configureBackup.syncConcurrency" tooltip="hide" min="10" max="200" step="10"></slider>
</div>
<input type="range" id="sliderConfigureBackupSyncConcurrency" ng-model="configureBackup.syncConcurrency" step="10" min="10" max="200" />
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">
<label class="control-label">{{ 'backups.configureBackupStorage.downloadConcurrency' | tr }}: <b>{{ configureBackup.downloadConcurrency }}</b></label>
<label class="control-label" for="sliderConfigureBackupDownloadConcurrency">{{ 'backups.configureBackupStorage.downloadConcurrency' | tr }}: <b>{{ configureBackup.downloadConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.downloadConcurrencyDescription' | tr }}</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.downloadConcurrency" tooltip="hide" min="10" max="200" step="10"></slider>
</div>
<input type="range" id="sliderConfigureBackupDownloadConcurrency" ng-model="configureBackup.downloadConcurrency" step="10" min="10" max="200" />
</div>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">
<label class="control-label">{{ 'backups.configureBackupStorage.copyConcurrency' | tr }}: <b>{{ configureBackup.copyConcurrency }}</b></label>
<label class="control-label" for="sliderConfigureBackupCopyConcurrency">{{ 'backups.configureBackupStorage.copyConcurrency' | tr }}: <b>{{ configureBackup.copyConcurrency }}</b></label>
<p class="small">{{ 'backups.configureBackupStorage.copyConcurrencyDescription' | tr }}
<span ng-show="configureBackup.provider === 'digitalocean-spaces'">{{ 'backups.configureBackupStorage.copyConcurrencyDigitalOceanNote' | tr }}</span>
</p>
<div style="padding: 0 10px;">
<slider id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.copyConcurrency" tooltip="hide" min="10" max="500" step="10"></slider>
</div>
<input type="range" id="sliderConfigureBackupCopyConcurrency" ng-model="configureBackup.copyConcurrency" step="10" min="10" max="500" />
</div>
</div> <!-- advanced -->
@@ -468,8 +483,8 @@
<div class="col-xs-6 text-right no-wrap">
<span ng-show="backupConfig.provider === 'filesystem'">{{ backupConfig.backupFolder }}</span>
<span ng-show="mountlike(backupConfig.provider)">
<i class="fa fa-circle" ng-style="{ color: backupConfig.mountStatus.state === 'active' ? '#27CE65' : '#d9534f' }" ng-show="backupConfig.mountStatus" uib-tooltip="{{ backupConfig.mountStatus.message }}"></i>
<span ng-show="backupConfig.provider === 'filesystem' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs' || backupConfig.provider === 'mountpoint'">{{ backupConfig.mountOptions.diskPath || backupConfig.mountPoint }}{{ (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
<i class="fa fa-circle" ng-style="{ color: mountStatus.state === 'active' ? '#27CE65' : '#d9534f' }" ng-show="mountStatus" uib-tooltip="{{ mountStatus.message }}"></i>
<span ng-show="backupConfig.provider === 'disk' || backupConfig.provider === 'filesystem' || backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs' || backupConfig.provider === 'mountpoint'">{{ backupConfig.mountOptions.diskPath || backupConfig.mountPoint }}{{ (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
<span ng-show="backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs' || backupConfig.provider === 'sshfs'">{{ backupConfig.mountOptions.host }}:{{ backupConfig.mountOptions.remoteDir }}{{ (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
</span>
@@ -507,8 +522,23 @@
</div>
</div>
<div class="text-left">
<h3>{{ 'backups.schedule.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'backups.schedule.title' | tr }}
<!-- <a class="btn btn-sm btn-default pull-right" ng-href="/frontend/logs.html?taskId={{cleanupBackups.taskId}}" target="_blank" uib-tooltip="{{ 'backups.logs.showLogs' | tr }}"><i class="fas fa-align-left"></i></a> -->
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="cleanupTasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'backups.logs.showLogs' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in cleanupTasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
@@ -518,7 +548,7 @@
<span class="text-muted">{{ 'backups.schedule.schedule' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ prettyBackupSchedule(backupConfig.schedulePattern) }}</span>
<span>{{ prettyBackupSchedule(backupPolicy.currentPolicy.schedule) }}</span>
</div>
</div>
<div class="row">
@@ -526,19 +556,34 @@
<span class="text-muted">{{ 'backups.schedule.retentionPolicy' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ prettyBackupRetentionPolicy(backupConfig.retentionPolicy) }}</span>
<span>{{ prettyBackupRetention(backupPolicy.currentPolicy.retention) }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-show="user.isAtLeastOwner" ng-click="configureScheduleAndRetention.show()">{{ 'backups.schedule.configure' | tr }}</button>
<button class="btn btn-default" ng-click="cleanupBackups.ask()" ng-disabled="cleanupBackups.busy" style="margin-right: 5px"><i class="fa fa-circle-notch fa-spin" ng-show="cleanupBackups.busy"></i> {{ 'backups.listing.cleanupBackups' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-show="user.isAtLeastOwner" ng-click="backupPolicy.show()">{{ 'backups.schedule.configure' | tr }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'backups.listing.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'backups.listing.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="backupTasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'backups.logs.showLogs' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in backupTasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card card-large">
@@ -560,7 +605,7 @@
<tr ng-repeat="backup in backups">
<td><i class="fas fa-archive" ng-show="backup.preserveSecs === -1" uib-tooltip="{{ 'backups.listing.tooltipPreservedBackup' | tr }}"></i></td>
<td ng-click="backupDetails.show(backup)" class="hand">v{{ backup.packageVersion }}</td>
<td ng-click="backupDetails.show(backup)" class="hand"><span uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }} <b ng-show="backup.label">({{ backup.label }})</b></span></td>
<td ng-click="backupDetails.show(backup)" class="hand">{{ backup.creationTime | prettyLongDate }} <b ng-show="backup.label">({{ backup.label }})</b></td>
<td ng-click="backupDetails.show(backup)" class="hand">
<span ng-show="!backup.contents.length">{{ 'backups.listing.noApps' | tr }}</span>
<span ng-show="backup.contents.length">{{ 'backups.listing.appCount' | tr:{ appCount: backup.contents.length } }}</span>
@@ -594,22 +639,8 @@
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-default" ng-click="createBackup.cleanupBackups()" ng-show="!createBackup.busy" style="margin-right: 5px">{{ 'backups.listing.cleanupBackups' | tr }}</button>
<button class="btn btn-outline btn-primary" ng-click="createBackup.startBackup()" ng-show="!createBackup.busy">{{ 'backups.listing.backupNow' | tr }}</button>
<button class="btn btn-outline btn-danger" ng-click="createBackup.stopTask()" ng-show="createBackup.busy">{{ 'backups.listing.stopTask' | tr:{ taskType: createBackup.taskType } }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'backups.logs.title' | tr }}</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>{{ 'backups.logs.description' | tr }}</p>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{createBackup.taskId}}" ng-disabled="!createBackup.taskId" target="_blank">{{ 'backups.logs.showLogs' | tr }}</a>
<button class="btn btn-outline btn-danger" ng-click="createBackup.stopTask()" ng-show="createBackup.busy">{{ 'backups.listing.stopTask' | tr }}</button>
</div>
</div>
</div>
+222 -102
View File
@@ -1,22 +1,25 @@
'use strict';
/* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER, STORAGE_PROVIDERS, BACKUP_FORMATS, APP_TYPES */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR */
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR , REGIONS_CONTABO */
angular.module('Application').controller('BackupsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', function ($scope, $location, $rootScope, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.SECRET_PLACEHOLDER = SECRET_PLACEHOLDER;
$scope.MIN_MEMORY_LIMIT = 800 * 1024 * 1024;
$scope.MIN_MEMORY_LIMIT = 1024 * 1024 * 1024; // 1 GB
$scope.MAX_MEMORY_LIMIT = $scope.MIN_MEMORY_LIMIT; // set later
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.memory = null; // { memory, swap }
$scope.mountStatus = null; // { state, message }
$scope.manualBackupApps = [];
$scope.backupConfig = {};
$scope.backups = [];
$scope.backupTasks = [];
$scope.cleanupTasks = [];
$scope.s3Regions = REGIONS_S3;
$scope.wasabiRegions = REGIONS_WASABI;
@@ -28,12 +31,13 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.ionosRegions = REGIONS_IONOS;
$scope.upcloudRegions = REGIONS_UPCLOUD;
$scope.vultrRegions = REGIONS_VULTR;
$scope.contaboRegions = REGIONS_CONTABO;
$scope.storageProviders = STORAGE_PROVIDERS.concat([
{ name: 'No-op (Only for testing)', value: 'noop' }
]);
$scope.retentionPolicies = [
$scope.backupRetentions = [
{ name: '2 days', value: { keepWithinSecs: 2 * 24 * 60 * 60 }},
{ name: '1 week', value: { keepWithinSecs: 7 * 24 * 60 * 60 }}, // default
{ name: '1 month', value: { keepWithinSecs: 30 * 24 * 60 * 60 }},
@@ -83,8 +87,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
return prettyDay + ' at ' + prettyHour;
};
$scope.prettyBackupRetentionPolicy = function (retentionPolicy) {
var tmp = $scope.retentionPolicies.find(function (p) { return angular.equals(p.value, retentionPolicy); });
$scope.prettyBackupRetention = function (retention) {
var tmp = $scope.backupRetentions.find(function (p) { return angular.equals(p.value, retention); });
return tmp ? tmp.name : '';
};
@@ -119,11 +123,9 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
message: '',
errorMessage: '',
taskId: '',
taskType: TASK_TYPES.TASK_BACKUP,
checkStatus: function () {
// TODO support both task types TASK_BACKUP and TASK_CLEAN_BACKUPS
Client.getLatestTaskByType($scope.createBackup.taskType, function (error, task) {
init: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_BACKUP, function (error, task) {
if (error) return console.error(error);
if (!task) return;
@@ -143,6 +145,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.createBackup.percent = 100; // indicates that 'result' is valid
$scope.createBackup.errorMessage = data.success ? '' : data.error.message;
getBackupTasks();
return fetchBackups();
}
@@ -158,7 +162,6 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.createBackup.percent = 0;
$scope.createBackup.message = '';
$scope.createBackup.errorMessage = '';
$scope.createBackup.taskType = TASK_TYPES.TASK_BACKUP;
Client.startBackup(function (error, taskId) {
if (error) {
@@ -177,32 +180,14 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
return;
}
$scope.createBackup.taskId = taskId;
$scope.createBackup.updateStatus();
});
},
cleanupBackups: function () {
$('#cleanupBackupsModal').modal('show');
},
startCleanup: function () {
$scope.createBackup.busy = true;
$scope.createBackup.percent = 0;
$scope.createBackup.message = '';
$scope.createBackup.errorMessage = '';
$scope.createBackup.taskType = TASK_TYPES.TASK_CLEAN_BACKUPS;
$('#cleanupBackupsModal').modal('hide');
Client.cleanupBackups(function (error, taskId) {
if (error) console.error(error);
getBackupTasks();
$scope.createBackup.taskId = taskId;
$scope.createBackup.updateStatus();
});
},
stopTask: function () {
Client.stopTask($scope.createBackup.taskId, function (error) {
if (error) {
@@ -214,6 +199,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
$scope.createBackup.busy = false;
getBackupTasks();
return;
}
@@ -221,6 +207,62 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
};
$scope.cleanupBackups = {
busy: false,
taskId: 0,
init: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_CLEAN_BACKUPS, function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.cleanupBackups.taskId = task.id;
$scope.cleanupBackups.updateStatus();
getCleanupTasks();
});
},
updateStatus: function () {
Client.getTask($scope.cleanupBackups.taskId, function (error, data) {
if (error) return window.setTimeout($scope.cleanupBackups.updateStatus, 5000);
if (!data.active) {
$scope.cleanupBackups.busy = false;
getCleanupTasks();
fetchBackups();
return;
}
$scope.cleanupBackups.busy = true;
$scope.cleanupBackups.message = data.message;
window.setTimeout($scope.cleanupBackups.updateStatus, 3000);
});
},
ask: function () {
$('#cleanupBackupsModal').modal('show');
},
start: function () {
$scope.cleanupBackups.busy = true;
$('#cleanupBackupsModal').modal('hide');
Client.cleanupBackups(function (error, taskId) {
if (error) console.error(error);
$scope.cleanupBackups.taskId = taskId;
$scope.cleanupBackups.updateStatus();
getCleanupTasks();
});
}
};
$scope.listBackups = {
};
@@ -229,11 +271,12 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
|| provider === 'exoscale-sos' || provider === 'digitalocean-spaces'
|| provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' || provider === 'cloudflare-r2'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage'
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2';
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage' || provider === 'idrive-e2'
|| provider === 'contabo-objectstorage';
};
$scope.mountlike = function (provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs';
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4' || provider === 'xfs' || provider === 'disk';
};
// https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server#18197341
@@ -261,7 +304,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = $scope.backupConfig[k];
});
var filename = 'cloudron-backup-config-' + (new Date()).toISOString().replace(/:|T/g,'-').replace(/\..*/,'') + '.json';
var filename = 'cloudron-backup-config-' + (new Date()).toISOString().replace(/:|T/g,'-').replace(/\..*/,'') + ' (' + $scope.config.adminFqdn + ')' + '.json';
download(filename, JSON.stringify(tmp, null, 4));
};
@@ -307,68 +350,76 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
};
$scope.configureScheduleAndRetention = {
$scope.backupPolicy = {
busy: false,
error: {},
retentionPolicy: $scope.retentionPolicies[0],
currentPolicy: null,
retention: null,
days: [],
hours: [],
init: function () {
Client.getBackupPolicy(function (error, policy) {
if (error) Client.error(error);
$scope.backupPolicy.currentPolicy = policy;
});
},
show: function () {
$scope.configureScheduleAndRetention.error = {};
$scope.configureScheduleAndRetention.busy = false;
$scope.backupPolicy.error = {};
$scope.backupPolicy.busy = false;
var selectedPolicy = $scope.retentionPolicies.find(function (x) { return angular.equals(x.value, $scope.backupConfig.retentionPolicy); });
if (!selectedPolicy) selectedPolicy = $scope.retentionPolicies[0];
var selectedRetention = $scope.backupRetentions.find(function (x) { return angular.equals(x.value, $scope.backupPolicy.currentPolicy.retention); });
if (!selectedRetention) selectedRetention = $scope.backupRetentions[0];
$scope.configureScheduleAndRetention.retentionPolicy = selectedPolicy.value;
$scope.backupPolicy.retention = selectedRetention.value;
var tmp = $scope.backupConfig.schedulePattern.split(' ');
var tmp = $scope.backupPolicy.currentPolicy.schedule.split(' ');
var hours = tmp[2].split(','), days = tmp[5].split(',');
if (days[0] === '*') {
$scope.configureScheduleAndRetention.days = angular.copy($scope.cronDays, []);
$scope.backupPolicy.days = angular.copy($scope.cronDays, []);
} else {
$scope.configureScheduleAndRetention.days = days.map(function (day) { return $scope.cronDays[parseInt(day, 10)]; });
$scope.backupPolicy.days = days.map(function (day) { return $scope.cronDays[parseInt(day, 10)]; });
}
$scope.configureScheduleAndRetention.hours = hours.map(function (hour) { return $scope.cronHours[parseInt(hour, 10)]; });
$scope.backupPolicy.hours = hours.map(function (hour) { return $scope.cronHours[parseInt(hour, 10)]; });
$('#configureScheduleAndRetentionModal').modal('show');
$('#backupPolicyModal').modal('show');
},
valid: function () {
return $scope.configureScheduleAndRetention.days.length && $scope.configureScheduleAndRetention.hours.length;
return $scope.backupPolicy.days.length && $scope.backupPolicy.hours.length;
},
submit: function () {
if (!$scope.configureScheduleAndRetention.days.length) return;
if (!$scope.configureScheduleAndRetention.hours.length) return;
if (!$scope.backupPolicy.days.length) return;
if (!$scope.backupPolicy.hours.length) return;
$scope.configureScheduleAndRetention.error = {};
$scope.configureScheduleAndRetention.busy = true;
// start with the full backupConfig since the api requires all fields
var backupConfig = $scope.backupConfig;
backupConfig.retentionPolicy = $scope.configureScheduleAndRetention.retentionPolicy;
$scope.backupPolicy.error = {};
$scope.backupPolicy.busy = true;
var daysPattern;
if ($scope.configureScheduleAndRetention.days.length === 7) daysPattern = '*';
else daysPattern = $scope.configureScheduleAndRetention.days.map(function (d) { return d.value; });
if ($scope.backupPolicy.days.length === 7) daysPattern = '*';
else daysPattern = $scope.backupPolicy.days.map(function (d) { return d.value; });
var hoursPattern;
if ($scope.configureScheduleAndRetention.hours.length === 24) hoursPattern = '*';
else hoursPattern = $scope.configureScheduleAndRetention.hours.map(function (d) { return d.value; });
if ($scope.backupPolicy.hours.length === 24) hoursPattern = '*';
else hoursPattern = $scope.backupPolicy.hours.map(function (d) { return d.value; });
backupConfig.schedulePattern ='00 00 ' + hoursPattern + ' * * ' + daysPattern;
var policy = {
retention: $scope.backupPolicy.retention,
schedule: '00 00 ' + hoursPattern + ' * * ' + daysPattern
};
Client.setBackupConfig(backupConfig, function (error) {
$scope.configureScheduleAndRetention.busy = false;
Client.setBackupPolicy(policy, function (error) {
$scope.backupPolicy.busy = false;
if (error) {
if (error.statusCode === 424) {
$scope.configureScheduleAndRetention.error.generic = error.message;
$scope.backupPolicy.error.generic = error.message;
} else if (error.statusCode === 400) {
$scope.configureScheduleAndRetention.error.generic = error.message;
$scope.backupPolicy.error.generic = error.message;
} else {
console.error('Unable to change schedule or retention.', error);
}
@@ -376,13 +427,18 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
return;
}
$('#configureScheduleAndRetentionModal').modal('hide');
$('#backupPolicyModal').modal('hide');
getBackupConfig();
$scope.backupPolicy.init();
});
}
};
$scope.$watch('configureBackup.disk', function (newValue) {
if (!newValue) return;
$scope.configureBackup.mountOptions.diskPath = '/dev/disk/by-uuid/' + newValue.uuid;
});
$scope.configureBackup = {
busy: false,
error: {},
@@ -408,19 +464,20 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
memoryTicks: [],
memoryLimit: $scope.MIN_MEMORY_LIMIT,
uploadPartSizeTicks: [],
uploadPartSize: 50 * 1024 * 1024,
copyConcurrency: '',
downloadConcurrency: '',
syncConcurrency: '', // sort of similar to upload
blockDevices: [],
disk: null,
mountOptions: {
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
seal: false,
seal: true,
user: '',
port: 22,
privateKey: ''
@@ -448,7 +505,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.syncConcurrency = $scope.configureBackup.provider === 's3' ? 20 : 10;
$scope.configureBackup.copyConcurrency = $scope.configureBackup.provider === 's3' ? 500 : 10;
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: false, user: '', port: 22, privateKey: '' };
$scope.configureBackup.disk = null;
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: true, user: '', port: 22, privateKey: '' };
},
show: function () {
@@ -482,23 +540,13 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks;
$scope.configureBackup.chown = $scope.backupConfig.chown;
$scope.configureBackup.memoryLimit = $scope.backupConfig.memoryLimit;
const limits = $scope.backupConfig.limits || {};
$scope.configureBackup.uploadPartSize = $scope.backupConfig.uploadPartSize || ($scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024);
$scope.configureBackup.downloadConcurrency = $scope.backupConfig.downloadConcurrency || ($scope.backupConfig.provider === 's3' ? 30 : 10);
$scope.configureBackup.syncConcurrency = $scope.backupConfig.syncConcurrency || ($scope.backupConfig.provider === 's3' ? 20 : 10);
$scope.configureBackup.copyConcurrency = $scope.backupConfig.copyConcurrency || ($scope.backupConfig.provider === 's3' ? 500 : 10);
var totalMemory = Math.max(($scope.memory.memory + $scope.memory.swap) * 1.5, 2 * 1024 * 1024);
$scope.configureBackup.memoryTicks = [ $scope.MIN_MEMORY_LIMIT ];
for (var i = 1024; i <= totalMemory/1024/1024; i *= 2) {
$scope.configureBackup.memoryTicks.push(i * 1024 * 1024);
}
$scope.configureBackup.uploadPartSizeTicks = [ 5 * 1024 * 1024 ];
for (var j = 32; j <= 1 * 1024; j *= 2) { // 5 GB is max for s3. but let's keep things practical for now. we upload 3 parts in parallel
$scope.configureBackup.uploadPartSizeTicks.push(j * 1024 * 1024);
}
$scope.configureBackup.memoryLimit = limits.memoryLimit ? Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT) : $scope.MIN_MEMORY_LIMIT;
$scope.configureBackup.uploadPartSize = limits.uploadPartSize || ($scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024);
$scope.configureBackup.downloadConcurrency = limits.downloadConcurrency || ($scope.backupConfig.provider === 's3' ? 30 : 10);
$scope.configureBackup.syncConcurrency = limits.syncConcurrency || ($scope.backupConfig.provider === 's3' ? 20 : 10);
$scope.configureBackup.copyConcurrency = limits.copyConcurrency || ($scope.backupConfig.provider === 's3' ? 500 : 10);
var mountOptions = $scope.backupConfig.mountOptions || {};
$scope.configureBackup.mountOptions = {
@@ -513,7 +561,28 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
privateKey: mountOptions.privateKey || ''
};
$('#configureBackupModal').modal('show');
Client.getBlockDevices(function (error, result) {
if (error) return console.error('Failed to list blockdevices:', error);
// only offer non /, /boot or /home disks
result = result.filter(function (d) { return d.mountpoint !== '/' && d.mountpoint !== '/home' && d.mountpoint !== '/boot'; });
// only offer xfs and ext4 disks
result = result.filter(function (d) { return d.type === 'xfs' || d.type === 'ext4'; });
// amend label for UI
result.forEach(function (d) {
d.label = d.path;
// pre-select current if set
if (d.path === $scope.configureBackup.mountOptions.diskPath || ('/dev/disk/by-uuid/' + d.uuid) === $scope.configureBackup.mountOptions.diskPath) {
$scope.configureBackup.disk = d;
}
});
$scope.configureBackup.blockDevices = result;
$('#configureBackupModal').modal('show');
});
},
submit: function () {
@@ -523,10 +592,12 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
var backupConfig = {
provider: $scope.configureBackup.provider,
format: $scope.configureBackup.format,
memoryLimit: $scope.configureBackup.memoryLimit,
// required for api call to provide all fields
schedulePattern: $scope.backupConfig.schedulePattern,
retentionPolicy: $scope.backupConfig.retentionPolicy
retentionPolicy: $scope.backupConfig.retentionPolicy,
limits: {
memoryLimit: parseInt($scope.configureBackup.memoryLimit),
},
};
if ($scope.configureBackup.password) {
backupConfig.password = $scope.configureBackup.password;
@@ -570,6 +641,10 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
} else if (backupConfig.provider === 'vultr-objectstorage') {
backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'contabo-objectstorage') {
backupConfig.region = $scope.contaboRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
backupConfig.s3ForcePathStyle = true; // https://docs.contabo.com/docs/products/Object-Storage/technical-description (no virtual buckets)
} else if (backupConfig.provider === 'upcloud-objectstorage') { // the UI sets region and endpoint
var m = /^.*\.(.*)\.upcloudobjects.com$/.exec(backupConfig.endpoint);
backupConfig.region = m ? m[1] : 'us-east-1'; // let it fail in validation phase if m is not valid
@@ -577,6 +652,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
} else if (backupConfig.provider === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
}
backupConfig.limits.uploadPartSize = parseInt($scope.configureBackup.uploadPartSize);
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.configureBackup.bucket;
backupConfig.prefix = $scope.configureBackup.prefix;
@@ -615,7 +692,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
backupConfig.mountOptions.port = $scope.configureBackup.mountOptions.port;
backupConfig.mountOptions.privateKey = $scope.configureBackup.mountOptions.privateKey;
}
} else if (backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs') {
} else if (backupConfig.provider === 'ext4' || backupConfig.provider === 'xfs' || backupConfig.provider === 'disk') {
backupConfig.mountOptions.diskPath = $scope.configureBackup.mountOptions.diskPath;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountPoint = $scope.configureBackup.mountPoint;
@@ -627,12 +704,10 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks;
}
backupConfig.uploadPartSize = $scope.configureBackup.uploadPartSize;
if (backupConfig.format === 'rsync') {
backupConfig.downloadConcurrency = $scope.configureBackup.downloadConcurrency;
backupConfig.syncConcurrency = $scope.configureBackup.syncConcurrency;
backupConfig.copyConcurrency = $scope.configureBackup.copyConcurrency;
backupConfig.limits.downloadConcurrency = parseInt($scope.configureBackup.downloadConcurrency);
backupConfig.limits.syncConcurrency = parseInt($scope.configureBackup.syncConcurrency);
backupConfig.limits.copyConcurrency = parseInt($scope.configureBackup.copyConcurrency);
}
Client.setBackupConfig(backupConfig, function (error) {
@@ -680,7 +755,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.error.backupFolder = true;
}
} else {
console.error('Unable to change provider.', error);
$scope.configureBackup.error.generic = error.message;
}
return;
@@ -708,14 +783,23 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
});
$scope.backups.forEach(function (backup) {
backup.contents = [];
backup.contents = []; // { id, label, fqdn }
backup.dependsOn.forEach(function (appBackupId) {
let match = appBackupId.match(/app_(.*?)_.*/); // *? means non-greedy
if (!match) return;
if (match[1].indexOf('.') !== -1) { // newer backups have fqdn in them
if (appsByFqdn[match[1]]) backup.contents.push(appsByFqdn[match[1]]);
if (!match) return; // for example, 'mail'
const app = appsById[match[1]];
if (app) {
backup.contents.push({
id: app.id,
label: app.label,
fqdn: app.fqdn
});
} else {
if (appsById[match[1]]) backup.contents.push(appsById[match[1]]);
backup.contents.push({
id: match[1],
label: null,
fqdn: null
});
}
});
});
@@ -727,6 +811,35 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if (error) return console.error(error);
$scope.backupConfig = backupConfig;
$scope.mountStatus = null;
if (!$scope.mountlike($scope.backupConfig.provider)) return;
Client.getBackupMountStatus(function (error, mountStatus) {
if (error) return console.error(error);
$scope.mountStatus = mountStatus;
});
});
}
function getBackupTasks() {
Client.getTasksByType(TASK_TYPES.TASK_BACKUP, function (error, tasks) {
if (error) return console.error(error);
if (!tasks.length) return;
$scope.backupTasks = tasks.slice(0, 10);
});
}
function getCleanupTasks() {
Client.getTasksByType(TASK_TYPES.TASK_CLEAN_BACKUPS, function (error, tasks) {
if (error) return console.error(error);
if (!tasks.length) return;
$scope.cleanupTasks = tasks.slice(0, 10);
});
}
@@ -735,6 +848,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if (error) console.error(error);
$scope.memory = memory;
var nearestGb = Math.ceil($scope.memory.memory / (1024*1024*1024)) * 1024 * 1024 * 1024;
$scope.MAX_MEMORY_LIMIT = nearestGb;
fetchBackups();
getBackupConfig();
@@ -742,7 +857,12 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.manualBackupApps = Client.getInstalledApps().filter(function (app) { return app.type !== APP_TYPES.LINK && !app.enableBackup; });
// show backup status
$scope.createBackup.checkStatus();
$scope.createBackup.init();
$scope.cleanupBackups.init();
$scope.backupPolicy.init();
getBackupTasks();
getCleanupTasks();
});
});
@@ -767,7 +887,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
// setup all the dialog focus handling
['configureBackupModal', 'editBackupModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
$(this).find('[autofocus]:first').focus();
});
});
+34 -22
View File
@@ -1,27 +1,27 @@
<!-- Modal change avatar -->
<div class="modal fade" id="avatarChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'branding.changeLogo.title' | tr }}</h4>
</div>
<div class="modal-body branding-avatar-selector">
<img id="previewAvatar" width="128" height="128" ng-src="{{ avatarChange.avatarUrl() }}"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/*"/>
<br/>
<br/>
<div class="grid">
<div class="item" ng-repeat="avatar in avatarChange.availableAvatars" style="background-image: url('{{avatar.data || avatar.url}}');" ng-click="avatarChange.setPreviewAvatar(avatar)"></div>
<div class="item add" ng-click="avatarChange.showCustomAvatarSelector()"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.setAvatar()"> {{ 'main.dialog.save' | tr }}</button>
</div>
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'branding.changeLogo.title' | tr }}</h4>
</div>
<div class="modal-body branding-avatar-selector">
<img id="previewAvatar" width="128" height="128" ng-src="{{ avatarChange.avatarUrl() }}"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/*"/>
<br/>
<br/>
<div class="grid">
<div class="item" ng-repeat="avatar in avatarChange.availableAvatars" style="background-image: url('{{avatar.data || avatar.url}}');" ng-click="avatarChange.setPreviewAvatar(avatar)"></div>
<div class="item add" ng-click="avatarChange.showCustomAvatarSelector()"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.setAvatar()"> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
@@ -49,7 +49,19 @@
</div>
<div class="branding-avatar" ng-click="avatarChange.showChangeAvatar()">
<img ng-src="{{ about.avatarUrl() }}"/>
<div class="overlay"></div>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
</div>
<div class="form-group">
<div>
<label class="control-label">{{ 'branding.backgroundImage' | tr }}</label>
<div class="branding-background" ng-click="background.selectNew()">
<img ng-src="{{ background.url() }}" onerror="this.src = '/img/background-image-placeholder.svg'"/>
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
<a href="" ng-show="!background.cleared" ng-click="background.clear()">{{ 'branding.clearBackgroundImage' | tr }}</a>
<input type="file" id="backgroundFileInput" style="display: none" accept="image/*"/>
</div>
</div>
@@ -61,7 +73,7 @@
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="about.submit()" ng-disabled="(!about.avatar && !aboutForm.$dirty) || aboutForm.$invalid || about.busy"><i class="fa fa-circle-notch fa-spin" ng-show="about.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="about.submit()" ng-disabled="false && (!about.avatar && !aboutForm.$dirty) || aboutForm.$invalid || about.busy"><i class="fa fa-circle-notch fa-spin" ng-show="about.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
+99 -9
View File
@@ -4,7 +4,7 @@
/* global $:false */
angular.module('Application').controller('BrandingController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (Client.getUserInfo().role !== 'owner') $location.path('/'); });
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
@@ -134,6 +134,76 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
fr.readAsDataURL(event.target.files[0]);
};
$scope.background = {
enabled: false,
file: null,
src: null,
cleared: false,
newImageFile: null,
cacheBusting: Date.now(),
url() {
if ($scope.background.cleared) return '/img/background-image-placeholder.svg';
else if ($scope.background.src) return $scope.background.src;
else return `${Client.apiOrigin}/api/v1/cloudron/background?${$scope.background.cacheBusting}`;
},
selectNew() {
document.getElementById('backgroundFileInput').click();
},
submit(callback) {
if ($scope.background.cleared) {
Client.changeCloudronBackground(null, callback);
} else if ($scope.background.newImageFile) {
Client.changeCloudronBackground($scope.background.newImageFile, callback);
} else {
callback();
}
},
clear() {
$scope.background.cleared = true;
}
};
document.getElementById('backgroundFileInput').onchange = function (event) {
const fr = new FileReader();
fr.onload = function () {
const image = new Image();
image.onload = function () {
// convert and scale to webp max 4k
const maxWidth = 4096;
const canvas = document.createElement('canvas');
if (image.naturalWidth > maxWidth) {
canvas.width = maxWidth;
canvas.height = (image.naturalHeight / image.naturalWidth) * maxWidth;
} else {
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
}
canvas.getContext('2d').drawImage(image, 0, 0, canvas.width, canvas.height);
canvas.toBlob((blob) => {
$scope.$apply(function () {
const myImage = new File([blob], 'background.webp', { type: blob.type });
$scope.background.cleared = false;
$scope.background.newImageFile = myImage;
$scope.background.src = URL.createObjectURL(myImage);
});
}, 'image/webp');
$scope.background.file = event.target.files[0];
};
image.src = fr.result;
};
fr.readAsDataURL(event.target.files[0]);
};
$scope.about = {
busy: false,
error: {},
@@ -141,7 +211,7 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
avatar: null,
avatarBlob: null,
avatarUrl: function () {
avatarUrl() {
if ($scope.about.avatar) {
return $scope.about.avatar.data || $scope.about.avatar.url;
} else {
@@ -149,12 +219,22 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
}
},
refresh: function () {
refresh() {
$scope.about.cloudronName = $scope.config.cloudronName;
$scope.about.avatar = null;
Client.hasCloudronBackground(function (error, result) {
if (error) return console.error('Failed to get background state.', error);
$scope.background.enabled = result;
$scope.background.file = null;
$scope.background.src = null;
$scope.background.newImageFile = null;
$scope.background.cacheBusting = Date.now();
});
},
submit: function () {
submit() {
$scope.about.error.name = null;
$scope.about.busy = true;
@@ -182,12 +262,22 @@ angular.module('Application').controller('BrandingController', ['$scope', '$loca
return;
}
Client.refreshConfig(function () {
if ($scope.about.avatar) Client.resetAvatar();
$scope.background.submit(function (error) {
if (error) {
$scope.about.busy = false;
console.error('Unable to change background.', error);
return;
}
$scope.aboutForm.$setPristine();
$scope.about.avatar = null;
$scope.about.busy = false;
Client.refreshConfig(function () {
if ($scope.about.avatar) Client.resetAvatar();
$scope.aboutForm.$setPristine();
$scope.about.avatar = null;
$scope.about.refresh();
$scope.about.busy = false;
});
});
});
});
+94 -24
View File
@@ -98,6 +98,25 @@
<input type="text" class="form-control" ng-model="domainConfigure.netcupApiPassword" name="netcupApiPassword" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'netcup'">
</div>
<!-- OVH -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label" for="inputConfigureOvhEndpoint">{{ 'domains.domainDialog.ovhEndpoint' | tr }}</label>
<select class="form-control" name="endpoint" id="inputConfigureOvhEndpoint" ng-model="domainConfigure.ovhEndpoint" ng-options="a.value as a.name for a in ovhEndpoints" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'ovh'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label">{{ 'domains.domainDialog.ovhConsumerKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.ovhConsumerKey" name="ovhConsumerKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'ovh'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label">{{ 'domains.domainDialog.ovhAppKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.ovhAppKey" name="ovhAppKey" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'ovh'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'ovh'">
<label class="control-label">{{ 'domains.domainDialog.ovhAppSecret' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.ovhAppSecret" name="ovhAppSecret" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'ovh'">
</div>
<!-- Porkbun -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'porkbun'">
<label class="control-label">{{ 'domains.domainDialog.porkbunApikey' | tr }}</label>
@@ -109,6 +128,14 @@
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare'">
<label class="control-label">{{ 'domains.domainDialog.cloudflareTokenType' | tr }}</label>
<select class="form-control" ng-model="domainConfigure.cloudflareTokenType">
<option value="GlobalApiKey">{{ 'domains.domainDialog.cloudflareTokenTypeGlobalApiKey' | tr }}</option>
<option value="ApiToken">{{ 'domains.domainDialog.cloudflareTokenTypeApiToken' | tr }}</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare'">
<label class="control-label" ng-show="domainConfigure.cloudflareTokenType === 'GlobalApiKey'">{{ 'domains.domainDialog.cloudflareTokenTypeGlobalApiKey' | tr }}</label>
<label class="control-label" ng-show="domainConfigure.cloudflareTokenType === 'ApiToken'">{{ 'domains.domainDialog.cloudflareTokenTypeApiToken' | tr }}</label>
@@ -126,14 +153,6 @@
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare'">
<label class="control-label">{{ 'domains.domainDialog.cloudflareTokenType' | tr }}</label>
<select class="form-control" ng-model="domainConfigure.cloudflareTokenType">
<option value="GlobalApiKey">{{ 'domains.domainDialog.cloudflareTokenTypeGlobalApiKey' | tr }}</option>
<option value="ApiToken">{{ 'domains.domainDialog.cloudflareTokenTypeApiToken' | tr }}</option>
</select>
</div>
<!-- Linode -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'linode'">
<label class="control-label">{{ 'domains.domainDialog.linodeToken' | tr }}</label>
@@ -146,6 +165,12 @@
<input type="text" class="form-control" ng-model="domainConfigure.bunnyAccessKey" name="bunnyAccessKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'bunny'">
</div>
<!-- dnsimple -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'dnsimple'">
<label class="control-label">{{ 'domains.domainDialog.dnsimpleAccessToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.dnsimpleAccessToken" name="dnsimpleAccessToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'dnsimple'">
</div>
<!-- Hetzner -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'hetzner'">
<label class="control-label">{{ 'domains.domainDialog.hetznerToken' | tr }}</label>
@@ -158,6 +183,12 @@
<input type="text" class="form-control" ng-model="domainConfigure.vultrToken" name="vultrToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'vultr'">
</div>
<!-- deSEC -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'desec'">
<label class="control-label">{{ 'domains.domainDialog.deSecToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.deSecToken" name="deSecToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'desec'">
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecom'">
<label class="control-label">{{ 'domains.domainDialog.nameComUsername' | tr }}</label>
@@ -331,9 +362,9 @@
{{ prettyProviderName(domain) }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="domainWellKnown.show(domain)" title="{{ 'domains.tooltipWellKnown' | tr }}"><i class="fa fa-atlas"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainConfigure.show(domain)" title="{{ 'domains.tooltipEdit' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="domainRemove.show(domain)" title="{{ 'domains.tooltipRemove' | tr }}" ng-disabled="domain.domain === config.adminDomain"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainWellKnown.show(domain)" uib-tooltip="{{ 'domains.tooltipWellKnown' | tr }}"><i class="fa fa-atlas"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainConfigure.show(domain)" uib-tooltip="{{ 'domains.tooltipEdit' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="domainRemove.show(domain)" uib-tooltip="{{ 'domains.tooltipRemove' | tr }}" ng-disabled="domain.domain === config.adminDomain"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
@@ -350,8 +381,22 @@
</div>
</div>
<div class="text-left">
<h3>{{ 'domains.renewCerts.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'domains.renewCerts.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="renewCerts.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in renewCerts.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card">
@@ -375,14 +420,27 @@
<p ng-hide="renewCerts.busy">
<div class="has-error" ng-show="!renewCerts.active">{{ renewCerts.errorMessage }}</div>
</p>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{renewCerts.taskId}}" ng-disabled="!renewCerts.taskId" target="_blank">{{ 'domains.renewCerts.showLogsAction' | tr }}</a>
<button class="btn btn-outline btn-primary pull-right" ng-click="renewCerts.renew()" ng-disabled="renewCerts.busy" style="margin-right: 10px">{{ 'domains.renewCerts.renewAllAction' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="renewCerts.renew()" ng-disabled="renewCerts.busy">{{ 'domains.renewCerts.renewAllAction' | tr }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'domains.syncDns.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'domains.syncDns.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="syncDns.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in syncDns.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card">
@@ -406,14 +464,27 @@
<p ng-hide="syncDns.busy">
<div class="has-error" ng-show="!syncDns.active">{{ syncDns.errorMessage }}</div>
</p>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{syncDns.taskId}}" ng-disabled="!syncDns.taskId" target="_blank">{{ 'domains.syncDns.showLogsAction' | tr }}</a>
<button class="btn btn-outline btn-primary pull-right" ng-click="syncDns.sync()" ng-disabled="syncDns.busy" style="margin-right: 10px">{{ 'domains.syncDns.syncAction' | tr }}</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="syncDns.sync()" ng-disabled="syncDns.busy">{{ 'domains.syncDns.syncAction' | tr }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'domains.changeDashboardDomain.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'domains.changeDashboardDomain.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="changeDashboard.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in changeDashboard.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card">
@@ -438,16 +509,15 @@
</div>
<div class="row">
<div class="col-md-6">
<div class="col-md-8">
<p ng-show="changeDashboard.busy">{{ changeDashboard.message }}</p>
<p ng-hide="changeDashboard.busy">
<div class="has-error" ng-show="!changeDashboard.active">{{ changeDashboard.errorMessage }}</div>
</p>
</div>
<div class="col-md-6 text-right">
<div class="col-md-4 text-right">
<button class="btn btn-outline btn-primary" ng-click="changeDashboard.change()" ng-hide="changeDashboard.busy" ng-disabled="changeDashboard.selectedDomain.domain === changeDashboard.adminDomain.domain">{{ 'domains.changeDashboardDomain.changeAction' | tr }}</button>
<button class="btn btn-outline btn-danger" ng-click="changeDashboard.stop()" ng-show="changeDashboard.busy" style="margin-right: 10px">{{ 'domains.changeDashboardDomain.cancelAction' | tr }}</button>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{changeDashboard.taskId}}" ng-show="changeDashboard.busy" target="_blank">{{ 'domains.changeDashboardDomain.showLogsAction' | tr }}</a>
</div>
</div>
</div>
+75 -44
View File
@@ -2,7 +2,7 @@
/* global async */
/* global angular */
/* global $, TASK_TYPES */
/* global $, TASK_TYPES, ENDPOINTS_OVH */
angular.module('Application').controller('DomainsController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -11,7 +11,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domains = [];
$scope.ready = false;
$scope.domainSearchString = '';
$scope.pageSize = 10;
$scope.pageSize = localStorage.cloudronPageSize || 10;
$scope.currentPage = 1;
$scope.showNextPage = function () {
@@ -41,12 +41,14 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
{ name: 'Custom Wildcard Certificate', value: 'fallback' },
];
// keep in sync with setupdns.js
// keep in sync with setup.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Bunny', value: 'bunny' },
{ name: 'Cloudflare', value: 'cloudflare' },
{ name: 'deSEC', value: 'desec' },
{ name: 'DigitalOcean', value: 'digitalocean' },
{ name: 'DNSimple', value: 'dnsimple' },
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
@@ -55,6 +57,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'OVH', value: 'ovh' },
{ name: 'Porkbun', value: 'porkbun' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
@@ -67,13 +70,16 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
case 'bunny': return 'Bunny';
case 'route53': return 'AWS Route53';
case 'cloudflare': return 'Cloudflare';
case 'desec': return 'deSEC';
case 'digitalocean': return 'DigitalOcean';
case 'dnsimple': return 'dnsimple';
case 'gandi': return 'Gandi LiveDNS';
case 'hetzner': return 'Hetzner DNS';
case 'linode': return 'Linode';
case 'namecom': return 'Name.com';
case 'namecheap': return 'Namecheap';
case 'netcup': return 'Netcup';
case 'ovh': return 'OVH';
case 'gcdns': return 'Google Cloud';
case 'godaddy': return 'GoDaddy';
case 'vultr': return 'Vultr';
@@ -85,6 +91,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
}
};
$scope.ovhEndpoints = ENDPOINTS_OVH;
$scope.needsPort80 = function (dnsProvider, tlsProvider) {
return ((dnsProvider === 'manual' || dnsProvider === 'noop' || dnsProvider === 'wildcard') &&
(tlsProvider === 'letsencrypt-prod' || tlsProvider === 'letsencrypt-staging'));
@@ -249,8 +257,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
cloudflareTokenType: 'GlobalApiKey',
linodeToken: '',
bunnyAccessKey: '',
dnsimpleAccessToken: '',
hetznerToken: '',
vultrToken: '',
deSecToken: '',
nameComToken: '',
nameComUsername: '',
namecheapUsername: '',
@@ -258,6 +268,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
ovhEndpoint: 'ovh-eu',
ovhConsumerKey: '',
ovhAppKey: '',
ovhAppSecret: '',
porkbunSecretapikey: '',
porkbunApikey: '',
@@ -307,8 +321,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.digitalOceanToken = domain.provider === 'digitalocean' ? domain.config.token : '';
$scope.domainConfigure.linodeToken = domain.provider === 'linode' ? domain.config.token : '';
$scope.domainConfigure.bunnyAccessKey = domain.provider === 'bunny' ? domain.config.accessKey : '';
$scope.domainConfigure.dnsimpleAccessToken = domain.provider === 'dnsimple' ? domain.config.accessToken : '';
$scope.domainConfigure.hetznerToken = domain.provider === 'hetzner' ? domain.config.token : '';
$scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? domain.config.token : '';
$scope.domainConfigure.deSecToken = domain.provider === 'desec' ? domain.config.token : '';
$scope.domainConfigure.gandiApiKey = domain.provider === 'gandi' ? domain.config.token : '';
$scope.domainConfigure.cloudflareToken = domain.provider === 'cloudflare' ? domain.config.token : '';
$scope.domainConfigure.cloudflareEmail = domain.provider === 'cloudflare' ? domain.config.email : '';
@@ -328,6 +344,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.netcupApiKey = domain.provider === 'netcup' ? domain.config.apiKey : '';
$scope.domainConfigure.netcupApiPassword = domain.provider === 'netcup' ? domain.config.apiPassword : '';
$scope.domainConfigure.ovhEndpoint = domain.provider === 'ovh' ? domain.config.endpoint : '';
$scope.domainConfigure.ovhConsumerKey = domain.provider === 'ovh' ? domain.config.consumerKey : '';
$scope.domainConfigure.ovhAppKey = domain.provider === 'ovh' ? domain.config.appKey : '';
$scope.domainConfigure.ovhAppSecret = domain.provider === 'ovh' ? domain.config.appSecret : '';
$scope.domainConfigure.porkbunApikey = domain.provider === 'porkbun' ? domain.config.apikey : '';
$scope.domainConfigure.porkbunSecretapikey = domain.provider === 'porkbun' ? domain.config.secretapikey : '';
@@ -379,10 +400,14 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
data.token = $scope.domainConfigure.linodeToken;
} else if (provider === 'bunny') {
data.accessKey = $scope.domainConfigure.bunnyAccessKey;
} else if (provider === 'dnsimple') {
data.accessToken = $scope.domainConfigure.dnsimpleAccessToken;
} else if (provider === 'hetzner') {
data.token = $scope.domainConfigure.hetznerToken;
} else if (provider === 'vultr') {
data.token = $scope.domainConfigure.vultrToken;
} else if (provider === 'desec') {
data.token = $scope.domainConfigure.deSecToken;
} else if (provider === 'gandi') {
data.token = $scope.domainConfigure.gandiApiKey;
} else if (provider === 'godaddy') {
@@ -403,6 +428,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
data.customerNumber = $scope.domainConfigure.netcupCustomerNumber;
data.apiKey = $scope.domainConfigure.netcupApiKey;
data.apiPassword = $scope.domainConfigure.netcupApiPassword;
} else if (provider === 'ovh') {
data.endpoint = $scope.domainConfigure.ovhEndpoint;
data.consumerKey = $scope.domainConfigure.ovhConsumerKey;
data.appKey = $scope.domainConfigure.ovhAppKey;
data.appSecret = $scope.domainConfigure.ovhAppSecret;
} else if (provider === 'porkbun') {
data.apikey = $scope.domainConfigure.porkbunApikey;
data.secretapikey = $scope.domainConfigure.porkbunSecretapikey;
@@ -472,9 +502,14 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.netcupCustomerNumber = '';
$scope.domainConfigure.netcupApiKey = '';
$scope.domainConfigure.netcupApiPassword = '';
$scope.domainConfigure.ovhEndpoint = '';
$scope.domainConfigure.ovhConsumerKey = '';
$scope.domainConfigure.ovhAppKey = '';
$scope.domainConfigure.ovhAppSecret = '';
$scope.domainConfigure.porkbunApikey = '';
$scope.domainConfigure.porkbunSecretapikey = '';
$scope.domainConfigure.vultrToken = '';
$scope.domainConfigure.deSecToken = '';
$scope.domainConfigure.tlsConfig.provider = 'letsencrypt-prod';
$scope.domainConfigure.zoneName = '';
@@ -489,21 +524,20 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
percent: 0,
message: '',
errorMessage: '',
taskId: '',
tasks: [],
checkStatus: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_CHECK_CERTS, function (error, task) {
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_CHECK_CERTS, function (error, tasks) {
if (error) return console.error(error);
if (!task) return;
$scope.renewCerts.taskId = task.id;
$scope.renewCerts.updateStatus();
$scope.renewCerts.tasks = tasks.slice(0, 10);
if ($scope.renewCerts.tasks.length && $scope.renewCerts.tasks[0].active) $scope.renewCerts.updateStatus();
});
},
updateStatus: function () {
Client.getTask($scope.renewCerts.taskId, function (error, data) {
var taskId = $scope.renewCerts.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.renewCerts.updateStatus, 5000);
if (!data.active) {
@@ -512,6 +546,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.renewCerts.percent = 100; // indicates that 'result' is valid
$scope.renewCerts.errorMessage = data.success ? '' : data.error.message;
$scope.renewCerts.refreshTasks(); // update the tasks list dropdown
return;
}
@@ -529,15 +565,13 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.renewCerts.errorMessage = '';
// always rebuild the nginx configs when triggered via the UI. we assume user is clicking this because something is wrong
Client.renewCerts({ rebuild: true }, function (error, taskId) {
Client.renewCerts({ rebuild: true }, function (error /*, taskId */) {
if (error) {
console.error(error);
$scope.renewCerts.errorMessage = error.message;
$scope.renewCerts.busy = false;
} else {
$scope.renewCerts.taskId = taskId;
$scope.renewCerts.updateStatus();
$scope.renewCerts.refreshTasks();
}
});
}
@@ -548,21 +582,19 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
percent: 0,
message: '',
errorMessage: '',
taskId: '',
tasks: [],
checkStatus: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_SYNC_DNS_RECORDS, function (error, task) {
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_SYNC_DNS_RECORDS, function (error, tasks) {
if (error) return console.error(error);
if (!task) return;
$scope.syncDns.taskId = task.id;
$scope.syncDns.updateStatus();
$scope.syncDns.tasks = tasks.slice(0, 10);
if ($scope.syncDns.tasks.length && $scope.syncDns.tasks[0].active) $scope.syncDns.updateStatus();
});
},
updateStatus: function () {
Client.getTask($scope.syncDns.taskId, function (error, data) {
var taskId = $scope.syncDns.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.syncDns.updateStatus, 5000);
if (!data.active) {
@@ -571,6 +603,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.syncDns.percent = 100; // indicates that 'result' is valid
$scope.syncDns.errorMessage = data.success ? '' : data.error.message;
$scope.syncDns.refreshTasks(); // update the tasks list dropdown
return;
}
@@ -587,15 +621,13 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.syncDns.message = '';
$scope.syncDns.errorMessage = '';
Client.setDnsRecords({}, function (error, taskId) {
Client.setDnsRecords({}, function (error /*, taskId */) {
if (error) {
console.error(error);
$scope.syncDns.errorMessage = error.message;
$scope.syncDns.busy = false;
} else {
$scope.syncDns.taskId = taskId;
$scope.syncDns.updateStatus();
$scope.syncDns.refreshTasks();
}
});
}
@@ -649,24 +681,21 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
taskId: '',
selectedDomain: null,
adminDomain: null,
tasks: [],
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_PREPARE_DASHBOARD_LOCATION, function (error, tasks) {
if (error) return console.error(error);
$scope.changeDashboard.tasks = tasks.slice(0, 10);
if ($scope.changeDashboard.tasks.length && $scope.changeDashboard.tasks[0].active) $scope.changeDashboard.updateStatus();
});
},
stop: function () {
Client.stopTask($scope.changeDashboard.taskId, function (error) {
if (error) console.error(error);
$scope.changeDashboard.busy = false;
});
},
// this function is not called intentionally. currently, we do switching in two steps - prepare and set
// if the user refreshed the UI in the middle of prepare, then it would be awkward to resume/call 'set' when the
// user visits the UI the next time around.
checkStatus: function () {
Client.getLatestTaskByType('prepareDashboardDomain', function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.changeDashboard.taskId = task.id;
$scope.changeDashboard.updateStatus();
$scope.changeDashboard.refreshTasks();
});
},
@@ -721,7 +750,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.changeDashboard.busy = false;
} else {
$scope.changeDashboard.taskId = taskId;
$scope.changeDashboard.updateStatus();
$scope.changeDashboard.refreshTasks();
}
});
}
@@ -734,7 +763,9 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.ready = true;
});
$scope.renewCerts.checkStatus();
$scope.renewCerts.refreshTasks();
$scope.syncDns.refreshTasks();
$scope.changeDashboard.refreshTasks();
});
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.domainConfigure.gcdnsKey, 'content', 'keyFileName');
+6 -5
View File
@@ -157,9 +157,10 @@
{{ 'email.editMailboxDialog.enableStorageQuota' | tr }} <b ng-hide="!mailboxes.edit.storageQuotaEnabled">: {{ mailboxes.edit.storageQuota | prettyDecimalSize }}</b>
</input>
</label>
<div style="padding: 0 10px;">
<slider id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" ticks-snap-bounds="1000000000" tooltip="hide" ticks="storageQuotaTicks"></slider>
</div>
<input type="range" id="storageQuota" ng-disabled="!mailboxes.edit.storageQuotaEnabled" ng-model="mailboxes.edit.storageQuota" step="500000000" min="{{ storageQuotaTicks[0] }}" max="{{ storageQuotaTicks[storageQuotaTicks.length-1] }}" list="storageQuotaTicks" />
<datalist id="storageQuotaTicks">
<option ng-repeat="quota in storageQuotaTicks" value="{{ quota }}"></option>
</datalist>
</div>
<div class="checkbox">
@@ -385,7 +386,7 @@
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="https://docs.cloudron.io/email/" target="_blank">{{ 'app.docsAction' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="howToConnectInfo.show()">{{ 'email.config.clientConfiguration' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="domain.mailConfig.enabled ? howToConnectInfo.show() : null">{{ 'email.config.clientConfiguration' | tr }}</a></li>
</ul>
</div>
</h3>
@@ -725,7 +726,7 @@
<div id="collapse_dns_{{ record.value }}" class="panel-collapse collapse">
<div class="panel-body">
<p ng-show="record.name === 'MX' && domain.provider === 'namecheap'">{{ 'email.dnsStatus.namecheapInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="record.name === 'PTR'">{{ 'email.dnsStatus.ptrInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/troubleshooting/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="record.name === 'PTR'">{{ 'email.dnsStatus.ptrInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.hostname' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].name }}</tt></b></p>
<p ng-hide="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.domain' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].domain }}</tt></b></p>
<p>{{ 'email.dnsStatus.type' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].type }}</tt></b></p>
+9 -3
View File
@@ -42,7 +42,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$scope.domain = null;
$scope.adminDomain = null;
$scope.mailUsage = null;
$scope.storageQuotaTicks = [ 500*1000*1000, 1*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
$scope.storageQuotaTicks = [ 500*1000*1000, 5*1000*1000*1000, 15*1000*1000*1000, 50*1000*1000*1000, 100*1000*1000*1000 ];
$scope.expectedDnsRecords = {
mx: { },
@@ -64,6 +64,12 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
Client.openSubscriptionSetup($scope.$parent.subscription);
};
function updateMailUsage(mailboxName, quotaLimit) {
if (!$scope.mailUsage) $scope.mailUsage = {};
if (!$scope.mailUsage[mailboxName]) $scope.mailUsage[mailboxName] = {};
$scope.mailUsage[mailboxName].quotaLimit = quotaLimit;
}
function refreshMailUsage() {
Client.getMailUsage($scope.domain.domain, function (error, usage) {
if (error) console.error(error);
@@ -633,7 +639,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
ownerType: $scope.mailboxes.edit.owner.type,
active: $scope.mailboxes.edit.active,
enablePop3: $scope.mailboxes.edit.enablePop3,
storageQuota: $scope.mailboxes.edit.storageQuotaEnabled ? $scope.mailboxes.edit.storageQuota : 0,
storageQuota: $scope.mailboxes.edit.storageQuotaEnabled ? parseInt($scope.mailboxes.edit.storageQuota) : 0,
messagesQuota: 0
};
@@ -646,7 +652,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
function done() {
$scope.mailUsage[$scope.mailboxes.edit.name + '@' + $scope.domain.domain].quotaLimit = $scope.mailboxes.edit.storageQuotaEnabled ? $scope.mailboxes.edit.storageQuota : 0; // hack to avoid refresh
updateMailUsage($scope.mailboxes.edit.name + '@' + $scope.domain.domain, $scope.mailboxes.edit.storageQuotaEnabled ? $scope.mailboxes.edit.storageQuota : 0); // hack to avoid refresh
$scope.mailboxes.edit.busy = false;
$scope.mailboxes.edit.error = null;
+1 -1
View File
@@ -7,7 +7,7 @@
<h1>
{{ 'emails.eventlog.title' | tr }}
<a class="btn btn-default btn-outline pull-right" href="/logs.html?id=mail" target="_blank">{{ 'main.action.logs' | tr }}</a>
<a class="btn btn-default btn-outline pull-right" href="/frontend/logs.html?id=mail" target="_blank">{{ 'main.action.logs' | tr }}</a>
<a class="btn btn-default btn-outline pull-right" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
</h1>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
/* global angular */
angular.module('Application').controller('EmailsEventlogController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();
+1 -1
View File
@@ -7,7 +7,7 @@
<h1>
{{ 'emails.queue.title' | tr }}
<a class="btn btn-default btn-outline pull-right" href="/logs.html?id=mail" target="_blank">{{ 'main.action.logs' | tr }}</a>
<a class="btn btn-default btn-outline pull-right" href="/frontend/logs.html?id=mail" target="_blank">{{ 'main.action.logs' | tr }}</a>
</h1>
</div>
</div>
+1 -1
View File
@@ -4,7 +4,7 @@
/* global angular */
angular.module('Application').controller('EmailsQueueController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();
+107 -102
View File
@@ -1,50 +1,3 @@
<!-- Modal change mail server domain -->
<div class="modal fade" id="mailLocationModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'emails.changeDomainDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-bind-html=" 'emails.changeDomainDialog.description' | tr "></div>
<br>
<form name="mailLocationForm" role="form" novalidate ng-submit="mailLocation.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (mailLocationForm.subdomain.$dirty && mailLocationForm.subdomain.$invalid) || (!mailLocationForm.subdomain.$dirty && mailLocation.error)}">
<label class="control-label">{{ 'emails.changeDomainDialog.location' | tr }}</label>
<div class="has-error" ng-show="mailLocation.error">{{ mailLocation.error.message }}</div>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="mailLocation.subdomain" id="mailLocationLocationInput" name="location" placeholder="{{ 'emails.changeDomainDialog.locationPlaceholder' | tr }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>{{ (!mailLocation.subdomain ? '' : '.') + mailLocation.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="domain in domains">
<a href="" ng-click="mailLocation.domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
<p class="text-center text-warning text-bold" ng-show="mailLocation.domain.provider === 'manual'" ng-bind-html="'emails.changeDomainDialog.manualInfo' | tr:{ domain: mailLocation.domain.domain }"></p>
<input class="ng-hide" type="submit" ng-disabled="mailLocationForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="mailLocation.submit()" ng-disabled="mailLocationForm.$invalid || mailLocation.busy"><i class="fa fa-circle-notch fa-spin" ng-show="mailLocation.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal change max email size -->
<div class="modal fade" id="maxEmailSizeChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -57,8 +10,8 @@
<br>
<form name="maxEmailSizeChangeForm" role="form" novalidate ng-submit="maxEmailSize.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label">{{ 'emails.changeMailSizeDialog.size' | tr }} <b>{{ maxEmailSize.size | prettyDecimalSize }}</b></label>
<slider ng-model="maxEmailSize.size" tooltip="hide" min="1000000" max="1000000000" step="1000000"></slider>
<label class="control-label" for="maxEmailSizeInput">{{ 'emails.changeMailSizeDialog.size' | tr }} <b>{{ maxEmailSize.size | prettyDecimalSize }}</b></label>
<input type="range" id="maxEmailSizeInput" ng-model="maxEmailSize.size" step="1000000" min="1000000" max="1000000000" />
</div>
<input class="ng-hide" type="submit"/>
</form>
@@ -71,30 +24,19 @@
</div>
</div>
<!-- Modal change mailbox sharing -->
<div class="modal fade" id="mailboxSharingChangeModal" tabindex="-1" role="dialog">
<!-- Modal change virtual all mail -->
<div class="modal fade" id="virtualAllMailChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'emails.mailboxSharingDialog.title' | tr }}</h4>
<h4 class="modal-title">{{ 'emails.changeVirtualAllMailDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-bind-html=" 'emails.mailboxSharingDialog.description' | tr "></div>
<br>
<form name="mailboxSharingChangeForm" role="form" novalidate ng-submit="mailboxSharing.submit()" autocomplete="off">
<div class="form-group">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxSharing.enable">{{ 'emails.mailboxSharing.mailboxSharingCheckbox' | tr }}</input>
</label>
</div>
</div>
<input class="ng-hide" type="submit"/>
</form>
<div ng-bind-html=" 'emails.changeVirtualAllMailDialog.description' | tr "></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="mailboxSharing.submit()" ng-disabled="mailboxSharing.enable === mailboxSharing.enabled"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxSharing.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button class="btn btn-primary" ng-click="virtualAllMail.submit(!virtualAllMail.enabled)"><i class="fa fa-circle-notch fa-spin" ng-show="virtualAllMail.busy"></i> {{ virtualAllMail.enabled ? ('main.disableAction' | tr) : ('main.enableAction' | tr) }} </button>
</div>
</div>
</div>
@@ -159,8 +101,8 @@
<div class="form-group">
<label class="control-label">{{ 'emails.spamFilterDialog.blacklisteAddresses' | tr }}</label>
<p class="small">{{ 'emails.spamFilterDialog.blacklisteAddressesInfo' | tr }}</p>
<div class="has-error" ng-show="spamConfig.error.blacklist">{{ spamConfig.error.blacklist }}</div>
<textarea ng-model="spamConfig.blacklist" placeholder="{{ 'emails.spamFilterDialog.blacklisteAddressesPlaceholder' | tr }}" name="blacklist" class="form-control" ng-class="{ 'has-error': !spamConfigChangeForm.blacklist.$dirty && spamConfig.error.blacklist }" rows="4"></textarea>
<div class="has-error" ng-show="spamConfig.error.blocklist">{{ spamConfig.error.blocklist }}</div>
<textarea ng-model="spamConfig.blocklist" placeholder="{{ 'emails.spamFilterDialog.blacklisteAddressesPlaceholder' | tr }}" name="blocklist" class="form-control" ng-class="{ 'has-error': !spamConfigChangeForm.blocklist.$dirty && spamConfig.error.blocklist }" rows="4"></textarea>
</div>
<div class="form-group">
<label class="control-label">{{ 'emails.spamFilterDialog.customRules' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#custom-spam-filtering-rules" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
@@ -214,14 +156,13 @@
{{ 'emails.title' | tr }}
<div class="pull-right">
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-eventlog">{{ 'eventlog.title' | tr }}</a>
<!-- hidden for now, until we see a purpose -->
<!-- <a class="btn btn-sm btn-default" ng-disabled="user.isAtLeastOwner" href="/filemanager.html?id=mail&type=mail" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a> -->
<a class="btn btn-default" ng-show="user.isAtLeastAdmin" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
<a class="btn btn-default" ng-show="user.isAtLeastAdmin" href="#/emails-eventlog">{{ 'eventlog.title' | tr }}</a>
</div>
</h1>
</div>
<!-- domain listing -->
<div class="text-left">
<h3>{{ 'emails.domains.title' | tr }}</h3>
</div>
@@ -254,10 +195,20 @@
</td>
<td class="elide-table-cell no-padding">
<a href="/#/email/{{ domain.domain }}" class="email-domain-list-item">
<span ng-show="domain.inbound && domain.outbound && domain.usage === null">{{ 'main.loadingPlaceholder' | tr }} ...</span>
<span ng-show="domain.inbound && domain.outbound && domain.usage !== null">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount, usage: (domain.usage | prettyDecimalSize) } }}</span>
<span ng-show="!domain.inbound && domain.outbound">{{ 'emails.domains.outbound' | tr }}</span>
<span ng-show="!domain.inbound && !domain.outbound">{{ 'emails.domains.disabled' | tr }}</span>
<span ng-switch on="domain.loading">
<span ng-switch-when="true">{{ 'main.loadingPlaceholder' | tr }} ...</span>
<span ng-switch-default>
<span ng-switch on="domain.inbound">
<span ng-switch-when="true">
<span ng-show="domain.loadingUsage">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount } }} {{ 'main.loadingPlaceholder' | tr }} ... </span>
<span ng-show="!domain.loadingUsage">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount, usage: (domain.usage | prettyDecimalSize) } }}</span>
</span>
<span ng-switch-default>
<span ng-show="domain.outbound">{{ 'emails.domains.outbound' | tr }}</span>
<span ng-show="!domain.outbound">{{ 'emails.domains.disabled' | tr }}</span>
</span>
</span>
</span>
</a>
</td>
<td class="text-right no-wrap">
@@ -271,11 +222,12 @@
</div>
</div>
<div class="text-left" ng-show="user.isAtLeastOwner">
<!-- mailbox sharing -->
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'emails.mailboxSharing.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
<div class="card" ng-show="user.isAtLeastAdmin" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>{{ 'emails.mailboxSharing.description' | tr }}</p>
@@ -291,28 +243,94 @@
</div>
</div>
<div class="text-left" ng-show="user.isAtLeastOwner">
<!-- server location -->
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>
{{ 'emails.settings.location' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="mailLocation.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'main.action.showLogs' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in mailLocation.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card" ng-show="user.isAtLeastAdmin">
<div class="row">
<div class="col-md-7">
<p ng-bind-html="'emails.changeDomainDialog.description' | tr"></p>
</div>
<div class="col-md-5">
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="mailLocation.subdomain" id="mailLocationLocationInput" name="location" placeholder="{{ 'emails.changeDomainDialog.locationPlaceholder' | tr }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>{{ (!mailLocation.subdomain ? '' : '.') + mailLocation.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="domain in domains">
<a href="" ng-click="mailLocation.domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12" style="margin-bottom: 10px;">
<div ng-show="mailLocation.busy" class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailLocation.percent }}%"></div>
</div>
</div>
</div>
<p class="text-center text-warning text-bold" ng-show="mailLocation.domain.provider === 'manual'" ng-bind-html="'emails.changeDomainDialog.manualInfo' | tr:{ domain: mailLocation.domain.domain }"></p>
<div class="row">
<div class="col-md-6">
<p ng-show="mailLocation.busy">{{ mailLocation.message }}</p>
<p ng-hide="mailLocation.busy">
<div class="has-error" ng-show="!mailLocation.active">{{ mailLocation.errorMessage }}</div>
</p>
</div>
<div class="col-md-6 text-right">
<!-- save is always enabled so that user can "redo" the task -->
<button class="btn btn-outline btn-primary" ng-click="mailLocation.change()" ng-hide="mailLocation.busy">{{ 'main.dialog.save' | tr }}</button>
<button class="btn btn-outline btn-danger" ng-click="mailLocation.stop()" ng-show="mailLocation.busy" style="margin-right: 10px">{{ 'main.dialog.cancel' | tr }}</button>
</div>
</div>
</div>
<!-- settings -->
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
<h3>{{ 'emails.settings.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
<div class="card" ng-show="user.isAtLeastAdmin" style="margin-bottom: 15px;">
<p ng-bind-html=" 'emails.settings.info' | tr "></p>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.location' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ mailLocation.currentLocation.subdomain + (!mailLocation.currentLocation.subdomain ? '' : '.') + mailLocation.currentLocation.domain.domain }}
<a ng-hide="mailLocation.busy" href="" ng-click="mailLocation.show()"><i class="fa fa-edit text-small"></i></a> <!-- ng-disabled does not work for links -->
</span>
</div>
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.maxMailSize' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ maxEmailSize.currentSize | prettyDecimalSize }} <a href="" ng-click="maxEmailSize.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.virtualAllMail' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ virtualAllMail.enabled ? 'main.statusEnabled' : 'main.statusDisabled' | tr }} <a href="" ng-click="virtualAllMail.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.acl' | tr }}</span>
</div>
@@ -323,7 +341,7 @@
<span class="text-muted">{{ 'emails.settings.spamFilter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ 'emails.settings.spamFilterOverview' | tr:{ blacklistCount: spamConfig.acl.blacklist.length } }} <a href="" ng-click="spamConfig.show()"><i class="fa fa-edit text-small"></i></a></span>
<span>{{ 'emails.settings.spamFilterOverview' | tr:{ blacklistCount: spamConfig.acl.blocklist.length } }} <a href="" ng-click="spamConfig.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.solrFts' | tr }}</span>
@@ -341,19 +359,6 @@
<a href="" ng-click="solrConfig.show()"><i class="fa fa-edit text-small"></i></a>
</div>
</div>
<div class="row" ng-show="mailLocation.busy">
<div class="col-md-12" style="margin-top: 10px;">
{{ 'emails.settings.changeDomainProgress' | tr }}
<div style="display: flex; margin: 4px 0;">
<div class="progress progress-striped active animateMe" style="flex-grow: 1;">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailLocation.percent }}%"></div>
</div>
<div ng-show="mailLocation.taskMinutesActive >= 2" class="text-danger hand" style="margin: 0 4px;" ng-click="mailLocation.stopTask()" uib-tooltip="Cancel Task"><i class="fas fa-times"></i></div>
</div>
<p>{{ mailLocation.message }}</p>
</div>
</div>
</div>
</div>
+147 -103
View File
@@ -1,6 +1,7 @@
'use strict';
/* global $, angular, TASK_TYPES */
/* global async */
angular.module('Application').controller('EmailsController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastMailManager) $location.path('/'); });
@@ -10,39 +11,30 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.user = Client.getUserInfo();
$scope.domains = [];
// this is required because we need to rewrite the MAIL_SERVER_NAME env var
$scope.reconfigureEmailApps = function () {
var installedApps = Client.getInstalledApps();
for (var i = 0; i < installedApps.length; i++) {
if (!installedApps[i].manifest.addons.email) continue;
Client.repairApp(installedApps[i].id, { }, function (error) {
if (error) console.error(error);
});
}
};
$scope.mailLocation = {
busy: false,
error: null,
percent: 0,
message: '',
errorMessage: '',
currentLocation: { domain: null, subdomain: '' },
domain: null,
subdomain: '',
taskId: null,
percent: 0,
taskMinutesActive: 0,
message: '',
errorMessage: '',
reconfigure: false,
tasks: [],
stopTask: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_CHANGE_MAIL_LOCATION, function (error, task) {
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_CHANGE_MAIL_LOCATION, function (error, tasks) {
if (error) return console.error(error);
if (!task.id) return;
$scope.mailLocation.tasks = tasks.slice(0, 10);
if ($scope.mailLocation.tasks.length && $scope.mailLocation.tasks[0].active) $scope.mailLocation.updateStatus();
});
},
Client.stopTask(task.id, function (error) {
if (error) console.error(error);
});
stop: function () {
Client.stopTask($scope.mailLocation.tasks[0].id, function (error) {
if (error) console.error(error);
$scope.mailLocation.busy = false;
$scope.mailLocation.refreshTasks();
});
},
@@ -50,46 +42,26 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
Client.getMailLocation(function (error, location) {
if (error) return console.error('Failed to get max email location', error);
$scope.mailLocation.currentLocation.subdomain = location.subdomain;
$scope.mailLocation.currentLocation.domain = $scope.domains.find(function (d) { return location.domain === d.domain; });
$scope.mailLocation.currentLocation.subdomain = $scope.mailLocation.subdomain = location.subdomain;
$scope.mailLocation.currentLocation.domain = $scope.mailLocation.domain = $scope.domains.find(function (d) { return location.domain === d.domain; });
Client.getLatestTaskByType(TASK_TYPES.TASK_CHANGE_MAIL_LOCATION, function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.mailLocation.taskId = task.id;
$scope.mailLocation.reconfigure = task.active; // if task is active when this view reloaded, reconfigure email apps when task done
$scope.mailLocation.updateStatus();
});
$scope.mailLocation.refreshTasks();
});
},
show: function () {
$scope.mailLocation.busy = false;
$scope.mailLocation.error = null;
$scope.mailLocation.domain = $scope.mailLocation.currentLocation.domain;
$scope.mailLocation.subdomain = $scope.mailLocation.currentLocation.subdomain;
$scope.mailLocationForm.$setUntouched();
$scope.mailLocationForm.$setPristine();
$('#mailLocationModal').modal('show');
},
updateStatus: function () {
Client.getTask($scope.mailLocation.taskId, function (error, data) {
var taskId = $scope.mailLocation.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.mailLocation.updateStatus, 5000);
if (!data.active) {
$scope.mailLocation.taskId = null;
$scope.mailLocation.busy = false;
$scope.mailLocation.message = '';
$scope.mailLocation.percent = 0;
$scope.taskMinutesActive = 0;
$scope.mailLocation.percent = 100;
$scope.mailLocation.errorMessage = data.success ? '' : data.error.message;
if ($scope.mailLocation.reconfigure) $scope.reconfigureEmailApps();
$scope.mailLocation.refreshTasks(); // update the tasks list dropdown
return;
}
@@ -97,32 +69,26 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.mailLocation.busy = true;
$scope.mailLocation.percent = data.percent;
$scope.mailLocation.message = data.message;
$scope.mailLocation.taskMinutesActive = moment().diff(moment(data.creationTime), 'minutes');
window.setTimeout($scope.mailLocation.updateStatus, 1000);
});
},
submit: function () {
change: function () {
$scope.mailLocation.busy = true;
$scope.mailLocation.percent = 0;
$scope.mailLocation.message = '';
$scope.mailLocation.errorMessage = '';
Client.setMailLocation($scope.mailLocation.subdomain, $scope.mailLocation.domain.domain, function (error, result) {
Client.setMailLocation($scope.mailLocation.subdomain, $scope.mailLocation.domain.domain, function (error) {
if (error) {
console.error(error);
$scope.mailLocation.errorMessage = error.message;
$scope.mailLocation.busy = false;
$scope.mailLocation.error = error;
return;
} else {
$scope.mailLocation.refreshTasks();
}
// update UI immediately
$scope.mailLocation.currentLocation = { subdomain: $scope.mailLocation.subdomain, domain: $scope.mailLocation.domain };
$scope.mailLocation.taskId = result.taskId;
$scope.mailLocation.reconfigure = true; // reconfigure email apps when task done
$scope.mailLocation.updateStatus();
Client.refreshConfig(); // update config.mailFqdn
$('#mailLocationModal').modal('hide');
});
}
};
@@ -155,7 +121,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
submit: function () {
$scope.maxEmailSize.busy = true;
Client.setMaxEmailSize($scope.maxEmailSize.size, function (error) {
Client.setMaxEmailSize(parseInt($scope.maxEmailSize.size), function (error) {
$scope.maxEmailSize.busy = false;
if (error) return console.error(error);
@@ -168,6 +134,41 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
}
};
$scope.virtualAllMail = {
busy: false,
error: null,
enabled: false,
refresh: function () {
Client.getVirtualAllMail(function (error, enabled) {
if (error) return console.error('Failed to get max email size', error);
$scope.virtualAllMail.enabled = enabled;
});
},
show: function() {
$scope.virtualAllMail.busy = false;
$scope.virtualAllMail.error = null;
$('#virtualAllMailChangeModal').modal('show');
},
submit: function (enable) {
$scope.virtualAllMail.busy = true;
Client.setVirtualAllMail(enable, function (error) {
$scope.virtualAllMail.busy = false;
if (error) return console.error(error);
$scope.virtualAllMail.enabled = enable;
$('#virtualAllMailChangeModal').modal('hide');
});
}
};
$scope.mailboxSharing = {
busy: false,
error: null,
@@ -249,11 +250,11 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig = {
busy: false,
error: {},
acl: { whitelist: [], blacklist: [] },
acl: { allowlist: [], blocklist: [] },
customConfig: '',
config: '',
blacklist: '', // currently, we don't support whitelist because it requires user to understand a bit more of what he is doing
blocklist: '', // currently, we don't support allowlist because it requires user to understand a bit more of what he is doing
refresh: function () {
Client.getSpamCustomConfig(function (error, config) {
@@ -273,7 +274,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig.busy = false;
$scope.spamConfig.error = {};
$scope.spamConfig.blacklist = $scope.spamConfig.acl.blacklist.join('\n');
$scope.spamConfig.blocklist = $scope.spamConfig.acl.blocklist.join('\n');
$scope.spamConfig.config = $scope.spamConfig.customConfig;
$scope.spamConfigChangeForm.$setUntouched();
@@ -286,13 +287,13 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.spamConfig.busy = true;
$scope.spamConfig.error = {};
var blacklist = $scope.spamConfig.blacklist.split('\n').filter(function (l) { return l !== ''; });
var blocklist = $scope.spamConfig.blocklist.split('\n').filter(function (l) { return l !== ''; });
Client.setSpamAcl({ blacklist: blacklist, whitelist: [] }, function (error) {
Client.setSpamAcl({ blocklist: blocklist, allowlist: [] }, function (error) {
if (error) {
$scope.spamConfig.busy = false;
$scope.spamConfig.error.blacklist = error.message;
$scope.spamConfigChangeForm.blacklist.$setPristine();
$scope.spamConfig.error.blocklist = error.message;
$scope.spamConfigChangeForm.blocklist.$setPristine();
return;
}
@@ -404,43 +405,83 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
}
};
function refreshDomainStatuses() {
$scope.domains.forEach(function (domain) {
domain.usage = null; // used by ui to show 'loading'
function refreshMailStatus(domain, done) {
Client.getMailStatusForDomain(domain.domain, function (error, result) {
if (error) {
console.error('Failed to fetch mail status for domain', domain.domain, error);
return done();
}
Client.getMailStatusForDomain(domain.domain, function (error, result) {
if (error) return console.error('Failed to fetch mail status for domain', domain.domain, error);
domain.status = result;
domain.status = result;
domain.statusOk = Object.keys(result).every(function (k) {
if (k === 'dns') return Object.keys(result.dns).every(function (k) { return result.dns[k].status; });
domain.statusOk = Object.keys(result).every(function (k) {
if (k === 'dns') return Object.keys(result.dns).every(function (k) { return result.dns[k].status; });
if (!('status' in result[k])) return true; // if status is not present, the test was not run
if (!('status' in result[k])) return true; // if status is not present, the test was not run
return result[k].status;
});
return result[k].status;
});
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
if (error) return console.error('Failed to fetch mail config for domain', domain.domain, error);
done();
});
}
domain.inbound = mailConfig.enabled;
domain.outbound = mailConfig.relay.provider !== 'noop';
function refreshMailConfig(domain, done) {
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
if (error) {
console.error('Failed to fetch mail config for domain', domain.domain, error);
return done();
}
// do this even if no outbound since people forget to remove mailboxes
Client.getMailboxCount(domain.domain, function (error, count) {
if (error) return console.error('Failed to fetch mailboxes for domain', domain.domain, error);
domain.inbound = mailConfig.enabled;
domain.outbound = mailConfig.relay.provider !== 'noop';
domain.mailboxCount = count;
// do this even if no outbound since people forget to remove mailboxes
Client.getMailboxCount(domain.domain, function (error, count) {
if (error) {
console.error('Failed to fetch mailboxes for domain', domain.domain, error);
return done();
}
Client.getMailUsage(domain.domain, function (error, usage) {
if (error) return console.error('Failed to fetch usage for domain', domain.domain, error);
domain.mailboxCount = count;
domain.usage = 0;
// quotaValue can be missing for deleted mailboxes that are on disk but removed from dovecot/ldap itself
Object.keys(usage).forEach(function (m) { domain.usage += (usage[m].quotaValue || usage[m].diskSize); });
});
done();
});
});
}
function refreshMailUsage(domain, done) {
Client.getMailUsage(domain.domain, function (error, usage) {
if (error) {
console.error('Failed to fetch usage for domain', domain.domain, error);
return done();
}
domain.usage = 0;
// we used to use quotaValue here but it's quite different wrt diskSize. so choose diskSize consistently
// also, quotaValue can be missing for deleted mailboxes that are on disk but removed from dovecot/ldap itself
Object.keys(usage).forEach(function (m) { domain.usage += usage[m].diskSize; });
done();
});
}
function refreshDomainStatuses() {
async.each($scope.domains, function (domain, iteratorDone) {
async.series([
refreshMailStatus.bind(null, domain),
refreshMailConfig.bind(null, domain),
], function () {
domain.loading = false;
iteratorDone();
});
}, function () {
// mail usage is loaded separately with a cancellation check. when there are a lot of domains, it runs a long time in background and slows down loading of new views
async.eachLimit($scope.domains, 5, function (domain, itemDone) {
if ($scope.$$destroyed) return itemDone(); // abort!
refreshMailUsage(domain, function () {
domain.loadingUsage = false;
itemDone();
});
});
});
@@ -450,12 +491,15 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
Client.getDomains(function (error, domains) {
if (error) return console.error('Unable to get domain listing.', error);
domains.forEach(function (domain) { domain.loading = true; domain.loadingUsage = true; }); // used by ui to show 'loading'
$scope.domains = domains;
$scope.ready = true;
if ($scope.user.isAtLeastOwner) {
if ($scope.user.isAtLeastAdmin) {
$scope.mailLocation.refresh();
$scope.maxEmailSize.refresh();
$scope.virtualAllMail.refresh();
$scope.mailboxSharing.refresh();
$scope.spamConfig.refresh();
$scope.solrConfig.refresh();
+6 -3
View File
@@ -32,8 +32,8 @@
<thead>
<tr>
<th class="col-md-2">{{ 'eventlog.time' | tr }}</th>
<th class="col-md-3">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-7">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2">{{ 'eventlog.source' | tr }}</th>
<th class="col-md-8">{{ 'eventlog.details' | tr }}</th>
</tr>
</thead>
<tbody ng-repeat="eventLog in eventLogs">
@@ -43,7 +43,10 @@
<td ng-bind-html="eventLog.details"></td>
</tr>
<tr ng-show="activeEventLog === eventLog">
<td colspan="4"><pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre></td>
<td colspan="4">
<p ng-show="eventLog.raw.source.ip">Source IP: <code>{{ eventLog.raw.source.ip }}</code></p>
<pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre>
</td>
</tr>
</tbody>
</table>
+4 -2
View File
@@ -32,7 +32,6 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'app.start', value: 'app.start' },
{ name: 'app.stop', value: 'app.stop' },
{ name: 'app.restart', value: 'app.restart' },
{ name: 'Apptask Crash', value: 'app.task.crash' },
{ name: 'backup.cleanup', value: 'backup.cleanup.start' },
{ name: 'backup.cleanup.finish', value: 'backup.cleanup.finish' },
{ name: 'backup.finish', value: 'backup.finish' },
@@ -47,10 +46,12 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'cloudron.update', value: 'cloudron.update' },
{ name: 'cloudron.update.finish', value: 'cloudron.update.finish' },
{ name: 'dashboard.domain.update', value: 'dashboard.domain.update' },
{ name: 'directoryserver.configure', value: 'directoryserver.configure' },
{ name: 'dyndns.update', value: 'dyndns.update' },
{ name: 'domain.add', value: 'domain.add' },
{ name: 'domain.update', value: 'domain.update' },
{ name: 'domain.remove', value: 'domain.remove' },
{ name: 'externalldap.configure', value: 'externalldap.configure' },
{ name: 'mail.location', value: 'mail.location' },
{ name: 'mail.enabled', value: 'mail.enabled' },
{ name: 'mail.box.add', value: 'mail.box.add' },
@@ -71,10 +72,10 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ name: 'user.remove', value: 'user.remove' },
{ name: 'user.transfer', value: 'user.transfer' },
{ name: 'user.update', value: 'user.update' },
{ name: 'userdirectory.profileconfig.update', value: 'userdirectory.profileconfig.update '},
{ name: 'volume.add', value: 'volume.add' },
{ name: 'volume.update', value: 'volume.update' },
{ name: 'volume.remove', value: 'volume.update' },
{ name: 'System Crash', value: 'system.crash' }
];
$scope.pageItemCount = [
@@ -142,6 +143,7 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
};
Client.onReady(function () {
$scope.search = $location.search().search || ''; // sent from the backups view when app is deleted
fetchEventLogs();
});
+100 -50
View File
@@ -65,7 +65,33 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="blocklist.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="blocklist.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-disabled="blocklist.busy" ng-click="blocklist.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="blocklist.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal Trusted IPs -->
<div class="modal fade" id="trustedIpsModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'network.trustedIps.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="trustedIpsChangeForm" role="form" novalidate ng-submit="trustedIps.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label">{{ 'network.trustedIpRanges' | tr }}</label>
<p class="small">{{ 'network.trustedIps.description' | tr }}</p>
<div class="has-error" ng-show="trustedIps.error.trustedIps">{{ trustedIps.error.trustedIps }}</div>
<textarea ng-model="trustedIps.trustedIps" placeholder="{{ 'network.firewall.configure.blocklistPlaceholder' | tr }}" name="trustedIps" class="form-control" ng-class="{ 'has-error': !trustedIpsChangeForm.trustedIps.$dirty && trustedIps.error.trustedIps }" rows="4"></textarea>
</div>
<input class="ng-hide" type="submit"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-disabled="trustedIps.busy" ng-click="trustedIps.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="trustedIps.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -145,7 +171,7 @@
</div>
</div>
<div class="row">
<div class="row" ng-show="sysinfo.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
@@ -173,12 +199,63 @@
</div>
</div>
<!-- IPv6 -->
<div class="text-left section-header">
<h3>{{ 'network.ipv6.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-xs-12">
{{ 'network.ipv6.description' | tr }}
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.provider' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span>{{ prettyIpProviderName(ipv6Configure.provider) }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.provider !== 'noop'">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span ng-show="ipv6Configure.ipv6">{{ ipv6Configure.ipv6 }}</span>
<span ng-show="!ipv6Configure.ipv6 && ipv6Configure.serverIPv6">{{ ipv6Configure.serverIPv6 }} ({{ 'network.ip.detected' | tr }})</span>
<span ng-show="ipv6Configure.displayError" class="text-danger">{{ ipv6Configure.displayError }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.ifname">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.ip.interface' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ ipv6Configure.ifname }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-6 col-md-offset-6 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="ipv6Configure.show()">{{ 'network.ip.configure' | tr }}</button>
</div>
</div>
</div>
<!-- Firewall -->
<div class="text-left" ng-show="user.isAtLeastOwner">
<div class="text-left section-header">
<h3>{{ 'network.firewall.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.isAtLeastOwner">
<div class="card">
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.firewall.blockedIpRanges' | tr }}</span>
@@ -187,61 +264,34 @@
<span>{{ 'network.firewall.blocklist' | tr:{ blockCount: blocklist.currentBlocklistLength } }} <a href="" ng-click="blocklist.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
</div>
</div>
<!-- IPv6 -->
<div class="text-left">
<h3>{{ 'network.ipv6.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-xs-12">
{{ 'network.ipv6.description' | tr }}
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.provider' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span>{{ prettyIpProviderName(ipv6Configure.provider) }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.provider !== 'noop'">
<div class="col-xs-2">
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
<div class="col-xs-10 text-right">
<span ng-show="ipv6Configure.ipv6">{{ ipv6Configure.ipv6 }}</span>
<span ng-show="!ipv6Configure.ipv6 && ipv6Configure.serverIPv6">{{ ipv6Configure.serverIPv6 }} ({{ 'network.ip.detected' | tr }})</span>
<span ng-show="ipv6Configure.displayError" class="text-danger">{{ ipv6Configure.displayError }}</span>
</div>
</div>
<div class="row" ng-show="ipv6Configure.ifname">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.ip.interface' | tr }}</span>
<span class="text-muted">{{ 'network.trustedIpRanges' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ ipv6Configure.ifname }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-6 col-md-offset-6 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="ipv6Configure.show()">{{ 'network.ip.configure' | tr }}</button>
<span>{{ 'network.trustedIps.summary' | tr:{ trustCount: trustedIps.currentTrustedIpsLength } }} <a href="" ng-click="trustedIps.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'network.dyndns.title' | tr }}</h3>
<!-- Dynamic DNS -->
<div class="text-left section-header">
<h3>
{{ 'network.dyndns.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="dyndnsConfigure.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'network.dyndns.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in dyndnsConfigure.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card">
+69 -10
View File
@@ -1,9 +1,9 @@
'use strict';
/* global angular */
/* global $ */
/* global $, TASK_TYPES */
angular.module('Application').controller('NetworkController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
angular.module('Application').controller('NetworkController', ['$scope', '$location', '$timeout', 'Client', function ($scope, $location, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.user = Client.getUserInfo();
@@ -11,6 +11,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
// keep in sync with sysinfo.js
$scope.sysinfoProvider = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
@@ -37,12 +38,25 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
busy: false,
error: '',
isEnabled: false,
tasks: [],
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_SYNC_DYNDNS, function (error, tasks) {
if (error) return console.error(error);
$scope.dyndnsConfigure.tasks = tasks.slice(0, 10);
if ($scope.dyndnsConfigure.tasks.length && $scope.dyndnsConfigure.tasks[0].active) {
$timeout($scope.renewCerts.refreshTasks, 5000);
}
});
},
refresh: function () {
Client.getDynamicDnsConfig(function (error, enabled) {
if (error) return console.error(error);
$scope.dyndnsConfigure.isEnabled = enabled;
$scope.dyndnsConfigure.refreshTasks();
});
},
@@ -83,7 +97,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
}
$scope.ipv6Configure.provider = result.provider;
$scope.ipv6Configure.ipv6 = result.ipv6 || '';
$scope.ipv6Configure.ipv6 = result.ip || '';
$scope.ipv6Configure.ifname = result.ifname || '';
if (result.provider === 'noop') return;
@@ -93,7 +107,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
return console.error(error);
}
$scope.ipv6Configure.serverIPv6 = result.ipv6;
$scope.ipv6Configure.serverIPv6 = result.ip;
});
});
},
@@ -116,7 +130,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
};
if (config.provider === 'fixed') {
config.ipv6 = $scope.ipv6Configure.newIPv6;
config.ip = $scope.ipv6Configure.newIPv6;
} else if (config.provider === 'network-interface') {
config.ifname = $scope.ipv6Configure.newIfname;
}
@@ -192,6 +206,50 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
}
};
$scope.trustedIps = {
busy: false,
error: {},
trustedIps: '',
currentTrustedIps: '',
currentTrustedIpsLength: 0,
refresh: function () {
Client.getTrustedIps(function (error, result) {
if (error) return console.error(error);
$scope.trustedIps.currentTrustedIps = result;
$scope.trustedIps.currentTrustedIpsLength = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length;
});
},
show: function () {
$scope.trustedIps.error = {};
$scope.trustedIps.trustedIps = $scope.trustedIps.currentTrustedIps;
$('#trustedIpsModal').modal('show');
},
submit: function () {
$scope.trustedIps.error = {};
$scope.trustedIps.busy = true;
Client.setTrustedIps($scope.trustedIps.trustedIps, function (error) {
$scope.trustedIps.busy = false;
if (error) {
$scope.trustedIps.error.trustedIps = error.message;
$scope.trustedIps.error.ip = error.message;
$scope.trustedIpsChangeForm.$setPristine();
$scope.trustedIpsChangeForm.$setUntouched();
return;
}
$scope.trustedIps.refresh();
$('#trustedIpsModal').modal('hide');
});
}
};
$scope.sysinfo = {
busy: false,
error: {},
@@ -208,17 +266,17 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
newIfname: '',
refresh: function () {
Client.getSysinfoConfig(function (error, result) {
Client.getIPv4Config(function (error, result) {
if (error) return console.error(error);
$scope.sysinfo.provider = result.provider;
$scope.sysinfo.ipv4 = result.ipv4 || '';
$scope.sysinfo.ipv4 = result.ip || '';
$scope.sysinfo.ifname = result.ifname || '';
Client.getServerIpv4(function (error, result) {
if (error) return console.error(error);
$scope.sysinfo.serverIPv4 = result.ipv4;
$scope.sysinfo.serverIPv4 = result.ip;
});
});
},
@@ -241,12 +299,12 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
};
if (config.provider === 'fixed') {
config.ipv4 = $scope.sysinfo.newIPv4;
config.ip = $scope.sysinfo.newIPv4;
} else if (config.provider === 'network-interface') {
config.ifname = $scope.sysinfo.newIfname;
}
Client.setSysinfoConfig(config, function (error) {
Client.setIPv4Config(config, function (error) {
$scope.sysinfo.busy = false;
if (error && error.message.indexOf('ipv') !== -1) {
$scope.sysinfo.error.ipv4 = error.message;
@@ -276,6 +334,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
$scope.dyndnsConfigure.refresh();
$scope.ipv6Configure.refresh();
$scope.trustedIps.refresh();
if ($scope.user.isAtLeastOwner) $scope.blocklist.refresh();
});
+1 -1
View File
@@ -23,7 +23,7 @@
</div>
</div>
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed">
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
<div class="row">
<div class="col-xs-12" ng-class="{ 'notification-details': notification.detailsShown }">
<span class="notification-title">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
-209
View File
@@ -1,209 +0,0 @@
<!-- Modal client add -->
<div class="modal fade" id="clientAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'oidc.newClientDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
{{ 'oidc.newClientDialog.description' | tr }}
<br/>
<br/>
<form name="clientAddForm" role="form" novalidate ng-submit="clientAdd.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label" for="clientName">{{ 'oidc.client.name' | tr }}</label>
<input type="text" id="clientName" class="form-control" name="clientName" ng-model="clientAdd.name" autofocus required/>
</div>
<div class="form-group" ng-class="{ 'has-error': clientAdd.error.id }">
<label class="control-label" for="clientId">{{ 'oidc.client.id' | tr }}</label>
<input type="text" id="clientId" class="form-control" name="clientId" ng-model="clientAdd.id" required/>
<div class="control-label" ng-show="clientAdd.error.id">
<small>{{ clientAdd.error.id }}</small>
</div>
</div>
<div class="form-group">
<label class="control-label" for="clientSecret">{{ 'oidc.client.secret' | tr }}</label>
<input type="text" id="clientSecret" class="form-control" name="clientSecret" ng-model="clientAdd.secret" required/>
</div>
<div class="form-group">
<label class="control-label" for="loginRedirectUri">{{ 'oidc.client.loginRedirectUri' | tr }}</label>
<input type="text" id="loginRedirectUri" class="form-control" name="loginRedirectUri" ng-model="clientAdd.loginRedirectUri" required/>
</div>
<div class="form-group">
<label class="control-label" for="logoutRedirectUri">{{ 'oidc.client.logoutRedirectUri' | tr }}</label>
<input type="url" id="logoutRedirectUri" class="form-control" name="logoutRedirectUri" ng-model="clientAdd.logoutRedirectUri"/>
</div>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.signingAlgorithm' | tr }}</label>
<div class="control-label">
<select class="form-control" ng-model="clientAdd.tokenSignatureAlgorithm">
<option value="RS256">RS256</option>
<option value="EdDSA">EdDSA</option>
</select>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="clientAddForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="clientAdd.submit()" ng-disabled="clientAddForm.$invalid || clientAdd.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="clientAdd.busy"></i> {{ 'oidc.newClientDialog.createAction' | tr }}
</button>
</div>
</div>
</div>
</div>
<!-- Modal client edit -->
<div class="modal fade" id="clientEditModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'oidc.editClientDialog.title' | tr:{ client: clientEdit.id } }}</h4>
</div>
<div class="modal-body">
<form name="clientEditForm" role="form" novalidate ng-submit="clientEdit.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label" for="inputEditClientName">{{ 'oidc.client.name' | tr }}</label>
<input type="text" id="inputEditClientName" class="form-control" name="clientName" ng-model="clientEdit.name" autofocus required/>
</div>
<div class="form-group">
<label class="control-label" for="inputEditClientSecret">{{ 'oidc.client.secret' | tr }}</label>
<input type="text" id="inputEditClientSecret" class="form-control" name="clientSecret" ng-model="clientEdit.secret" required/>
</div>
<div class="form-group">
<label class="control-label" for="inputEditLoginRedirectUri">{{ 'oidc.client.loginRedirectUri' | tr }}</label>
<input type="text" id="inputEditLoginRedirectUri" class="form-control" name="loginRedirectUri" ng-model="clientEdit.loginRedirectUri" required/>
</div>
<div class="form-group">
<label class="control-label" for="inputEditLogoutRedirectUri">{{ 'oidc.client.logoutRedirectUri' | tr }}</label>
<input type="url" id="inputEditLogoutRedirectUri" class="form-control" name="logoutRedirectUri" ng-model="clientEdit.logoutRedirectUri"/>
</div>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.signingAlgorithm' | tr }}</label>
<div class="control-label">
<select class="form-control" ng-model="clientEdit.tokenSignatureAlgorithm">
<option value="RS256">RS256</option>
<option value="EdDSA">EdDSA</option>
</select>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="clientEditForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="clientEdit.submit()" ng-disabled="clientEditForm.$invalid || clientEdit.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="clientEdit.busy"></i> {{ 'main.dialog.save' | tr }}
</button>
</div>
</div>
</div>
</div>
<!-- Modal client delete -->
<div class="modal fade" id="clientDeleteModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'oidc.deleteClientDialog.title' | tr:{ client: deleteClient.id } }}</h4>
</div>
<div class="modal-body">
<p>{{ 'oidc.deleteClientDialog.description' | tr }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-danger" ng-click="deleteClient.submit()" ng-disabled="deleteClient.busy"><i class="fa fa-circle-notch fa-spin" ng-show="deleteClient.busy"></i> {{ 'main.dialog.delete' | tr }}</button>
</div>
</div>
</div>
</div>
<div class="content">
<div class="text-left">
<h1>{{ 'oidc.title' | tr }}</h1>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-md-12">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.discoveryUrl' | tr }}</td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/.well-known/openid-configuration</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.authEndpoint' | tr }}</td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/openid/auth</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.tokenEndpoint' | tr }}</td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/openid/token</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.keysEndpoint' | tr }}</td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/openid/jwks</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.profileEndpoint' | tr }}</td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/openid/me</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.logoutUrl' | tr }}</td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/openid/session/end</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<br>
<div class="text-left">
<h3>{{ 'oidc.clients.title' | tr }} <button class="btn btn-primary btn-sm pull-right" ng-click="clientAdd.show()"><i class="fa fa-plus"></i> {{ 'oidc.clients.newClient' | tr }}</button></h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 33%">{{ 'oidc.client.name' | tr }}</th>
<th style="width: 33%">{{ 'oidc.client.id' | tr }}</th>
<th style="width: 33%">{{ 'oidc.client.signingAlgorithm' | tr }}</th>
<th style="width: 10%" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-show="clients.length === 0">
<td colspan="3" class="text-center">{{ 'oidc.clients.empty' | tr }}</td>
</tr>
<tr ng-repeat="client in clients">
<td class="text-left elide-table-cell hand" ng-click="clientEdit.show(client)">
{{ client.name }}
</td>
<td class="text-left elide-table-cell hand" ng-click="clientEdit.show(client)">
{{ client.id }}
</td>
<td class="text-left elide-table-cell hand" ng-click="clientEdit.show(client)">
{{ client.tokenSignatureAlgorithm }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-danger" ng-click="deleteClient.show(client)" uib-tooltip="Delete"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="clientEdit.show(client)" uib-tooltip="Edit"><i class="far fa fa-pencil-alt"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
-153
View File
@@ -1,153 +0,0 @@
'use strict';
/* global angular */
/* global $ */
angular.module('Application').controller('OidcController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.clients = [];
$scope.refreshClients = function () {
Client.getOidcClients(function (error, result) {
if (error) return console.error('Failed to load oidc clients', error);
$scope.clients = result;
});
};
$scope.clientAdd = {
busy: false,
error: {},
id: '',
name: '',
secret: '',
loginRedirectUri: '',
logoutRedirectUri: '',
tokenSignatureAlgorithm: '',
show: function () {
$scope.clientAdd.id = '';
$scope.clientAdd.secret = '';
$scope.clientAdd.name = '';
$scope.clientAdd.loginRedirectUri = '';
$scope.clientAdd.logoutRedirectUri = '';
$scope.clientAdd.tokenSignatureAlgorithm = 'RS256';
$scope.clientAdd.busy = false;
$scope.clientAdd.error = null;
$scope.clientAddForm.$setPristine();
$('#clientAddModal').modal('show');
},
submit: function () {
$scope.clientAdd.busy = true;
$scope.clientAdd.error = {};
Client.addOidcClient($scope.clientAdd.id, $scope.clientAdd.name, $scope.clientAdd.secret, $scope.clientAdd.loginRedirectUri, $scope.clientAdd.logoutRedirectUri, $scope.clientAdd.tokenSignatureAlgorithm, function (error) {
if (error) {
if (error.statusCode === 409) {
$scope.clientAdd.error.id = 'Client ID already exists';
$('#clientId').focus();
} else {
console.error('Unable to add openid client.', error);
}
$scope.clientAdd.busy = false;
return;
}
$scope.refreshClients();
$scope.clientAdd.busy = false;
$('#clientAddModal').modal('hide');
});
}
};
$scope.clientEdit = {
busy: false,
error: {},
id: '',
name: '',
secret: '',
loginRedirectUri: '',
logoutRedirectUri: '',
tokenSignatureAlgorithm: '',
show: function (client) {
$scope.clientEdit.id = client.id;
$scope.clientEdit.name = client.name;
$scope.clientEdit.secret = client.secret;
$scope.clientEdit.loginRedirectUri = client.loginRedirectUri;
$scope.clientEdit.logoutRedirectUri = client.logoutRedirectUri;
$scope.clientEdit.tokenSignatureAlgorithm = client.tokenSignatureAlgorithm;
$scope.clientEdit.busy = false;
$scope.clientEdit.error = null;
$scope.clientEditForm.$setPristine();
$('#clientEditModal').modal('show');
},
submit: function () {
$scope.clientEdit.busy = true;
$scope.clientEdit.error = {};
Client.updateOidcClient($scope.clientEdit.id, $scope.clientEdit.name, $scope.clientEdit.secret, $scope.clientEdit.loginRedirectUri, $scope.clientEdit.logoutRedirectUri, $scope.clientEdit.tokenSignatureAlgorithm, function (error) {
if (error) {
console.error('Unable to edit openid client.', error);
$scope.clientEdit.busy = false;
return;
}
$scope.refreshClients();
$scope.clientEdit.busy = false;
$('#clientEditModal').modal('hide');
});
}
};
$scope.deleteClient = {
busy: false,
error: {},
id: '',
show: function (client) {
$scope.deleteClient.busy = false;
$scope.deleteClient.id = client.id;
$('#clientDeleteModal').modal('show');
},
submit: function () {
Client.delOidcClient($scope.deleteClient.id, function (error) {
$scope.deleteClient.busy = false;
if (error) return console.error('Failed to delete openid client', error);
$scope.refreshClients();
$('#clientDeleteModal').modal('hide');
});
}
};
Client.onReady(function () {
$scope.refreshClients();
});
// setup all the dialog focus handling
['clientAddModal', 'clientEditmodal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
});
$('.modal-backdrop').remove();
}]);
+21 -14
View File
@@ -115,13 +115,21 @@
<h4 class="modal-title">{{ 'profile.changeEmail.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="emailChangeForm" role="form" novalidate ng-submit="emailchange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) || (!emailChangeForm.email.$dirty && emailchange.error.email)}">
<input type="email" class="form-control" ng-model="emailchange.email" id="inputEmailChangeEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailchange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
<form name="emailChangeForm" role="form" novalidate ng-submit="emailChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) || (!emailChangeForm.email.$dirty && emailChange.error.email)}">
<label class="control-label" for="inputEmailChangeEmail">{{ 'profile.changeEmail.email' | tr }}</label>
<input type="email" class="form-control" ng-model="emailChange.email" id="inputEmailChangeEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailChange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
<small ng-show="emailChangeForm.email.$error.required">{{ 'profile.changeEmail.errorEmailRequired' | tr }}</small>
<small ng-show="(emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) && !emailChangeForm.email.$error.required">{{ 'profile.changeEmail.errorEmailInvalid' | tr }}</small>
<small ng-show="!emailChangeForm.email.$dirty && emailchange.error.email">{{ emailchange.error.email }}</small>
<small ng-show="!emailChangeForm.email.$dirty && emailChange.error.email">{{ emailChange.error.email }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (emailChange.error.password && !emailChangeForm.password.$dirty) }">
<label class="control-label" for="inputEmailChangePassword">{{ 'profile.changeEmail.password' | tr }}</label>
<input type="password" class="form-control" ng-model="emailChange.password" id="inputEmailChangePassword" name="password" required autofocus password-reveal>
<div class="control-label" ng-show="emailChange.error.password && !emailChangeForm.password.$dirty">
<small ng-show="emailChange.error.password">{{ 'profile.changeEmail.errorWrongPassword' | tr }}</small>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="emailChangeForm.$invalid"/>
@@ -129,7 +137,7 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="emailchange.submit()" ng-disabled="emailChangeForm.$invalid || emailchange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="emailchange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="emailChange.submit()" ng-disabled="emailChangeForm.$invalid || emailChange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="emailChange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -387,8 +395,8 @@
<div class="grid-item-top">
<div class="row">
<div class="col-xs-3" style="min-width: 150px;">
<div class="settings-avatar" style="background-image: url('{{ user.avatarUrl }}');">
<div class="overlay" ng-click="avatarChange.showChangeAvatar()"></div>
<div class="settings-avatar" style="background-image: url('{{ user.avatarUrl }}');" ng-click="avatarChange.showChangeAvatar()">
<i class="picture-edit-indicator fa fa-pencil-alt"></i>
</div>
</div>
<div class="col-xs-9">
@@ -408,7 +416,7 @@
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'profile.primaryEmail' | tr }}</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
{{ user.email }} <a href="" ng-click="emailchange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
{{ user.email }} <a href="" ng-click="emailChange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<tr>
@@ -417,7 +425,7 @@
{{ user.fallbackEmail }} <a href="" ng-click="fallbackEmailChange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<tr>
<tr ng-hide="user.source">
<td colspan="2" class="text-right">
<a href="" ng-click="sendPasswordReset()">{{ 'profile.passwordResetAction' | tr }}</a>
</td>
@@ -437,8 +445,7 @@
<br/>
<button class="btn btn-default" ng-click="backgroundImageChange.show()">Set Background Image</button>
<button class="btn btn-primary pull-right" ng-click="passwordchange.show()" ng-hide="user.source">{{ 'profile.changePasswordAction' | tr }}</button>
<button class="btn pull-right" ng-class="user.twoFactorAuthenticationEnabled ? 'btn-danger' : 'btn-success'" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction' | tr }}</button>
</div>
<button class="btn pull-right" uib-tooltip="{{ (user.source && config.external2FA) ? ('profile.enable2FANotAvailable' | tr) : '' }}" ng-disabled="user.source && config.external2FA" ng-class="user.twoFactorAuthenticationEnabled ? 'btn-danger' : 'btn-success'" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction' | tr }}</button> </div>
</div>
</div>
</div>
@@ -541,8 +548,8 @@
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<p>{{ 'profile.loginTokens.description' | tr:{ webadminTokenCount: tokens.webadminTokens.length, cliTokenCount: cliTokens.length } }}</p>
<button class="btn btn-outline btn-danger pull-right" ng-click="logoutFromAll()" ng-disabled="tokens.busy"><i class="fa fa-circle-notch fa-spin" ng-show="tokens.busy"></i> {{ 'profile.loginTokens.logoutAll' | tr }}</button>
<p>{{ 'profile.loginTokens.description' | tr:{ webadminTokenCount: tokens.webadminTokens.length, cliTokenCount: tokens.cliTokens.length } }}</p>
<button class="btn btn-outline btn-danger pull-right" ng-click="tokens.revokeAllWebAndCliTokens()" ng-disabled="tokens.busy"><i class="fa fa-circle-notch fa-spin" ng-show="tokens.busy"></i> {{ 'profile.loginTokens.logoutAll' | tr }}</button>
</div>
</div>
</div>
+44 -57
View File
@@ -3,6 +3,7 @@
/* global async, Clipboard */
/* global angular */
/* global $ */
/* global TOKEN_TYPES */
angular.module('Application').controller('ProfileController', ['$scope', '$translate', '$location', 'Client', '$timeout', function ($scope, $translate, $location, Client, $timeout) {
$scope.user = Client.getUserInfo();
@@ -14,7 +15,12 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$scope.$watch('language', function (newVal, oldVal) {
if (newVal === oldVal) return;
$translate.use(newVal.id);
Client.setProfileLanguage(newVal.id, function (error) {
if (error) return console.error('Failed to reset password:', error);
});
$translate.use(newVal.id); // this switches the language and saves locally in localStorage['NG_TRANSLATE_LANG_KEY']
});
$scope.sendPasswordReset = function () {
@@ -100,7 +106,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
return;
}
Client.refreshUserInfo();
Client.refreshProfile();
$('#twoFactorAuthenticationEnableModal').modal('hide');
});
@@ -122,7 +128,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
return;
}
Client.refreshUserInfo();
Client.refreshProfile();
$('#twoFactorAuthenticationDisableModal').modal('hide');
});
@@ -179,7 +185,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
function done(error) {
if (error) return console.error('Unable to change avatar.', error);
Client.refreshUserInfo(function (error) {
Client.refreshProfile(function (error) {
if (error) return console.error(error);
$('#avatarChangeModal').modal('hide');
@@ -206,15 +212,10 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
avatarChangeReset: function () {
$scope.avatarChange.error.avatar = null;
if ($scope.user.avatarUrl.indexOf('/api/v1/profile/avatar') !== -1) {
$scope.avatarChange.type = 'custom';
} else if ($scope.user.avatarUrl.indexOf('https://www.gravatar.com') === 0) {
$scope.avatarChange.type = 'gravatar';
} else {
$scope.avatarChange.type = '';
}
console.log($scope.user)
$scope.avatarChange.type = $scope.user.avatarType;
$scope.avatarChange.typeOrig = $scope.avatarChange.type;
document.getElementById('previewAvatar').src = $scope.avatarChange.type === 'custom' ? $scope.user.avatarUrl : '';
$scope.avatarChange.pictureChanged = false;
$scope.avatarChange.avatar = null;
@@ -353,42 +354,44 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
};
$scope.emailchange = {
$scope.emailChange = {
busy: false,
error: {},
email: '',
password: '',
reset: function () {
$scope.emailchange.busy = false;
$scope.emailchange.error.email = null;
$scope.emailchange.email = '';
$scope.emailChange.busy = false;
$scope.emailChange.error = {};
$scope.emailChange.email = '';
$scope.emailChange.password = '';
$scope.emailChangeForm.$setUntouched();
$scope.emailChangeForm.$setPristine();
},
show: function () {
$scope.emailchange.reset();
$scope.emailChange.reset();
$('#emailChangeModal').modal('show');
},
submit: function () {
$scope.emailchange.error.email = null;
$scope.emailchange.busy = true;
$scope.emailChange.error.email = null;
$scope.emailChange.busy = true;
var data = {
email: $scope.emailchange.email
};
Client.updateProfile(data, function (error) {
$scope.emailchange.busy = false;
Client.setProfileEmail($scope.emailChange.email, $scope.emailChange.password, function (error) {
$scope.emailChange.busy = false;
if (error) {
if (error.statusCode === 409) $scope.emailchange.error.email = 'Email already taken';
else if (error.statusCode === 400) $scope.emailchange.error.email = error.message;
else console.error('Unable to change email.', error);
$('#inputEmailChangeEmail').focus();
if (error.statusCode === 412) {
$scope.emailChange.error.password = true;
$scope.emailChange.password = '';
$scope.emailChangeForm.password.$setPristine();
$('#inputFallbackEmailChangePassword').focus();
} else {
$scope.emailChange.error.email = error.message;
$('#inputEmailChangeEmail').focus();
}
$scope.emailChangeForm.$setUntouched();
$scope.emailChangeForm.$setPristine();
@@ -396,9 +399,9 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
return;
}
Client.refreshUserInfo();
Client.refreshProfile();
$scope.emailchange.reset();
$scope.emailChange.reset();
$('#emailChangeModal').modal('hide');
});
}
@@ -435,12 +438,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$scope.fallbackEmailChange.error.generic = null;
$scope.fallbackEmailChange.busy = true;
var data = {
fallbackEmail: $scope.fallbackEmailChange.email,
password: $scope.fallbackEmailChange.password
};
Client.updateProfile(data, function (error) {
Client.setProfileFallbackEmail($scope.fallbackEmailChange.email, $scope.fallbackEmailChange.password, function (error) {
$scope.fallbackEmailChange.busy = false;
if (error) {
@@ -459,7 +457,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
// update user info in the background
Client.refreshUserInfo();
Client.refreshProfile();
$scope.fallbackEmailChange.reset();
$('#fallbackEmailChangeModal').modal('hide');
@@ -591,11 +589,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$scope.displayNameChange.error.displayName = null;
$scope.displayNameChange.busy = true;
var user = {
displayName: $scope.displayNameChange.displayName
};
Client.updateProfile(user, function (error) {
Client.setProfileDisplayName($scope.displayNameChange.displayName, function (error) {
$scope.displayNameChange.busy = false;
if (error) {
@@ -611,7 +605,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
// update user info in the background
Client.refreshUserInfo();
Client.refreshProfile();
$scope.displayNameChange.reset();
$('#displayNameChangeModal').modal('hide');
@@ -636,9 +630,10 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$scope.tokens.busy = false;
$scope.tokens.allTokens = result;
$scope.tokens.webadminTokens = result.filter(function (c) { return c.clientId === 'cid-webadmin'; });
$scope.tokens.cliTokens = result.filter(function (c) { return c.clientId === 'cid-cli'; });
$scope.tokens.apiTokens = result.filter(function (c) { return c.clientId === 'cid-sdk'; });
// dashboard and development clientIds were issued with 7.5.0
$scope.tokens.webadminTokens = result.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_WEBADMIN || c.clientId === TOKEN_TYPES.ID_DEVELOPMENT || c.clientId === 'dashboard' || c.clientId === 'development'; });
$scope.tokens.cliTokens = result.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_CLI; });
$scope.tokens.apiTokens = result.filter(function (c) { return c.clientId === TOKEN_TYPES.ID_SDK; });
});
},
@@ -709,18 +704,10 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
}
};
$scope.logoutFromAll = function () {
Client.destroyOidcSession(function (error) {
if (error) console.error('Failed to destroy oidc session', error);
$scope.tokens.revokeAllWebAndCliTokens();
});
};
Client.onReady(function () {
$scope.appPassword.refresh();
$scope.tokens.refresh();
Client.refreshUserInfo(); // 2fa status might have changed by admin
Client.refreshProfile(); // 2fa status might have changed by admin
$translate.onReady(function () {
var usedLang = $translate.use() || $translate.fallbackLanguage();
+11 -7
View File
@@ -12,11 +12,14 @@
<div class="form-group">
<label class="control-label" style="display: block;" for="memoryLimit">
{{ 'services.memoryLimit' | tr }}: <b>{{ serviceConfigure.memoryLimit / 1024 / 1024 + 'MB' }}</b>
{{ 'services.memoryLimit' | tr }}: <b>{{ serviceConfigure.memoryLimit | prettyBinarySize:'' }}</b>
<button type="button" class="btn btn-xs btn-default pull-right" ng-click="serviceConfigure.resetToDefaults()">{{ 'services.configure.resetToDefaults' | tr }}</button>
</label>
<div style="padding: 0 10px;">
<slider id="memoryLimit" ng-model="serviceConfigure.memoryLimit" step="134217728" tooltip="hide" ticks="serviceConfigure.memoryTicks" ticks-snap-bounds="67108864"></slider>
<input type="range" id="memoryLimit" ng-model="serviceConfigure.memoryLimit" step="134217728" min="{{ serviceConfigure.memoryTicks[0] }}" max="{{ serviceConfigure.memoryTicks[serviceConfigure.memoryTicks.length-1] }}" list="memoryLimitTicks" />
<datalist id="memoryLimitTicks">
<option ng-repeat="limit in serviceConfigure.memoryTicks" value="{{ limit }}"></option>
</datalist>
</div>
</div>
@@ -81,7 +84,7 @@
<td class="elide-table-cell"></td>
<td class="elide-table-cell text-center"></td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<a class="btn btn-xs btn-default" href="/logs.html?id=box" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}"><i class="fa fa-file-alt"></i></a>
<a class="btn btn-xs btn-default" href="/frontend/logs.html?id=box" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}"><i class="fa fa-file-alt"></i></a>
</td>
</tr>
<tr ng-repeat="service in services | filter:{ isRedis: false } | orderBy:'name'">
@@ -105,16 +108,17 @@
</td>
<td class="elide-table-cell">
<div class="progress progress-striped" ng-show="service.config.memoryLimit">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ service.memoryPercent }}%"></div>
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ service.memoryPercent }}%">{{ service.memoryPercent }}%</div>
</div>
</td>
<td class="elide-table-cell text-center">
<span ng-show="service.config.memoryLimit">{{ service.config.memoryLimit | prettyBinarySize }}</span>
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="serviceConfigure.show(service)" uib-tooltip="{{ 'services.configureActionTooltip' | tr }}" ng-disabled="!service.config.memoryLimit"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="serviceConfigure.show(service)" uib-tooltip="{{ 'services.configureActionTooltip' | tr }}" ng-disabled="service.status === 'disabled' || !service.config.memoryLimit"><i class="fa fa-pencil-alt"></i></button>
<!-- restart is always clickable so that a user can rebuild mongodb in disabled state when using VMs where CPU flags can be dynamically changed -->
<button class="btn btn-xs btn-default" ng-click="restartService(service.name)" uib-tooltip="{{ 'services.restartActionTooltip' | tr }}"><i class="fa fa-sync-alt" ng-class="{ 'fa-spin': service.status === 'starting' && !service.config.recoveryMode }"></i></button>
<a class="btn btn-xs btn-default" ng-href="{{ '/logs.html?id=' + service.name }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}"><i class="fa fa-file-alt"></i></a>
<a class="btn btn-xs btn-default" ng-href="{{ service.status === 'disabled' ? '' : ('/frontend/logs.html?id=' + service.name) }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}" ng-disabled="service.status === 'disabled'"><i class="fa fa-file-alt"></i></a>
</td>
</tr>
<tr ng-show="hasRedisServices" ng-click="redisServicesExpanded = !redisServicesExpanded" class="hand">
@@ -142,7 +146,7 @@
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="serviceConfigure.show(service)" uib-tooltip="{{ 'services.configureActionTooltip' | tr }}" ng-show="service.config.memoryLimit"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="restartService(service.name)" uib-tooltip="{{ 'services.restartActionTooltip' | tr }}"><i class="fa fa-sync-alt" ng-class="{ 'fa-spin': service.status === 'starting' && !service.config.recoveryMode }"></i></button>
<a class="btn btn-xs btn-default" ng-href="{{ '/logs.html?id=' + service.name }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}"><i class="fa fa-file-alt"></i></a>
<a class="btn btn-xs btn-default" ng-href="{{ '/frontend/logs.html?id=' + service.name }}" target="_blank" uib-tooltip="{{ 'logs.title' | tr }}"><i class="fa fa-file-alt"></i></a>
</td>
</tr>
</tbody>
+18 -11
View File
@@ -20,12 +20,13 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
if (error) return console.log('Error getting status of ' + serviceName + ':' + error.message);
var service = $scope.services.find(function (s) { return s.name === serviceName; });
if (!service) $scope.services[serviceName] = service;
if (!service) callback(new Error('no such service' + serviceName)); // cannot happen
service.status = result.status;
service.config = result.config;
service.memoryUsed = result.memoryUsed;
service.memoryPercent = result.memoryPercent;
service.defaultMemoryLimit = result.defaultMemoryLimit;
callback(null, service);
});
@@ -53,7 +54,10 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
return;
}
if (error) return Client.error(error);
if (error) {
refresh(serviceName);
return Client.error(error);
}
// show "busy" indicator for 3 seconds to show some ui activity
setTimeout(function () { waitForActive(serviceName); }, 3000);
@@ -75,19 +79,22 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
$scope.serviceConfigure.reset();
$scope.serviceConfigure.service = service;
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
$scope.serviceConfigure.recoveryMode = !!service.config.recoveryMode;
$scope.serviceConfigure.memoryTicks = [];
// we max system memory and current service memory for the case where the user configured the service on another server with more resources
var nearest256m = Math.ceil(Math.max($scope.memory.memory, service.config.memoryLimit) / (256*1024*1024)) * 256 * 1024 * 1024;
var startTick = service.defaultMemoryLimit;
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
$scope.serviceConfigure.memoryTicks = [];
var npow2 = Math.pow(2, Math.ceil(Math.log($scope.memory.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
$scope.serviceConfigure.memoryTicks.push(i * 1024 * 1024);
for (var i = startTick; i <= nearest256m; i *= 2) {
$scope.serviceConfigure.memoryTicks.push(i);
}
// for firefox widget update
$timeout(function() {
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
}, 500);
$('#serviceConfigureModal').modal('show');
},
@@ -96,7 +103,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
$scope.serviceConfigure.error = null;
var data = {
memoryLimit: $scope.serviceConfigure.memoryLimit,
memoryLimit: parseInt($scope.serviceConfigure.memoryLimit),
recoveryMode: $scope.serviceConfigure.recoveryMode
};
@@ -119,7 +126,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
},
resetToDefaults: function () {
$scope.serviceConfigure.memoryLimit = 536870912; // 512MB default
$scope.serviceConfigure.memoryLimit = $scope.serviceConfigure.service.defaultMemoryLimit;
},
reset: function () {
+23 -10
View File
@@ -7,7 +7,7 @@
<h4 class="modal-title">{{ 'settings.updateDialog.title' | tr }} <b>{{config.update.box.version}}</b> </h4>
</div>
<div class="modal-body">
<div ng-hide="installedApps | readyToUpdate">
<div ng-hide="installedApps | canUpdate">
<p>{{ 'settings.updateDialog.blockingApps' | tr }}</p>
<ul>
<li ng-repeat="app in installedApps | inProgressApps">{{app.fqdn}}</li>
@@ -17,7 +17,7 @@
<br/>
</div>
<div ng-show="installedApps | readyToUpdate">
<div ng-show="installedApps | canUpdate">
<p class="text-danger" ng-show="config.update.box.unstable">{{ 'settings.updateDialog.unstableWarning' | tr }}</p>
<p>{{ 'settings.updateDialog.changes' | tr }}:</p>
<ul>
@@ -28,12 +28,12 @@
</div>
</div>
<div class="modal-footer">
<label class="checkbox-inline pull-left">
<label ng-show="installedApps | canUpdate" class="checkbox-inline pull-left">
<input type="checkbox" ng-model="update.skipBackup">{{ 'settings.updateDialog.skipBackupCheckbox' | tr }}
</label>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy" ng-show="(installedApps | readyToUpdate)"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
<button type="button" class="btn" ng-show="installedApps | canUpdate" ng-class="config.update.box.unstable ? 'btn-danger' : 'btn-success'" ng-click="update.startUpdate()" ng-disabled="update.busy"><i class="fa fa-circle-notch fa-spin" ng-show="update.busy"></i> {{ 'settings.updateDialog.updateAction' | tr }}</button>
</div>
</div>
</div>
@@ -204,7 +204,7 @@
</div>
</div>
<div class="text-left">
<div class="text-left section-header">
<h3>{{ 'settings.timezone.title' | tr }}</h3>
</div>
@@ -228,7 +228,7 @@
</div>
</div>
<div class="text-left">
<div class="text-left section-header">
<h3>{{ 'settings.language.title' | tr }}</h3>
</div>
@@ -251,8 +251,22 @@
</div>
</div>
<div class="text-left">
<h3>{{ 'settings.updates.title' | tr }}</h3>
<div class="text-left section-header">
<h3>
{{ 'settings.updates.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="update.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'settings.updates.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in update.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
@@ -286,7 +300,6 @@
<div class="row" ng-show="update.busy">
<div class="col-md-12">
<p >{{ update.message }}</p>
<p class="has-error" ng-show="update.errorMessage">{{ update.errorMessage }}. <a ng-class="warning" ng-href="/logs.html?taskId={{update.taskId}}" target="_blank">{{ 'settings.updates.showLogsAction' | tr }}</a></p>
</div>
</div>
@@ -300,7 +313,7 @@
</div>
</div>
<div class="text-left">
<div class="text-left section-header">
<h3>{{ 'settings.privateDockerRegistry.title' | tr }}</h3>
</div>
+22 -19
View File
@@ -1,7 +1,7 @@
'use strict';
/* global angular:false */
/* global $:false */
/* global $:false, TASK_TYPES */
angular.module('Application').controller('SettingsController', ['$scope', '$location', '$translate', '$rootScope', '$timeout', 'Client', function ($scope, $location, $translate, $rootScope, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -87,8 +87,16 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
percent: 0,
message: 'Downloading',
errorMessage: '', // this shows inline
taskId: '',
skipBackup: false,
tasks: [],
refreshTasks: function () {
Client.getTasksByType(TASK_TYPES.TASK_UPDATE, function (error, tasks) {
if (error) return console.error(error);
$scope.update.tasks = tasks.slice(0, 10);
if ($scope.update.tasks.length && $scope.update.tasks[0].active) $scope.update.updateStatus();
});
},
checkNow: function () {
$scope.update.checking = true;
@@ -108,7 +116,9 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
},
stopUpdate: function () {
Client.stopTask($scope.update.taskId, function (error) {
var taskId = $scope.update.tasks[0].id;
Client.stopTask(taskId, function (error) {
if (error) {
if (error.statusCode === 409) {
$scope.update.errorMessage = 'No update is currently in progress';
@@ -124,18 +134,8 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
});
},
checkStatus: function () {
Client.getLatestTaskByType('update', function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.update.taskId = task.id;
$scope.update.updateStatus();
});
},
reloadIfNeeded: function () {
Client.getStatus(function (error, status) {
Client.getProvisionStatus(function (error, status) {
if (error) return $scope.error(error);
if (window.localStorage.version !== status.version) window.location.reload(true);
@@ -143,7 +143,9 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
},
updateStatus: function () {
Client.getTask($scope.update.taskId, function (error, data) {
var taskId = $scope.update.tasks[0].id;
Client.getTask(taskId, function (error, data) {
if (error) return window.setTimeout($scope.update.updateStatus, 5000);
if (!data.active) {
@@ -154,6 +156,8 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
if (!data.errorMessage) $scope.update.reloadIfNeeded(); // assume success
$scope.update.refreshTasks(); // redundant... update the tasks list dropdown
return;
}
@@ -172,7 +176,7 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
$scope.update.message = '';
$scope.update.errorMessage = '';
Client.update({ skipBackup: $scope.update.skipBackup }, function (error, taskId) {
Client.update({ skipBackup: $scope.update.skipBackup }, function (error /*, taskId */) {
if (error) {
$scope.update.error.generic = error.message;
$scope.update.busy = false;
@@ -181,8 +185,7 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
$('#updateModal').modal('hide');
$scope.update.taskId = taskId;
$scope.update.updateStatus();
$scope.update.refreshTasks();
});
}
};
@@ -430,7 +433,7 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
});
});
$scope.update.checkStatus();
$scope.update.refreshTasks();
if ($scope.user.isAtLeastOwner) getSubscription();
});
+23 -55
View File
@@ -5,71 +5,39 @@
</div>
<div class="text-left">
<h3>{{ 'support.ticket.title' | tr }}</h3>
<h3>{{ 'support.help.title' | tr }}</h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row" ng-hide="ready">
<h2 class="text-center"><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="row" ng-show="ready">
<div class="row">
<div class="col-lg-12">
<div ng-show="subscription && !subscription.emailVerified" style="margin-bottom: 30px;">
<p class="text-bold">
{{ 'support.ticket.emailNotVerified' | tr:{ email: subscription.email } }}
<br/>
<center>
<a ng-href="{{ config.consoleServerOrigin }}" target="_blank" class="btn btn-success">{{ 'support.ticket.emailVerifyAction' | tr }}</a>
</center>
</p>
</div>
<div ng-bind-html="supportConfig.ticketFormBody | markdown2html"></div>
<form ng-show="supportConfig.submitTickets" name="feedbackForm" ng-submit="submitFeedback()">
<div class="form-group">
<label>{{ 'support.ticket.type' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required ng-disabled="!subscription.emailVerified">
<option value="app_error">{{ 'support.ticket.typeApp' | tr }}</option>
<option value="ticket">{{ 'support.ticket.typeBug' | tr }}</option>
<option value="email_error">{{ 'support.ticket.typeEmail' | tr }}</option>
</select>
</div>
<div class="form-group" ng-show="feedback.type === 'app_error'">
<label>{{ 'support.ticket.selectApp' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.appId" ng-required="feedback.type === 'app_error'" ng-disabled="!subscription.emailVerified">
<option ng-repeat="app in apps" value="{{ app.id }}">{{ app.fqdn }}</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
<label>{{ 'support.ticket.topic' | tr }}</label>
<input type="text" class="form-control" name="subject" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.description.$dirty && feedbackForm.description.$invalid) }">
<label>{{ 'support.ticket.report' | tr }}</label>
<textarea class="form-control" name="description" rows="3" placeholder="{{ 'support.ticket.reportPlaceholder' | tr }}" ng-model="feedback.description" ng-minlength="1" required ng-disabled="!subscription.emailVerified"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': feedbackForm.email.$invalid }">
<label>{{ 'support.ticket.email' | tr }}</label> <small>{{ 'support.ticket.emailInfo' | tr:{ email: subscription.email } }}</small>
<input type="email" class="form-control" name="email" placeholder="{{ 'support.ticket.emailPlaceholder' | tr }}" ng-model="feedback.altEmail" ng-required="feedback.type === 'email_error'" ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group">
<label class="control-label">
<input type="checkbox" ng-model="feedback.enableSshSupport" ng-disabled="!subscription.emailVerified"> {{ 'support.ticket.sshCheckbox' | tr }}
</label>
</div>
<button type="submit" class="btn btn-primary pull-right" ng-disabled="!subscription.emailVerified || feedbackForm.$invalid || feedback.busy"><i class="fa fa-circle-notch fa-spin" ng-show="feedback.busy"></i> {{ 'support.ticket.submitAction' | tr }}</button>
<span ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</span>
<span ng-show="feedback.result" class="text-success text-bold">{{feedback.result.message}}</span>
</form>
<div ng-bind-html="'support.help.description' | tr:{ docsLink: 'https://docs.cloudron.io/?support_view', packagingLink: 'https://docs.cloudron.io/custom-apps/tutorial/?support_view', forumLink: 'https://forum.cloudron.io/' } | markdown2html"></div>
</div>
</div>
</div>
</div>
<div class="text-left">
<div class="text-left" ng-if="troubleshoot">
<h3>Troubleshoot</h3>
</div>
<div class="card" ng-if="troubleshoot">
<div class="grid-item-top">
<div class="row">
<div class="col-lg-12">
<p>Troubleshooting tools</p>
<div>
<button class="btn btn-default pull-right" ng-click="repairAll()"><i ng-show="repairAllBusy" class="fa fa-circle-notch fa-spin"></i> Repair All</button>
<button class="btn btn-default pull-right" ng-click="updateAll()"><i ng-show="updateAllBusy" class="fa fa-circle-notch fa-spin"></i> Update All</button>
</div>
<p class="text-small text-warning">{{ troubleshootingMessage }}</p>
</div>
</div>
</div>
</div>
<div class="text-left section-header">
<h3>{{ 'support.remoteSupport.title' | tr }}</h3>
</div>
+47 -68
View File
@@ -2,6 +2,8 @@
/* global angular:false */
/* global $:false */
/* global ISTATES */
/* global async */
angular.module('Application').controller('SupportController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastOwner) $location.path('/'); });
@@ -9,67 +11,59 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.apps = Client.getInstalledApps();
$scope.appsById = {};
$scope.supportConfig = null;
$scope.feedback = {
error: null,
result: null,
busy: false,
enableSshSupport: false,
subject: '',
type: 'app_error',
description: '',
appId: '',
altEmail: ''
};
$scope.installedApps = Client.getInstalledApps();
$scope.toggleSshSupportError = '';
$scope.sshSupportEnabled = false;
$scope.subscription = null;
function resetFeedback() {
$scope.feedback.enableSshSupport = false;
$scope.feedback.subject = '';
$scope.feedback.description = '';
$scope.feedback.type = 'app_error';
$scope.feedback.appId = '';
$scope.feedback.altEmail = '';
$scope.troubleshoot = $location.search().troubleshoot;
$scope.feedbackForm.$setUntouched();
$scope.feedbackForm.$setPristine();
}
$scope.updateAllBusy = false;
$scope.repairAllBusy = false;
$scope.troubleshootingMessage = '';
$scope.submitFeedback = function () {
$scope.feedback.busy = true;
$scope.feedback.result = null;
$scope.feedback.error = null;
$scope.updateAll = function () {
$scope.updateAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0, unstable = 0;
var data = {
enableSshSupport: $scope.feedback.enableSshSupport,
subject: $scope.feedback.subject,
description: $scope.feedback.description,
type: $scope.feedback.type,
appId: $scope.feedback.appId,
altEmail: $scope.feedback.altEmail
};
Client.checkForUpdates(function (error) {
if (error) Client.error(error);
Client.createTicket(data, function (error, result) {
if (error) {
$scope.feedback.error = error.message;
} else {
$scope.feedback.result = result;
resetFeedback();
}
async.eachSeries(Object.keys($scope.config.update), function (appId, iteratorDone) {
if ($scope.config.update[appId].unstable) { ++unstable; return iteratorDone(); }
$scope.feedback.busy = false;
Client.updateApp(appId, $scope.config.update[appId].manifest, { skipBackup: false }, function (error) {
if (error) Client.error(error);
else ++count;
// refresh state
Client.getRemoteSupport(function (error, enabled) {
if (error) return console.error(error);
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps updated. ${unstable} apps with unstable updates skipped.`;
$scope.updateAllBusy = false;
});
});
};
$scope.sshSupportEnabled = enabled;
$scope.repairAll = function () {
$scope.repairAllBusy = true;
$scope.troubleshootingMessage = '';
let count = 0;
Client.refreshInstalledApps(function () {
async.eachSeries($scope.installedApps, function (app, iteratorDone) {
if (app.installationState !== ISTATES.ERROR) return iteratorDone();
Client.repairApp(app.id, {}, function (error) {
if (error) Client.error(error);
else ++count;
iteratorDone();
});
}, function () {
$scope.troubleshootingMessage = `${count} apps repaired.`;
$scope.repairAllBusy = false;
});
});
};
@@ -89,27 +83,12 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
};
Client.onReady(function () {
Client.getSubscription(function (error, result) {
if (error && error.statusCode === 402) return $scope.ready = true; // not yet registered
if (error && error.statusCode === 412) return $scope.ready = true; // invalid appstore token
Client.getRemoteSupport(function (error, enabled) {
if (error) return console.error(error);
$scope.subscription = result;
$scope.sshSupportEnabled = enabled;
Client.getSupportConfig(function (error, supportConfig) {
if (error) return console.error(error);
$scope.supportConfig = supportConfig;
Client.getRemoteSupport(function (error, enabled) {
if (error) return console.error(error);
Client.getInstalledApps().forEach(function (app) { $scope.appsById[app.id] = app; });
$scope.sshSupportEnabled = enabled;
$scope.ready = true;
});
});
$scope.ready = true;
});
});
+47 -8
View File
@@ -4,17 +4,54 @@
<div class="col-md-12">
<h1>
{{ 'system.title' | tr }}
<a class="btn btn-default pull-right" href="/logs.html?id=box" target="_blank">{{ 'main.action.logs' | tr }}</a>
<a class="btn btn-default pull-right" href="/frontend/logs.html?id=box" target="_blank">{{ 'main.action.logs' | tr }}</a>
<button class="btn btn-default pull-right" ng-click="$parent.reboot.show()">{{ 'main.action.reboot' | tr }}</button>
</h1>
</div>
</div>
<div class="row">
<div class="col-md-6">
<div class="col-md-12">
<h3 class="graphs-toolbar">
Graphs
{{ 'system.info.title' | tr }}
</h3>
<div class="card card-expand">
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.platformVersion' | tr }}</div>
<div class="col-xs-8 text-right">v{{ config.version }} ({{ config.ubuntuVersion }})</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.vendor' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.sysVendor }}</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.product' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.productName }}</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">CPU</div>
<div class="col-xs-8 text-right">{{ cpus.length + ' Core "' + cpus[0].model + '"' }}</div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.memory' | tr }}</div>
<div class="col-xs-8 text-right">{{ memory.memory | prettyDiskSize }} RAM <span ng-show="memory.swap">&amp; {{ memory.swap | prettyDiskSize }} Swap</span></div>
</div>
<div class="row">
<div class="col-xs-4 text-muted">{{ 'system.info.uptime' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.uptimeSecs }}</div>
</div>
<div class="row" ng-show="info.activationTime">
<div class="col-xs-4 text-muted">{{ 'system.info.activationTime' | tr }}</div>
<div class="col-xs-8 text-right">{{ info.activationTime | prettyDate }}</div>
</div>
</div>
</div>
<div class="col-md-6">
<h3 class="graphs-toolbar">
{{ 'system.graphs.title' | tr }}
<div class="graphs-toolbar-actions">
<button class="btn btn-sm btn-default" style="margin-right: 5px;" ng-click="graphs.refresh()" ng-disabled="graphs.busy"><i class="fas fa-sync-alt" ng-class="{ 'fa-spin': graphs.busy }"></i></button>
<div class="dropdown">
@@ -68,6 +105,7 @@
</div>
<div ng-hide="disks.busy" class="ng-hide">
<div class="row" ng-repeat="disk in disks.disks" style="margin-bottom: 20px;">
<hr style="margin: 5px 0px;" ng-show="$index !== 0"/>
<div class="col-md-12">
<div style="display: flex; align-items: baseline; justify-content: space-between;">
<h3 class="no-wrap" style="font-size: 20px;" ng-bind-html="'system.diskUsage.mountedAt' | tr:{ filesystem: disk.filesystem, mountpoint: disk.mountpoint }"></h3>
@@ -78,18 +116,19 @@
<div class="progress-bar" ng-repeat="content in disk.contents" style="width: {{ content.usage / disk.size * 100 }}%; background-color: {{ content.color }};" uib-tooltip="{{ content.label + ' ' + (content.usage | prettyDiskSize) }}"></div>
<div class="text-center text-muted" style="font-size: 12px; line-height: 20px;">{{ disk.available | prettyDiskSize }}</div>
</div>
<div class="text-right text-muted" style="margin-top: 10px;">{{ 'system.diskUsage.diskSpeed' | tr:{ speed: disk.speed } }}</div>
<div class="text-right text-muted" style="margin-top: 10px;" ng-show="disk.speed !== -1">{{ 'system.diskUsage.diskSpeed' | tr:{ speed: disk.speed } }}</div>
<p ng-hide="disk.volume">{{ 'system.diskUsage.diskContent' | tr }}:</p>
<p ng-show="disk.volume" ng-bind-html="'system.diskUsage.volumeContent' | tr:{ name: disk.volume.name }"></p>
<div ng-repeat="content in disk.contents" class="disk-content">
<span class="color-indicator" style="background-color: {{ content.color }};">&nbsp;</span>
<span ng-show="content.type === 'standard'">{{ content.label || content.id }}</span>
<span ng-show="content.type === 'swap'">{{ content.id }}</span>
<span ng-show="content.type === 'cloudron-backup-default'">{{ content.path }} (Old Backups)</span>
<span ng-show="content.type === 'standard'">{{ content.label }}</span>
<span ng-show="content.type === 'swap'">{{ content.label }}</span>
<span ng-show="content.type === 'app'">
<a href="https://{{ content.app.fqdn }}" target="_blank" ng-hide="content.uninstalled">{{ content.app.label || content.app.fqdn }}</a>
<a href="/#/app/{{ content.app.id }}/storage" ng-hide="content.uninstalled">{{ content.label }}</a>
<span ng-show="content.uninstalled">{{ 'system.diskUsage.uninstalledApp' | tr }}</span>
</span>
<span ng-show="content.type === 'volume'"><a href="/#/volumes">{{ content.volume.name }}</a></span>
<span ng-show="content.type === 'volume'"><a href="/#/volumes">{{ content.label }}</a></span>
<small class="text-muted">{{ content.usage | prettyDiskSize }}</small>
</div>
</div>
+27 -1
View File
@@ -2,6 +2,7 @@
/* global angular */
/* global $ */
/* global TASK_TYPES */
/* global Chart */
angular.module('Application').controller('SystemController', ['$scope', '$location', '$timeout', 'Client', function ($scope, $location, $timeout, Client) {
@@ -9,6 +10,8 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
$scope.config = Client.getConfig();
$scope.memory = null;
$scope.cpus = null;
$scope.info = null;
$scope.volumesById = {};
// https://stackoverflow.com/questions/1484506/random-color-generator
@@ -85,14 +88,23 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
disk.contents.forEach(function (content) { if (content.path === disk.mountpoint) disk.volume = $scope.volumesById[content.id]; });
disk.contents = disk.contents.filter(function (content) { return content.path !== disk.mountpoint; });
// only show old backups if the size is significant
disk.contents = disk.contents.filter(function (content) { return content.id !== 'cloudron-backup-default' || content.usage > 1024*1024*1024; });
disk.contents.forEach(function (content) {
content.color = getNextColor();
if (content.type === 'app') {
content.app = Client.getInstalledAppsByAppId()[content.id];
if (!content.app) content.uninstalled = true;
else content.label = content.app.label || content.app.fqdn;
} else if (content.type === 'volume') {
content.volume = $scope.volumesById[content.id];
content.label = content.volume ? content.volume.name : 'Removed volume';
}
if (content.type === 'volume') content.volume = $scope.volumesById[content.id];
// ensure a label for ui
content.label = content.label || content.id;
usageOther -= content.usage;
});
@@ -317,6 +329,20 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
};
Client.onReady(function () {
Client.cpus(function (error, cpus) {
if (error) console.error(error);
$scope.cpus = cpus;
});
Client.systemInfo(function (error, info) {
if (error) console.error(error);
// prettify for UI
info.uptimeSecs = moment.duration(info.uptimeSecs, 'seconds').locale(navigator.language).humanize();
$scope.info = info;
});
Client.memory(function (error, memory) {
if (error) console.error(error);
+539
View File
@@ -0,0 +1,539 @@
<!-- Modal external ldap -->
<div class="modal fade" id="externalLdapModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.externalLdapDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<p class="has-error text-center" ng-show="externalLdap.error.generic">{{ externalLdap.error.generic }}</p>
<div class="form-group">
<label class="control-label" for="ldapProvider">{{ 'users.externalLdap.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#external-directory" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="ldapProvider" ng-model="externalLdap.provider" ng-options="a.value as a.name for a in ldapProvider"></select>
</div>
<p class="text-small text-warning" ng-show="externalLdap.provider === 'noop' && externalLdap.currentConfig.provider !== 'noop'">
{{ 'users.externalLdap.disableWarning' | tr }}
</p>
<div uib-collapse="externalLdap.provider === 'noop'">
<form name="externalLdapConfigForm" role="form" novalidate ng-submit="externalLdap.submit()" autocomplete="off">
<fieldset>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.url }">
<label class="control-label" for="inputExternalLdapConfigUrl">{{ 'users.externalLdap.server' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.url" id="inputExternalLdapConfigUrl" name="url" ng-disabled="externalLdap.busy" placeholder="ldaps://example.com:636" required>
</div>
<div class="form-group">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="externalLdap.acceptSelfSignedCerts"> {{ 'users.externalLdap.acceptSelfSignedCert' | tr }}
</label>
</div>
<p class="has-error" ng-show="externalLdap.error.acceptSelfSignedCerts">{{ 'users.externalLdap.errorSelfSignedCert' | tr }}</p>
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.baseDn }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigBaseDn">{{ 'users.externalLdap.baseDn' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.baseDn" id="inputExternalLdapConfigBaseDn" name="baseDn" ng-disabled="externalLdap.busy" placeholder="ou=users,dc=example,dc=com" ng-required="externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.filter }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigFilter">{{ 'users.externalLdap.filter' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.filter" id="inputExternalLdapConfigFilter" name="filter" ng-disabled="externalLdap.busy" placeholder="(objectClass=inetOrgPerson)" ng-required="externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.usernameField }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigUsernameField">{{ 'users.externalLdap.usernameField' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.usernameField" id="inputExternalLdapConfigUsernameField" name="usernameField" ng-disabled="externalLdap.busy" placeholder="uid or sAMAcountName">
</div>
<div class="form-group">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="externalLdap.syncGroups"> {{ 'users.externalLdap.syncGroups' | tr }}</sup>
</label>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupBaseDn }" ng-show="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigGroupBaseDn">{{ 'users.externalLdap.groupBaseDn' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.groupBaseDn" id="inputExternalLdapConfigGroupBaseDn" name="groupBaseDn" ng-disabled="externalLdap.busy" placeholder="ou=groups,dc=example,dc=com" ng-required="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupFilter }" ng-show="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigGroupFilter">{{ 'users.externalLdap.groupFilter' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.groupFilter" id="inputExternalLdapConfigGroupFilter" name="groupFilter" ng-disabled="externalLdap.busy" placeholder="(objectClass=groupOfNames)" ng-required="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupnameField }" ng-show="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigGroupnameField">{{ 'users.externalLdap.groupnameField' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.groupnameField" id="inputExternalLdapConfigGroupnameField" name="groupnameField" ng-disabled="externalLdap.busy" placeholder="cn" ng-required="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.credentials }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigBindDn">{{ 'users.externalLdap.bindUsername' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.bindDn" id="inputExternalLdapConfigBindDn" name="bindDn" ng-disabled="externalLdap.busy" placeholder="uid=admin,ou=Users,dc=example,dc=com">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.credentials }">
<label class="control-label" for="inputExternalLdapConfigBindPassword">{{ 'users.externalLdap.bindPassword' | tr }}</label>
<input type="password" class="form-control" ng-model="externalLdap.bindPassword" id="inputExternalLdapConfigBindPassword" name="bindPassword" ng-disabled="externalLdap.busy" placeholder="" password-reveal>
</div>
<div class="form-group">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="externalLdap.autoCreate"> {{ 'users.externalLdap.autocreateUsersOnLogin' | tr }}
</label>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="externalLdapConfigForm.$invalid"/>
</fieldset>
</form>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-primary" ng-click="externalLdap.submit()" ng-disabled="externalLdapConfigForm.$invalid || externalLdap.saveBusy"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.saveBusy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal client add -->
<div class="modal fade" id="oidcClientAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'oidc.newClientDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
{{ 'oidc.newClientDialog.description' | tr }}
<br/>
<br/>
<form name="clientAddForm" role="form" novalidate ng-submit="clientAdd.submit()" autocomplete="off">
<p class="text-danger" ng-show="clientAdd.error">{{ clientAdd.error }}</p>
<div class="form-group">
<label class="control-label" for="clientName">{{ 'oidc.client.name' | tr }}</label>
<input type="text" id="clientName" class="form-control" name="clientName" ng-model="clientAdd.name" autofocus required/>
</div>
<div class="form-group">
<label class="control-label" for="loginRedirectUri">{{ 'oidc.client.loginRedirectUri' | tr }}</label>
<input type="text" id="loginRedirectUri" class="form-control" name="loginRedirectUri" ng-model="clientAdd.loginRedirectUri" required/>
</div>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.signingAlgorithm' | tr }}</label>
<div class="control-label">
<select class="form-control" ng-model="clientAdd.tokenSignatureAlgorithm">
<option value="RS256">RS256</option>
<option value="EdDSA">EdDSA</option>
</select>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="clientAddForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="clientAdd.submit()" ng-disabled="clientAddForm.$invalid || clientAdd.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="clientAdd.busy"></i> {{ 'oidc.newClientDialog.createAction' | tr }}
</button>
</div>
</div>
</div>
</div>
<!-- Modal client edit -->
<div class="modal fade" id="oidcClientEditModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'oidc.editClientDialog.title' | tr:{ client: clientEdit.name } }}</h4>
</div>
<div class="modal-body">
<form name="clientEditForm" role="form" novalidate ng-submit="clientEdit.submit()" autocomplete="off">
<p class="text-danger" ng-show="clientEdit.error">{{ clientEdit.error }}</p>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.id' | tr }}</label>
<div class="input-group">
<input type="text" id="clientIdInput" class="form-control" ng-value="clientEdit.id" readonly/>
<span class="input-group-btn">
<button class="btn btn-primary" id="clientIdInputClipboardButton" type="button" data-clipboard-target="#clientIdInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.secret' | tr }}</label>
<div class="input-group">
<input type="text" id="clientSecretInput" class="form-control" ng-value="clientEdit.secret" readonly/>
<span class="input-group-btn">
<button class="btn btn-primary" id="clientSecretInputClipboardButton" type="button" data-clipboard-target="#clientSecretInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<div class="form-group">
<label class="control-label" for="inputEditClientName">{{ 'oidc.client.name' | tr }}</label>
<input type="text" id="inputEditClientName" class="form-control" name="clientName" ng-model="clientEdit.name" autofocus required/>
</div>
<div class="form-group">
<label class="control-label" for="inputEditLoginRedirectUri">{{ 'oidc.client.loginRedirectUri' | tr }}</label>
<input type="text" id="inputEditLoginRedirectUri" class="form-control" name="loginRedirectUri" ng-model="clientEdit.loginRedirectUri" required/>
</div>
<div class="form-group">
<label class="control-label">{{ 'oidc.client.signingAlgorithm' | tr }}</label>
<div class="control-label">
<select class="form-control" ng-model="clientEdit.tokenSignatureAlgorithm">
<option value="RS256">RS256</option>
<option value="EdDSA">EdDSA</option>
</select>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="clientEditForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="clientEdit.submit()" ng-disabled="clientEditForm.$invalid || clientEdit.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="clientEdit.busy"></i> {{ 'main.dialog.save' | tr }}
</button>
</div>
</div>
</div>
</div>
<!-- Modal client delete -->
<div class="modal fade" id="oidcClientDeleteModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'oidc.deleteClientDialog.title' | tr:{ client: deleteClient.id } }}</h4>
</div>
<div class="modal-body">
<p>{{ 'oidc.deleteClientDialog.description' | tr }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-danger" ng-click="deleteClient.submit()" ng-disabled="deleteClient.busy"><i class="fa fa-circle-notch fa-spin" ng-show="deleteClient.busy"></i> {{ 'main.dialog.delete' | tr }}</button>
</div>
</div>
</div>
</div>
<div class="content content-large">
<div class="text-left">
<h1>
{{ 'users.title' | tr }}
</h1>
</div>
<div class="card card-large">
<form name="profileConfigForm" role="form" novalidate ng-submit="profileConfig.submit()" autocomplete="off">
<fieldset ng-disabled="profileConfig.busy">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="profileConfig.editableUserProfiles"> {{ 'users.settings.allowProfileEditCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#lock-profile" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="profileConfig.mandatory2FA"> {{ 'users.settings.require2FACheckbox' | tr }}
</label>
</div>
</fieldset>
</form>
<br/>
<div class="row">
<div class="col-md-12">
<span class="has-error" ng-show="profileConfig.errorMessage">{{ profileConfig.errorMessage }}</span>
<button class="btn btn-outline btn-primary pull-right" ng-click="profileConfig.submit()" ng-disabled="!profileConfigForm.$dirty || profileConfig.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="profileConfig.busy"></i> {{ 'users.settings.saveAction' | tr }}
</button>
</div>
</div>
</div>
<div class="text-left section-header">
<h3>
{{ 'users.externalLdap.title' | tr }}
<div class="btn-group btn-group-sm pull-right">
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="externalLdap.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
<i class="fas fa-align-left"></i> <span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="task in externalLdap.tasks">
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
</a>
</li>
</ul>
</div>
</h3>
</div>
<div class="card card-large">
<div class="row">
<div class="col-md-12">{{ 'users.externalLdap.description' | tr }}</div>
</div>
<br/>
<div class="row" ng-show="externalLdap.currentConfig.provider === 'noop'">
<div class="col-xs-12">
<span class="text-muted">{{ 'users.externalLdap.noopInfo' | tr }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.provider' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.provider }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.server' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.url }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.acceptSelfSignedCert' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.acceptSelfSignedCerts ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.baseDn' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.baseDn }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.filter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.filter }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.usernameField' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.usernameField || 'uid' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.syncGroups' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.syncGroups ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupBaseDn' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupBaseDn }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupFilter' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupFilter }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupnameField' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.groupnameField }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.auth' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.bindDn ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.autocreateUsersOnLogin' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ externalLdap.currentConfig.autoCreate ? 'Yes' : 'No' }}</span>
</div>
</div>
<div class="row">
<br/>
<div class="col-md-12" style="margin-bottom: 10px;">
<div ng-show="externalLdap.busy" class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ externalLdap.percent }}%"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<p ng-show="externalLdap.busy">{{ externalLdap.message }}</p>
<p ng-hide="externalLdap.busy">
<div class="has-error" ng-show="!externalLdap.active">{{ externalLdap.errorMessage }}</div>
</p>
<button class="btn btn-primary pull-right" ng-click="externalLdap.show()">{{ 'users.externalLdap.configureAction' | tr }}</button>
<button class="btn btn-success pull-right" ng-disabled="externalLdap.currentConfig.provider === 'noop'" ng-click="externalLdap.sync()"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.syncBusy"></i> {{ 'users.externalLdap.syncAction' | tr }}</button>
<a class="btn btn-primary pull-right" ng-show="externalLdap.taskId" ng-href="/frontend/logs.html?taskId={{ externalLdap.taskId }}" target="_blank">{{ 'users.externalLdap.showLogsAction' | tr }}</a>
</div>
</div>
</div>
<div class="text-left section-header">
<h3>{{ 'users.exposedLdap.title' | tr }}</h3>
</div>
<div class="card card-large">
<div class="row">
<div class="col-md-12">
<div>{{ 'users.exposedLdap.description' | tr }}</div>
<br/>
<form name="userDirectoryConfigForm" role="form" novalidate ng-submit="userDirectoryConfig.submit()" autocomplete="off">
<fieldset>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userDirectoryConfig.enabled" ng-disabled="userDirectoryConfig.busy"> {{ 'users.exposedLdap.enabled' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#directory-server" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<div class="form-group">
<label class="control-label">{{ 'users.exposedLdap.secret.url' | tr }}</label>
<div class="input-group">
<input type="text" id="userDirectoryUrlInput" ng-value="'ldaps://' + config.adminFqdn + ':636'" readonly name="userDirectoryUrl" class="form-control"/>
<span class="input-group-btn">
<button class="btn btn-default" type="button" id="userDirectoryUrlClipboardButton" data-clipboard-target="#userDirectoryUrlInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
<p class="text-small text-warning text-bold" ng-show="adminDomain.provider === 'cloudflare'">{{ 'users.exposedLdap.cloudflarePortWarning' | tr }} </p>
</div>
<div class="form-group">
<label class="control-label">{{ 'users.exposedLdap.secret.label' | tr }}</label>
<p class="small" ng-bind-html=" 'users.exposedLdap.secret.description' | tr:{ userDN: 'cn=admin,ou=system,dc=cloudron' }"></p>
<input type="password" ng-model="userDirectoryConfig.secret" ng-disabled="!userDirectoryConfig.enabled || userDirectoryConfig.busy" name="userDirectorySecret" class="form-control" ng-class="{ 'has-error': !userDirectoryConfigForm.secret.$dirty && userDirectoryConfig.error.secret }" password-reveal/>
<div class="has-error" ng-show="userDirectoryConfig.error.secret">{{ userDirectoryConfig.error.secret }}</div>
</div>
<div class="form-group">
<label class="control-label">{{ 'users.exposedLdap.ipRestriction.label' | tr }}</label>
<p class="small" ng-bind-html=" 'users.exposedLdap.ipRestriction.description' | tr "></p>
<textarea ng-model="userDirectoryConfig.allowlist" ng-disabled="!userDirectoryConfig.enabled || userDirectoryConfig.busy" placeholder="{{ 'users.exposedLdap.ipRestriction.placeholder' | tr }}" name="allowlist" class="form-control" ng-class="{ 'has-error': !userDirectoryConfigForm.allowlist.$dirty && userDirectoryConfig.error.allowlist }" rows="4"></textarea>
<div class="has-error" ng-show="userDirectoryConfig.error.allowlist">{{ userDirectoryConfig.error.allowlist }}</div>
</div>
</fieldset>
</form>
<br/>
<div>
<span class="has-error" ng-show="userDirectoryConfig.error.generic">{{ userDirectoryConfig.error.generic }}</span>
<button class="btn btn-outline btn-primary pull-right" ng-click="userDirectoryConfig.submit()" ng-disabled="!userDirectoryConfigForm.$dirty || userDirectoryConfig.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="userDirectoryConfig.busy"></i> {{ 'users.settings.saveAction' | tr }}
</button>
</div>
</div>
</div>
</div>
<div class="text-left section-header">
<h3>{{ 'oidc.title' | tr }}</h3>
</div>
<div class="card card-large">
<div class="grid-item-top">
<div class="row">
<div class="col-md-12">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'oidc.env.discoveryUrl' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#endpoints" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></td>
<td class="text-right" style="vertical-align: top;" ng-click-select>https://{{ config.adminFqdn }}/.well-known/openid-configuration</td>
</tr>
</table>
</div>
</div>
</div>
<hr/>
<div>
<h4>{{ 'oidc.clients.title' | tr }} <button class="btn btn-primary btn-sm pull-right" ng-click="clientAdd.show()"><i class="fa fa-plus"></i> {{ 'oidc.clients.newClient' | tr }}</button></h4>
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 80%">{{ 'oidc.client.name' | tr }}</th>
<th style="width: 20%" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-show="oidcClients.length === 0">
<td colspan="3" class="text-center">{{ 'oidc.clients.empty' | tr }}</td>
</tr>
<tr ng-repeat="client in oidcClients">
<td class="text-left elide-table-cell hand" ng-click="clientEdit.show(client)">
{{ client.name }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-danger" ng-click="deleteClient.show(client)" uib-tooltip="Delete"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="clientEdit.show(client)" uib-tooltip="Edit"><i class="fa fa-pencil-alt"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>

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