Compare commits

..

1298 Commits

Author SHA1 Message Date
Johannes Zellner 0dfd217a86 Fixup support view if not yet logged into the appstore 2022-02-21 22:38:11 +01:00
Johannes Zellner 3766101122 Update translations 2022-02-21 21:39:38 +01:00
Johannes Zellner b7dfa318f3 Add missing col div in support view 2022-02-21 21:15:08 +01:00
Johannes Zellner 4ab52c6927 Move subscription required button in install dialog to the right 2022-02-18 18:16:27 +01:00
Johannes Zellner 41312354a7 Add Cloudron ldap provider option 2022-02-18 18:03:44 +01:00
Girish Ramakrishnan f2c6d45c1c Fix mailbox import 2022-02-17 18:04:32 -08:00
Girish Ramakrishnan 2d27a92587 Fix mailbox export
can also export as csv now
2022-02-17 16:37:48 -08:00
Girish Ramakrishnan 8258a8c777 mailbox: export the real owner info 2022-02-17 16:24:29 -08:00
Girish Ramakrishnan dd364733a4 Fix user import 2022-02-17 15:52:02 -08:00
Johannes Zellner 5e76a8ea7b Show ipv6 detection errors 2022-02-17 18:11:22 +01:00
Johannes Zellner 292034c0e2 If domain does not exist, go back to domain selection 2022-02-17 17:54:44 +01:00
Johannes Zellner 9481eccfb0 Make email import dialog translatable 2022-02-17 15:52:15 +01:00
Girish Ramakrishnan 9d8f21f78d mailboxes: be explicit about what is exported 2022-02-16 23:10:10 -08:00
Girish Ramakrishnan 9567efeb45 set jitsi in wellknown if matrix hostname is not set 2022-02-16 22:04:08 -08:00
Girish Ramakrishnan d7cb909600 also export fallback email and role 2022-02-16 21:23:01 -08:00
Girish Ramakrishnan 40e84265e7 Fix doc link 2022-02-16 21:14:58 -08:00
Girish Ramakrishnan 9665d1de3a user: fix export where csv does not active field 2022-02-16 21:14:58 -08:00
Girish Ramakrishnan 5db0ace3ed Remove superfluous call when listing users 2022-02-16 21:14:55 -08:00
Girish Ramakrishnan 436a5d726b backups: send chown flag for mountpoint 2022-02-16 12:03:40 -08:00
Girish Ramakrishnan 0dd97a0dc0 name and avatar return 200 2022-02-16 10:22:35 -08:00
Johannes Zellner d08fb224ba use class name pattern for status leds 2022-02-16 18:52:34 +01:00
Johannes Zellner 6351e0c3fb Apply same status led indicator for mailbox sharing 2022-02-16 18:48:22 +01:00
Johannes Zellner a8de1ca37b Update translations 2022-02-16 16:31:33 +01:00
Johannes Zellner 42501fa364 Introduce css classes for status LEDs 2022-02-16 16:31:22 +01:00
Girish Ramakrishnan 0d6c2dc1cf add ipv6 configure form 2022-02-15 13:28:46 -08:00
Girish Ramakrishnan 41099c1131 Fix ipv4 and ipv6 routes 2022-02-15 12:51:06 -08:00
Girish Ramakrishnan 7af69e080f network: hide IPv6 field as needed 2022-02-15 12:08:57 -08:00
Girish Ramakrishnan 1c32495f22 Show IPv6 address separately 2022-02-15 12:08:57 -08:00
Johannes Zellner d51d81cdfa Sort languages according to localeCompare() 2022-02-15 17:31:32 +01:00
Johannes Zellner aa17196120 Better integrate the app grid item action button 2022-02-15 15:26:03 +01:00
Johannes Zellner 262e06dc15 Do not overwrite the language set in localstorage 2022-02-14 18:16:50 +01:00
Johannes Zellner 9a148ab7f8 Fetch 5k users at once 2022-02-14 17:34:36 +01:00
Johannes Zellner 2ec4ad934d Add an explicit Client.getAllUsers function 2022-02-14 14:55:04 +01:00
Johannes Zellner b4bbdda730 Fix user export based on client.js api change 2022-02-12 19:47:00 +01:00
Johannes Zellner d0002eb7ca Fix bug when location is set without error 2022-02-10 15:41:41 +01:00
Johannes Zellner a764a8ad4a Expand the notification click area 2022-02-09 18:20:58 +01:00
Johannes Zellner 6552747290 Avoid showing incomplete loading state in support view 2022-02-09 17:04:20 +01:00
Johannes Zellner 15a4a7071e Remove now unsed translation strings 2022-02-09 17:00:12 +01:00
Johannes Zellner 219764923b Replace dyndns checkbox with toggle button 2022-02-09 16:55:36 +01:00
Johannes Zellner 8b35d01f68 Make the linter happy 2022-02-09 16:49:53 +01:00
Johannes Zellner 2afa13bd7c Replace checkbox with toggle button for ipv6 2022-02-09 16:48:38 +01:00
Johannes Zellner 23d34e59b2 Remove space 2022-02-09 16:31:38 +01:00
Johannes Zellner 2d999eae9c Update translations 2022-02-09 16:22:52 +01:00
Johannes Zellner 7fc92101d5 Avoid using unnecessary checkbox for solr config 2022-02-09 16:22:44 +01:00
Johannes Zellner 12fa9731b8 Make user filter translatable 2022-02-09 14:35:33 +01:00
Johannes Zellner c67a46e2a9 Actually send the overwriteDns for the clone api 2022-02-08 22:16:49 +01:00
Johannes Zellner 8a36e2c730 Half way fix for clone, we need to adjust the error reporting for the clone api from the backend 2022-02-08 22:11:41 +01:00
Johannes Zellner 7a66a104ad Update translations 2022-02-08 21:52:15 +01:00
Johannes Zellner 06d60d5aea Implement dns overwrite and pre-flight checks for multi domain clone 2022-02-08 21:52:03 +01:00
Johannes Zellner b4335f3d0d Fix angular expression typo 2022-02-08 18:05:53 +01:00
Johannes Zellner 0cc46a8dba Relayout userdirectory toolbar 2022-02-08 15:05:27 +01:00
Girish Ramakrishnan 2a2b509837 Fix error messages of clone UI 2022-02-07 23:02:31 -08:00
Girish Ramakrishnan 886515e444 clone UI now takes secondary domains 2022-02-07 22:56:34 -08:00
Girish Ramakrishnan d5640d45f7 do pre-flight dns check for secondary domains 2022-02-07 22:44:54 -08:00
Girish Ramakrishnan 27ec200fc0 main -> primary 2022-02-07 17:23:17 -08:00
Girish Ramakrishnan 4fead2411e Fix error display 2022-02-07 16:11:57 -08:00
Girish Ramakrishnan 9ae69bb683 do not use field inside error object 2022-02-07 13:44:26 -08:00
Girish Ramakrishnan f4c9d7324b typo 2022-02-07 09:27:15 -08:00
Johannes Zellner b9a76aa6b8 Add user state filter
pending layout fix if decided where it should be
the toolbar is getting crowded
2022-02-07 17:24:27 +01:00
Johannes Zellner f55c22bdb9 update monaco-editor 2022-02-05 20:55:48 +01:00
Johannes Zellner fc0e73657f Update xterm.js 2022-02-05 20:43:08 +01:00
Girish Ramakrishnan 1ca07a4c92 network: ipv6 better display of error 2022-02-04 11:16:04 -08:00
Girish Ramakrishnan 9f1ab59e35 Fix link 2022-02-03 10:09:23 -08:00
Girish Ramakrishnan 455cf1bf98 restore: set diskPath in mountOptions 2022-01-27 09:11:01 -08:00
Girish Ramakrishnan 29960b8d6b restore: add ext4 provider type 2022-01-27 09:11:01 -08:00
Johannes Zellner f074ed1ec9 Ensure we call the full appstore.js init() once the user has signed-in 2022-01-27 17:14:51 +01:00
Girish Ramakrishnan b741cfbb21 restore: setupToken must be set on the top level object 2022-01-26 10:28:36 -08:00
Johannes Zellner 7a6a9cdbb4 Fix copy and paste error in restore 2022-01-26 11:22:18 +01:00
Johannes Zellner 0940ef5b54 Remove oldschool tab borders 2022-01-25 15:29:40 +01:00
Johannes Zellner 6c51cd8d7b Use better import/export icons and tone down the buttons 2022-01-25 11:01:01 +01:00
Johannes Zellner 814809f103 Update translations 2022-01-24 14:16:27 +01:00
Johannes Zellner 961cce95d7 Add user import/export translations 2022-01-24 14:16:16 +01:00
Johannes Zellner 963af4334d Add JSON import and export of mailboxes 2022-01-23 22:26:23 +01:00
Johannes Zellner 7b8c721a8a Add preliminiary text for import format
Just as a placeholder for translation once the UI is finished
2022-01-23 22:26:10 +01:00
Johannes Zellner 09e9dd0938 Fix tooltip placement 2022-01-22 10:25:36 +01:00
Johannes Zellner 36b0d4e1bc Also support user export as csv 2022-01-22 09:28:47 +01:00
Girish Ramakrishnan 0af47bba54 add UI for secondary domains
part of cloudron/box#809
2022-01-21 17:31:04 -08:00
Girish Ramakrishnan 9697dd8b4f Fix possible type 2022-01-20 16:31:29 -08:00
Johannes Zellner b604311e2a Add basic support for csv user import 2022-01-20 17:38:47 +01:00
Girish Ramakrishnan 63394a666e rename location to subdomain 2022-01-16 18:49:22 -08:00
Girish Ramakrishnan fd9efe3da3 rename alternateDomains to redirectDomains 2022-01-14 22:38:27 -08:00
Girish Ramakrishnan 9109c89d8f make username mandatory when profile locked 2022-01-13 15:48:42 -08:00
Girish Ramakrishnan 2085a4a7d4 rename directoryConfig to profileConfig 2022-01-13 14:49:05 -08:00
Girish Ramakrishnan e0b6ce9bd8 remove multiple onReady 2022-01-13 14:49:05 -08:00
Johannes Zellner 82f6359547 Fix import/export tooltips 2022-01-13 17:25:12 +01:00
Johannes Zellner 5dd318b5ab First version of users import and export feature 2022-01-13 15:14:26 +01:00
Girish Ramakrishnan 7082dfd418 allow username to be set by admin, when username is empty 2022-01-12 16:36:09 -08:00
Girish Ramakrishnan 38211e719e Update packages 2022-01-12 16:36:09 -08:00
Girish Ramakrishnan fd545a43a6 mail: add autofocus for edit dialogs 2022-01-10 22:08:55 -08:00
Johannes Zellner 3a96bdd40a Add cifs seal support for volumes 2022-01-10 16:45:58 +01:00
Johannes Zellner df85a70ccc Add seal option to restore ui 2022-01-10 16:30:12 +01:00
Johannes Zellner 1ed91f40ab Update translations 2022-01-10 16:09:53 +01:00
Johannes Zellner 9ed19c8b8e Add cifs seal translation 2022-01-10 16:09:42 +01:00
Johannes Zellner 93982bae7b Support cifs seal option for backups 2022-01-10 15:55:00 +01:00
Johannes Zellner 5e046a26e9 Update russian translation 2022-01-07 17:13:33 +01:00
Johannes Zellner 6b009016b8 Add input field to configure user directory secret 2022-01-07 17:13:25 +01:00
Johannes Zellner 8bb4e947a0 Exposed ldap got renamed to user directory 2022-01-07 14:22:07 +01:00
Girish Ramakrishnan 346dc4f861 add ui to enable/disable ipv6 2022-01-06 21:57:36 -08:00
Girish Ramakrishnan ccc5f5f004 sysinfo: add ipv6 field for fixed interface 2022-01-06 21:38:23 -08:00
Girish Ramakrishnan ac1fd54cce server_ip returns ipv4 and ipv6 now 2022-01-06 12:51:08 -08:00
Girish Ramakrishnan 1180820b6f dnsConfig -> domainConfig 2022-01-05 23:16:34 -08:00
Girish Ramakrishnan 7ea495c361 Bump year to 2022, happy new year! 2022-01-05 09:18:26 -08:00
Johannes Zellner b241c82eba Update xterm.js to 4.16.0 2021-12-26 11:11:36 +01:00
Girish Ramakrishnan 128e3c41a3 remove extra arg in addDomain 2021-12-24 15:15:07 -08:00
Girish Ramakrishnan c25afaa94f Give upstreamVersion in manifest priority 2021-12-21 11:25:23 -08:00
Johannes Zellner f35abe1ea0 Fix email event log search when pagination was used 2021-12-16 11:22:17 +01:00
Johannes Zellner e33a1ca47d Update lock file to v2 with node 16 2021-12-16 10:45:57 +01:00
Johannes Zellner 765422ac38 Ensure sorting by domain and mailbox name for aliases 2021-12-15 16:06:22 +01:00
Johannes Zellner 0d3e9e32f0 Reduce vertical space for email aliases 2021-12-14 18:35:32 +01:00
Johannes Zellner bca91d4928 Ensure many email aliases don't overflow the table 2021-12-14 18:02:20 +01:00
Johannes Zellner 5d7ac82a69 Never skip backup on manual update by default 2021-12-14 10:21:52 +01:00
Girish Ramakrishnan 41587ec540 add missing space 2021-12-13 11:45:04 -08:00
Johannes Zellner 5307a187d5 Add a way to stop the mail location change task after 2 minutes 2021-12-13 20:42:35 +01:00
Girish Ramakrishnan 84712ecc10 notfound: better text message 2021-12-13 11:42:11 -08:00
Girish Ramakrishnan 0c849d0df4 Fix backup error message width 2021-12-13 09:40:41 -08:00
Johannes Zellner 3120edfe04 Better form disable handling 2021-12-10 17:53:52 +01:00
Johannes Zellner eaa7e3870b Improve exposed ldap error reporting 2021-12-10 17:47:23 +01:00
Johannes Zellner c775e8ae05 Clear exposed ldap errors correctly 2021-12-10 17:10:10 +01:00
Johannes Zellner d524118759 Fixup bottom margin of group header 2021-12-09 21:29:13 +01:00
Johannes Zellner 643a1a0080 Give user search field the initial focus 2021-12-09 21:24:51 +01:00
Johannes Zellner b6159aabae Remove page size selector and move pagination to the bottom 2021-12-09 21:21:21 +01:00
Johannes Zellner 52f1205822 Fix pagination in mail view 2021-12-09 21:00:43 +01:00
Girish Ramakrishnan f86f5189f0 remove old mailbox sharing section 2021-12-09 09:34:10 -08:00
Johannes Zellner f77f57dd17 Attempt to fix visual issues with pagination 2021-12-09 14:58:29 +01:00
Girish Ramakrishnan afd4c16763 Various fixes to mailbox sharing ui 2021-12-08 17:01:25 -08:00
Girish Ramakrishnan 9cad1c19c0 add ui to enable/disable mailbox sharing 2021-12-08 11:45:39 -08:00
Johannes Zellner ab6c352538 Update translations 2021-12-05 11:44:15 +01:00
Johannes Zellner 83bd86dd6d Use explicit translation for navbar users 2021-12-05 11:43:59 +01:00
Girish Ramakrishnan ea117b1654 wellknown: move the doc links to the description 2021-12-03 19:33:12 -08:00
Girish Ramakrishnan 8cbdea57d8 add jitsi to well-known config 2021-12-03 19:16:30 -08:00
Girish Ramakrishnan 8028b93f53 domains: put well known in separate dialog 2021-12-03 19:07:04 -08:00
Johannes Zellner d9cee38906 Make exposed LDAP section translatable 2021-12-03 14:19:21 +01:00
Johannes Zellner b0ba29ab3c Ensure title sizes are consistent in users view 2021-12-03 12:45:40 +01:00
Johannes Zellner e248b2aacf Provide select dropdown for app inbox 2021-12-03 11:23:25 +01:00
Girish Ramakrishnan b9b2ebe202 allow app to be set as mailbox owner 2021-12-02 22:22:52 -08:00
Girish Ramakrishnan 2ecdfcdbd2 Fix icon of mail manager 2021-12-02 17:58:43 -08:00
Girish Ramakrishnan 2077f1de21 hide outbound and status tabs for mail manager 2021-12-02 14:56:19 -08:00
Girish Ramakrishnan 3f33497c8e show email help in a documentation dropdown 2021-12-02 14:49:35 -08:00
Girish Ramakrishnan 5a35284f98 email: show text for logs 2021-12-02 12:55:36 -08:00
Girish Ramakrishnan 019bff5738 email: move buttons to the bottom right for consistency 2021-12-02 12:44:30 -08:00
Girish Ramakrishnan bf087c49a1 AtLeast and not Atleast 2021-12-02 12:21:59 -08:00
Girish Ramakrishnan 6617ecb114 Use isAtleastOwner instead of role directly 2021-12-02 09:32:51 -08:00
Girish Ramakrishnan 2c5b3d2c07 add mail manager role
part of cloudron/box#807
2021-12-02 09:29:35 -08:00
Girish Ramakrishnan 141d9fe4a6 split busy into inbox/mailbox busy 2021-12-01 20:37:44 -08:00
Johannes Zellner 19532428d0 Embedd the password show/hide icons into javascript as svg 2021-12-01 14:05:21 +01:00
Girish Ramakrishnan 845315f52c fix doc link 2021-11-30 17:38:37 -08:00
Girish Ramakrishnan f22e43e189 add cron description 2021-11-29 09:54:52 -08:00
Johannes Zellner 1efdb846f3 Pass allowlist for exposed directory server 2021-11-26 10:44:10 +01:00
Johannes Zellner a5d34306e5 Better separate external and exposed user directory support 2021-11-26 10:32:41 +01:00
Johannes Zellner 001c1fdc59 Add basic settings form for exposed LDAP 2021-11-24 17:08:38 +01:00
Johannes Zellner a01984cbef Update language files 2021-11-24 16:35:54 +01:00
Johannes Zellner 11d6916841 Fix language package download url 2021-11-24 16:35:47 +01:00
Johannes Zellner be03a21214 The clipboard copy need readonly and not disabled 2021-11-22 17:56:53 +01:00
Johannes Zellner 611c5de9f3 Allow deeplinking into the mail view 2021-11-19 15:45:16 +01:00
Johannes Zellner 9d97391c54 Fix missing DOM node in mail view 2021-11-19 10:12:39 +01:00
Girish Ramakrishnan e109797420 email: fix doc links 2021-11-18 11:48:30 -08:00
Johannes Zellner bf5ae85b6b Ensure reveal indicator is always inserted right after the input node 2021-11-17 14:39:44 +01:00
Johannes Zellner f3f968e995 Change positioning strategy for reveal button to fix more complex layouts 2021-11-16 13:20:22 +01:00
Johannes Zellner b54c6ff5c5 Ensure angular templates are hidden until ready 2021-11-15 20:53:55 +01:00
Johannes Zellner 8f58ee37ca Give buttons more space for languages using long strings 2021-11-13 20:54:05 +01:00
Johannes Zellner 12b2ee43d4 Preserve app filters in localStorage 2021-11-11 15:11:09 +01:00
Johannes Zellner 54c846fed6 Fix toolbar buttons in notification view for mobile 2021-11-08 21:50:44 +01:00
Johannes Zellner 9a975fae43 Update various node modules with security updates 2021-11-08 12:30:01 +01:00
Johannes Zellner a1b286acea Update xterm.js 2021-11-08 12:27:27 +01:00
Johannes Zellner bd0ddc26cc Update monaco-editor 2021-11-08 12:26:07 +01:00
Johannes Zellner 2ad69dcd93 Update yargs 2021-11-08 12:24:53 +01:00
Johannes Zellner 154f46a631 Bring sass and gulp deps up-to-date 2021-11-08 12:23:38 +01:00
Johannes Zellner ab0a45a394 Update caniuse database 2021-11-08 11:41:03 +01:00
Johannes Zellner 6fa8de468b Bring font-awesome up-to-date 2021-11-08 11:40:13 +01:00
Johannes Zellner ae5df83c5d Also attempt to set a favicon for proxy auth 2021-11-03 22:16:25 +01:00
Johannes Zellner 3f54f001b3 Add fontawesome to proxy auth 2021-11-03 22:12:44 +01:00
Johannes Zellner 86a6aa5014 Add missing file 2021-11-03 22:04:24 +01:00
Johannes Zellner 29fa26a9fc Also add password reveal to proxy auth form 2021-11-03 22:02:59 +01:00
Johannes Zellner 11fc5248c2 Add password-reveal directive for password inputs 2021-11-03 21:57:52 +01:00
Girish Ramakrishnan 45596e29cd update translations 2021-11-03 12:20:02 -07:00
Johannes Zellner 13637ef8f3 Remove redundant use strict 2021-11-03 19:47:34 +01:00
Johannes Zellner b6962fa0f7 Set viewport meta tag for proxy auth login 2021-11-03 19:30:19 +01:00
Girish Ramakrishnan 78f3ba06ed enable the password reset and ghost buttons for self 2021-11-02 14:02:15 -07:00
Girish Ramakrishnan 98b562e2e6 Disable the buttons instead of hiding them (like the delete button) 2021-11-01 16:09:36 -07:00
Girish Ramakrishnan a9fc6a2cba add gl translation to ignore list 2021-11-01 16:09:26 -07:00
Johannes Zellner 139bd32224 Fix cog icon on app grid item hover for darkmode 2021-10-29 12:05:58 +02:00
Girish Ramakrishnan 16ddff1d1a disable impersonate when no username instead of hiding 2021-10-28 10:26:41 -07:00
Girish Ramakrishnan d47cf5fd60 Show notification for invitation link sent 2021-10-28 10:23:07 -07:00
Girish Ramakrishnan 7675048563 update translations 2021-10-28 10:08:40 -07:00
Johannes Zellner e8b7591e7c Show notification on password reset like in profile page instead of closing the dialog 2021-10-28 19:04:02 +02:00
Johannes Zellner 0daf926740 Use profile based password reset from profile page 2021-10-28 18:52:40 +02:00
Johannes Zellner 6cc9d610f1 Hide impersonate button for users which do not have a username yet 2021-10-27 23:31:22 +02:00
Johannes Zellner 512345fd41 Show email instead of fallbackEmail if no username is set 2021-10-27 22:57:12 +02:00
Johannes Zellner 6dcfef639c Patch up clipboard buttons 2021-10-27 22:41:02 +02:00
Johannes Zellner 3c08be0168 Fix wrong indentation 2021-10-27 22:35:58 +02:00
Johannes Zellner 38901c7716 Update translations 2021-10-27 22:29:38 +02:00
Johannes Zellner 46f8c9a702 Fix typos 2021-10-27 22:29:11 +02:00
Johannes Zellner eee5b87a38 Implement new invite flow 2021-10-27 19:57:57 +02:00
Johannes Zellner e1bc2b7dfa Remove one superfluous div indentation 2021-10-27 19:41:17 +02:00
Johannes Zellner 94d654d7d0 Remove copy and paste error 2021-10-27 19:32:09 +02:00
Johannes Zellner 248116cc8a Hide password reset button for users from external ldap 2021-10-27 19:18:21 +02:00
Johannes Zellner 13d7381c62 Change password reset to have both link and email 2021-10-27 19:16:46 +02:00
Johannes Zellner 4ae90fc2da Update translations 2021-10-27 17:19:54 +02:00
Johannes Zellner 1d13fbaff1 fallbackEmail is not required in user edit anymore 2021-10-27 17:19:54 +02:00
Girish Ramakrishnan 6c5995b6ac app update: show danger button for unstable releases 2021-10-26 21:58:07 -07:00
Johannes Zellner d952b4485d Allow to specify fallbackEmail during user creation 2021-10-26 23:39:15 +02:00
Girish Ramakrishnan 789438690d change minimum backup memory limit to 800 2021-10-26 11:03:40 -07:00
Johannes Zellner 3ec02c68e2 Do not disable but hide app related doc links if not applicable 2021-10-25 21:22:39 +02:00
Johannes Zellner 470b876865 Add Russian translation 2021-10-25 13:08:43 +02:00
Johannes Zellner 877bfe2df2 Update translations 2021-10-25 13:08:32 +02:00
Girish Ramakrishnan 11dfcb4c8f email: import link has changed 2021-10-24 21:20:33 -07:00
Johannes Zellner a396237832 Do not break lines in the middle of the log file path 2021-10-21 17:34:07 +02:00
Girish Ramakrishnan 5236ccb61a mail: hide the filemanager button 2021-10-20 17:41:19 -07:00
Girish Ramakrishnan d2de2039d5 pretend to be busy for 3 seconds 2021-10-20 14:09:40 -07:00
Johannes Zellner 7ca757bb85 Show an indicator for operators of apps 2021-10-20 09:29:14 +02:00
Girish Ramakrishnan 9e14fe449a ghost: Save -> Set Password 2021-10-19 20:09:00 -07:00
Girish Ramakrishnan 7edb5c486a users: password reset changes 2021-10-19 19:36:23 -07:00
Girish Ramakrishnan f2cf630aa2 Give it 3 seconds 2021-10-19 19:14:34 -07:00
Girish Ramakrishnan a2b4d945a2 services: fix status color in recovery mode 2021-10-19 15:51:44 -07:00
Girish Ramakrishnan 9b8e16f990 better crontab paste 2021-10-19 11:21:09 -07:00
Girish Ramakrishnan e2ff07b388 Give eventlog time a bit more space 2021-10-19 09:56:38 -07:00
Girish Ramakrishnan e9a9578735 app: various eventlog fixes 2021-10-19 09:49:53 -07:00
Johannes Zellner 9e483a317d Do not duplicate app descriptor in app eventlog 2021-10-19 16:23:44 +02:00
Johannes Zellner dceb748fbe Fix typo 2021-10-19 16:07:59 +02:00
Johannes Zellner a06bc276c1 Align buttons in support view to be consistent
The email verification is like the appstore login button essential and
thus centered for focus.
2021-10-19 15:24:42 +02:00
Johannes Zellner f3dcf10ace Improve loading state in support view 2021-10-19 15:14:52 +02:00
Girish Ramakrishnan c0be926d99 move the email verification section to the top 2021-10-18 21:48:54 -07:00
Girish Ramakrishnan a5ed4ac6e9 Add link to forum 2021-10-18 18:13:44 -07:00
Girish Ramakrishnan 4b87d754fb grammar 2021-10-18 11:04:55 -07:00
Girish Ramakrishnan ec56b30cdc mail: add option to force from address for relays 2021-10-16 21:47:28 -07:00
Girish Ramakrishnan ea746b7741 mail: configure acl 2021-10-13 14:53:05 -07:00
Girish Ramakrishnan fb77bb0b37 mail: add spam event type 2021-10-12 18:28:30 -07:00
Johannes Zellner 46942efe07 Hide impersonate button for own user 2021-10-12 19:00:13 +02:00
Johannes Zellner 9545403e00 show subscription expired badge for all users 2021-10-12 18:50:23 +02:00
Johannes Zellner b089a1f580 Add remount button for mountlike backup configs 2021-10-11 18:07:31 +02:00
Johannes Zellner 332158baaa Update translations 2021-10-11 16:24:21 +02:00
Johannes Zellner 80f860493a Add volume remount button 2021-10-11 16:24:11 +02:00
Girish Ramakrishnan 67918900bf mail: rework the eventlog 2021-10-08 20:34:06 -07:00
Girish Ramakrishnan 0f4e71d478 mailbox: add checkbox for pop3 2021-10-08 10:22:18 -07:00
Girish Ramakrishnan 355a4df65f update translations 2021-10-07 09:13:15 -07:00
Johannes Zellner 776c82ccae Show backend error if remote SSH cannot be enabled 2021-10-07 17:16:27 +02:00
Johannes Zellner 7f0035a823 $scope.error() is long gone 2021-10-07 17:10:20 +02:00
Girish Ramakrishnan bcd6bdcd9b inbox name is required when inbox is enabled 2021-10-03 23:49:12 -07:00
Girish Ramakrishnan 7b973f88e8 app: add recvmail section
part of cloudron/box#804
2021-10-03 23:24:32 -07:00
Girish Ramakrishnan 0c48159244 make it bold 2021-10-01 14:28:09 -07:00
Girish Ramakrishnan 08e7b0946a services: add recoveryMode checkbox 2021-10-01 14:24:09 -07:00
Johannes Zellner 1fada45e4c Either show invite or passwort reset 2021-10-01 14:36:50 +02:00
Johannes Zellner f07978cf08 account setup uses an inviteToken now 2021-10-01 12:27:59 +02:00
Girish Ramakrishnan e9b24f7313 Show any last backup error
part of cloudron/box#797
2021-09-30 14:00:46 -07:00
Girish Ramakrishnan b27d439834 eventlog: use appName 2021-09-30 11:48:13 -07:00
Girish Ramakrishnan ede4da931c Add app backup eventlog 2021-09-30 11:44:37 -07:00
Johannes Zellner 843bbbbe58 Add email eventlog translation 2021-09-30 14:49:11 +02:00
Johannes Zellner e0fcc8ae4b Move email eventlog to separate view 2021-09-30 14:35:06 +02:00
Girish Ramakrishnan 6a3459e514 hardcode mountPoint in the backend instead 2021-09-29 22:35:13 -07:00
Girish Ramakrishnan 6c580646f3 better translation for volume name 2021-09-29 19:47:53 -07:00
Johannes Zellner e00671d697 Make cron patterns translatable 2021-09-28 20:40:36 +02:00
Johannes Zellner ca0ac18a62 Add common cron pattern dropdown 2021-09-28 19:58:41 +02:00
Johannes Zellner fd4ada4f4d Add text-monospace class and apply it to cron, csp and robots inputs 2021-09-28 19:31:52 +02:00
Girish Ramakrishnan feea08adee cron: add default text 2021-09-28 10:21:51 -07:00
Johannes Zellner 93e003b31e Adjust card min-height to fit for the added cron tab 2021-09-28 18:04:29 +02:00
Johannes Zellner ab54721c04 Ensure dropdown a-tags are not affected 2021-09-28 17:51:50 +02:00
Girish Ramakrishnan b408b7ff35 cron -> crontab 2021-09-27 21:42:01 -07:00
Girish Ramakrishnan ce323ca60a app: add cron section
part of cloudron/box#793
2021-09-27 21:19:31 -07:00
Johannes Zellner e1801b7a99 Make filesystem header a link if it is an app 2021-09-27 21:18:22 +02:00
Girish Ramakrishnan 6a28961dde filemanager: change button color of chown and rename 2021-09-27 11:43:56 -07:00
Girish Ramakrishnan 0d6abb9850 filebrowser: add mail restart button 2021-09-27 11:42:14 -07:00
Girish Ramakrishnan 8c3e369599 upcloud: add object storage integration 2021-09-27 10:47:22 -07:00
Girish Ramakrishnan 3c1b01a857 mail: expose maildata via filemanager
part of cloudron/box#794
2021-09-26 12:51:37 -07:00
Girish Ramakrishnan f7d3f611cd filemanager: re-order columns 2021-09-24 10:51:34 -07:00
Girish Ramakrishnan 84b45aad46 eventlog: service events 2021-09-24 10:31:16 -07:00
Johannes Zellner d1fa514499 Only call the API if values have changed for operators or access controls 2021-09-24 13:19:07 +02:00
Girish Ramakrishnan 371eea50bf eventlog: operator change 2021-09-23 09:29:29 -07:00
Johannes Zellner d32f133d98 Show 30 events by default 2021-09-23 16:31:39 +02:00
Johannes Zellner 5d7832bec1 Add simple eventlog pagination in apps view 2021-09-23 12:09:05 +02:00
Johannes Zellner 3a55daed2f Improve appstore listing speed 2021-09-23 01:14:55 +02:00
Johannes Zellner 195c5ab21a Fetch appstore listing as soon as possible 2021-09-23 00:33:40 +02:00
Johannes Zellner 74045b7de1 Remove toplevel source column in apps eventlog 2021-09-22 23:29:32 +02:00
Johannes Zellner 1c1a4d8af6 Remove test usage of update indicator 2021-09-22 23:22:03 +02:00
Johannes Zellner 49280f616a Let cog overlay sso indicator for operators 2021-09-22 22:51:47 +02:00
Johannes Zellner db3df9a3ea Move sftp login details to help dialog 2021-09-22 22:28:05 +02:00
Johannes Zellner ad7afe8646 Move sso indicator to the bottom and always show for non-admins 2021-09-22 21:59:37 +02:00
Girish Ramakrishnan c4a3240c22 operator: hide Email section
operator cannot list domains
2021-09-22 12:46:59 -07:00
Girish Ramakrishnan c37830697a move the sftp description to operator section 2021-09-22 10:58:12 -07:00
Girish Ramakrishnan 81fa792198 mail: port 465 (TLS) note 2021-09-22 08:48:45 -07:00
Girish Ramakrishnan 155baa346b sftp: remove the requireAdmin setting now that we have operators 2021-09-21 22:39:11 -07:00
Girish Ramakrishnan 26e9589842 operator: use limits route to get the max memory app can use 2021-09-21 22:29:05 -07:00
Girish Ramakrishnan b493355cbc operator: use the new app task status route 2021-09-21 22:19:34 -07:00
Girish Ramakrishnan 4062872299 operator: use app graphs route 2021-09-21 21:52:59 -07:00
Girish Ramakrishnan 4d9af6651a operator: only show clone and config download button if cloudron admin 2021-09-21 20:01:16 -07:00
Girish Ramakrishnan a8b50642f2 operator: use new app update check route 2021-09-21 19:55:48 -07:00
Girish Ramakrishnan f8ed17dd58 Use the new app eventlog route 2021-09-21 19:46:18 -07:00
Girish Ramakrishnan aecba53de5 add operators UI 2021-09-21 18:20:18 -07:00
Girish Ramakrishnan ee62e9c2e7 cloudflare: also show warning for SFTP access 2021-09-20 11:42:55 -07:00
Girish Ramakrishnan d4313fd6e5 show cloudflare port warning
fixes cloudron/box#802
2021-09-20 11:30:27 -07:00
Girish Ramakrishnan 398d9b0343 show manual warning for noop also 2021-09-20 11:13:29 -07:00
Girish Ramakrishnan 867d1dfcbe manual dns: show setup warning for bare domains as well 2021-09-20 11:06:28 -07:00
Girish Ramakrishnan d853598c5f clone: linode warning is obsolete 2021-09-20 10:52:20 -07:00
Johannes Zellner f6a74731ba Create a separate section in the user edit dialog to reset 2fa 2021-09-20 17:25:58 +02:00
Johannes Zellner de2d200c89 Add ghost feature translation 2021-09-17 16:08:13 +02:00
Johannes Zellner 8057b2454c Add initial ghost creation UI 2021-09-17 15:55:42 +02:00
Johannes Zellner a9b257c9ca Move invite and reset button to indicate a confirm dialog 2021-09-16 21:28:56 +02:00
Johannes Zellner d9e93f9110 Further dark mode fixes 2021-09-16 21:27:19 +02:00
Johannes Zellner fe2856195e Bring the invite checkbox back, only now disabled by default 2021-09-16 20:17:37 +02:00
Johannes Zellner aca618fb1e further darken disabled buttons in dark mode 2021-09-16 20:17:17 +02:00
Johannes Zellner 30456d68a5 Remove password input on add user dialog again 2021-09-16 19:59:18 +02:00
Johannes Zellner 96ddf076eb Ensure all invite and reset bits are translated 2021-09-16 16:03:07 +02:00
Johannes Zellner 97c8c2460e Add invitation logic back just like password reset 2021-09-16 15:46:26 +02:00
Johannes Zellner 8b15dbdd5b Create invite route is gone 2021-09-16 15:43:27 +02:00
Johannes Zellner ea3726f88b Plain password reset for a user 2021-09-16 14:56:24 +02:00
Johannes Zellner 14478919e6 Move 2fa reset into user edit dialog 2021-09-16 13:21:55 +02:00
Johannes Zellner e0d7238a10 Add pre-setup password error reporting 2021-09-16 09:05:40 +02:00
Johannes Zellner a6301d2b6c Add optional password field on usercreation 2021-09-16 09:01:46 +02:00
Johannes Zellner 455fbf36e0 Fix darkmode for disabled input fields 2021-09-15 22:38:13 +02:00
Johannes Zellner bdd26c7d17 Add basic eventlog for apps in app view 2021-09-14 12:17:38 +02:00
Johannes Zellner 82ede09908 Move eventlog helpers to shared Client 2021-09-14 12:17:17 +02:00
Johannes Zellner be574d371f Enable sshfs/cifs/nfs in app import UI 2021-09-14 11:37:36 +02:00
Johannes Zellner a0e85f5203 Require password for fallback email change 2021-09-13 14:10:09 +02:00
Johannes Zellner f8d0438c06 Make password reset logic translatable 2021-09-09 22:31:00 +02:00
Johannes Zellner 04eb179899 Add password reset action to profile page 2021-09-09 22:24:35 +02:00
Johannes Zellner d4ffba86a6 Update xtermjs to 4.14 2021-09-09 21:53:55 +02:00
Johannes Zellner 200949d49f Only disable ticket form if email is not validated 2021-08-26 13:04:55 +02:00
Johannes Zellner 97dbf0ee7b Hide ticket form if cloudron.io email is not yet verified 2021-08-25 18:36:46 +02:00
Johannes Zellner a1b4986060 Fix translation links to email 2021-08-25 14:11:42 +02:00
Johannes Zellner bbc9d35d53 show indicator if appstore email is not yet verified 2021-08-23 17:37:59 +02:00
Johannes Zellner 5d918b0fad Set autofocus in clone modal 2021-08-12 09:59:56 +02:00
Johannes Zellner ca10b2103a Update translation also fixes spanish invite email crash 2021-08-11 21:53:17 +02:00
Girish Ramakrishnan a76c4b9b56 remove debugs 2021-08-11 12:41:44 -07:00
Girish Ramakrishnan 6ac297bac5 password reset: show 2fa input 2021-08-11 12:28:54 -07:00
Girish Ramakrishnan 458a758ea7 refresh config after appstore login
1. create new cloudron
2. appstore login
3. go to domains view. cannot add more than one domain because config.features is not refreshed.
2021-08-10 14:43:10 -07:00
Girish Ramakrishnan 6d7f9b10bd addUser now returns an id 2021-08-10 13:53:28 -07:00
Girish Ramakrishnan 1994ca1ac7 remove purpose field 2021-08-10 13:31:01 -07:00
Johannes Zellner 47fe89a595 Show stopped apps with grayscale filter 2021-07-30 14:58:16 +02:00
Girish Ramakrishnan c6c96fd51f remove the identity_server from response
https://forum.cloudron.io/topic/5416/implement-well-known-matrix-client-endpoint/10
2021-07-29 14:36:30 -07:00
Girish Ramakrishnan 060737b0d5 well-known: set matrix/client 2021-07-29 11:59:16 -07:00
Johannes Zellner e3555236d4 Firefox does not like color hex codes in inline-svg 2021-07-29 18:52:01 +02:00
Johannes Zellner d93793eb81 Ensure setup also has a darkmode background 2021-07-29 12:17:44 +02:00
Johannes Zellner 01c0bd0e73 Update translations 2021-07-29 10:54:27 +02:00
Girish Ramakrishnan 62a56c455a webterminal: remove --rcfile
this broke sourcing of personal rcfile. see https://forum.cloudron.io/topic/5385/wp-cli-not-working-on-6-3-5
2021-07-22 13:03:33 -07:00
Johannes Zellner 3e4adc29f7 Update translation for sso login information 2021-07-13 20:07:07 +02:00
Johannes Zellner bd2bc5b264 Hide groups/tags/state filter in app listing 2021-07-13 20:06:27 +02:00
Johannes Zellner 17cc6becd2 Ensure breadcrumbs and hash are correctly updated on folder navigation 2021-07-13 11:31:59 +02:00
Girish Ramakrishnan 30d14f5359 sshfs: add required attribute for various fields 2021-07-09 22:40:52 -07:00
Johannes Zellner e7053c2790 filemanager: reset selection if directory has changed 2021-07-09 17:02:25 +02:00
Girish Ramakrishnan a0d71bb8b0 branding: fix error highlight with empty cloudron name 2021-07-08 12:01:17 -07:00
Girish Ramakrishnan 4d44a1ceb9 translate error message and not the error object 2021-07-08 08:48:55 -07:00
Girish Ramakrishnan 306aeb3225 better text instead of "Cloudron in the wild" 2021-07-08 00:22:15 -07:00
Johannes Zellner feb7366124 Update translations 2021-07-07 21:08:29 +02:00
Johannes Zellner 423c6f2f85 Make sso login hint translatable 2021-07-07 21:08:23 +02:00
Johannes Zellner 22633dc16e Give unread notifications a small left border 2021-07-07 19:27:31 +02:00
Johannes Zellner 9bbd1af259 Ensure notifications are only fetched and shown for at least admins 2021-07-07 19:07:43 +02:00
Johannes Zellner 2cb698c6bd setupaccount: Show input field errors below input field 2021-07-07 18:44:55 +02:00
Johannes Zellner d292d5d419 Have 3 explicit avatar options
custom image, gravatar or none
2021-07-07 16:23:03 +02:00
Johannes Zellner 2caac75dbd Always show alias and redirect dots 2021-07-06 19:09:47 +02:00
Johannes Zellner d75d1a717c Set focus automatically for new alias or redirect 2021-07-06 19:05:42 +02:00
Johannes Zellner f06c0530ce Fixup input element size 2021-07-06 18:00:30 +02:00
Johannes Zellner 59f257346d We don't allow setting notifications to unread anymore so also prevent state issues when decreasing the counter 2021-06-30 17:24:41 +02:00
Johannes Zellner 7c15c26fa9 Move update badge out of the main a tag 2021-06-30 16:06:32 +02:00
Girish Ramakrishnan 56c54f1ab1 volume: add filesystem type 2021-06-25 10:21:09 -07:00
Girish Ramakrishnan da0c07ff33 remove hardcoded hostPath
this is now moved to the backend since hostPath now relies on the
volume id (for the filemanager to work)
2021-06-24 16:59:13 -07:00
Girish Ramakrishnan 9b882499e8 sshfs: move the port near the server address 2021-06-24 15:04:51 -07:00
Johannes Zellner 4d2d04c232 Fix app update badge 2021-06-24 19:17:52 +02:00
Girish Ramakrishnan a723e3a4dd notification: periodic refresh 2021-06-23 23:17:42 -07:00
Girish Ramakrishnan 4dbd794b41 Fix display of backup warning
see cloudron/box#719
2021-06-23 22:12:08 -07:00
Girish Ramakrishnan 1a406c4d7d notifications: translate messages prefixed with tr: 2021-06-23 21:59:30 -07:00
Girish Ramakrishnan baf543ba00 app: disable mounts from import UI for now 2021-06-22 16:14:47 -07:00
Girish Ramakrishnan e06400bb71 mountpoint: set chown and preserveAttributes 2021-06-22 16:09:52 -07:00
Girish Ramakrishnan 1ee6560f30 restore: fix UI 2021-06-22 14:34:41 -07:00
Girish Ramakrishnan 5e5948ecd4 typo 2021-06-22 13:45:15 -07:00
Girish Ramakrishnan 7b768d6149 backup: fix location display 2021-06-22 13:18:09 -07:00
Girish Ramakrishnan e9029eb1f9 backups: clear mount options on provider change 2021-06-22 13:03:25 -07:00
Girish Ramakrishnan a7783fdb0d remove the "-" because systemd makes it as \x2d when escaping 2021-06-22 09:46:18 -07:00
Johannes Zellner 7bc76f2f34 Actually 500 and 501 status codes do not mean the box as such is offline 2021-06-22 16:25:11 +02:00
Johannes Zellner 7472e78755 filemanager: Only retry every 2sec if path opening doesn't work right away to avoid busy loop 2021-06-22 16:24:35 +02:00
Johannes Zellner 0e5f8e75f9 filemanager: openPath() is not on the scope anymore 2021-06-22 16:07:40 +02:00
Girish Ramakrishnan 0fdb7f0a93 backups: show host and remotePath for sshfs 2021-06-21 23:19:47 -07:00
Girish Ramakrishnan cc0705183a backups: hardcode mountpoint 2021-06-21 22:08:19 -07:00
Girish Ramakrishnan 2aee2c9e27 update translations 2021-06-21 16:44:39 -07:00
Girish Ramakrishnan ccc45a41e6 volumes: remove the edit ui 2021-06-21 16:23:34 -07:00
Johannes Zellner b27b4a38eb Only enable notification ack button if there are any unread ones 2021-06-21 14:04:25 +02:00
Johannes Zellner 90112de6e4 Bring back backup warning translations 2021-06-21 09:53:48 +02:00
Girish Ramakrishnan cad08380ea volumes: hide hostPath for non noop 2021-06-21 00:20:12 -07:00
Girish Ramakrishnan 2aecf0c96a volumes: clear any previous error 2021-06-18 23:30:50 -07:00
Girish Ramakrishnan e614703305 volumes: fixes for ext4 2021-06-18 23:03:18 -07:00
Johannes Zellner a2f1a1feb3 Add left padding for mailbox conditionally if radiobutton is shown 2021-06-17 12:34:49 +02:00
Johannes Zellner b0965b3ec7 Give more visual feedback for cog button 2021-06-17 12:32:16 +02:00
Girish Ramakrishnan 9f9f745f47 Add vultr object storage 2021-06-16 22:51:05 -07:00
Johannes Zellner 52ab35d8c6 Only show cog action on hover like before 2021-06-16 19:48:38 +02:00
Girish Ramakrishnan 5f78722c8f profile: refresh 2fa status 2021-06-16 08:37:38 -07:00
Girish Ramakrishnan 3da97fb7cb rename activity -> eventlog 2021-06-15 11:17:55 -07:00
Girish Ramakrishnan 9c191c6c11 clear vultr token 2021-06-15 11:04:34 -07:00
Johannes Zellner 095d9fd7fa Prevent external returnTo targets for login 2021-06-07 12:11:09 +02:00
Girish Ramakrishnan 2df8769fcf API has changed 2021-06-04 13:11:06 -07:00
Girish Ramakrishnan 043d6692f5 notifications: rename Clear All to Mark All as Read 2021-06-01 09:09:57 -07:00
Girish Ramakrishnan 10a377e083 vultr DNS 2021-05-29 23:01:50 -07:00
Girish Ramakrishnan 042d6099c4 notifications: always show all notifications 2021-05-29 16:18:03 -07:00
Girish Ramakrishnan e001a21e4b notifications: move vars/functions up a level 2021-05-29 16:08:11 -07:00
Girish Ramakrishnan 5aa6e18ea7 sync notification UI with backend changes 2021-05-29 16:03:38 -07:00
Girish Ramakrishnan 841c9bc261 add sshfs bits to restore and backup UI 2021-05-27 15:31:37 -07:00
Girish Ramakrishnan 218450880e restore: add mounts support 2021-05-27 15:01:44 -07:00
Girish Ramakrishnan fcee182ca3 Update translations 2021-05-27 14:13:38 -07:00
Girish Ramakrishnan 8590148803 typo 2021-05-27 13:19:13 -07:00
Girish Ramakrishnan d93c9b3c59 import is a state of its own now 2021-05-26 09:32:17 -07:00
Girish Ramakrishnan 4a238256e8 more post install variables 2021-05-25 11:33:02 -07:00
Johannes Zellner 5718775bf7 Do not wrap table column 2021-05-25 14:10:00 +02:00
Johannes Zellner 89754a62fe filemanager: Prevent and reset hash state during busy times 2021-05-20 19:26:20 +02:00
Johannes Zellner ecb93cb115 Better busy handling in filemanager on slow storage 2021-05-20 18:12:25 +02:00
Johannes Zellner e79c90f330 Prevent img preview flickering with an empty pixel image 2021-05-20 17:14:58 +02:00
Girish Ramakrishnan 691013d7e0 Make it a single line 2021-05-19 18:20:37 -07:00
Girish Ramakrishnan 5b3e800567 appstatus: better text 2021-05-19 14:01:41 -07:00
Girish Ramakrishnan a3c2fcf1b6 settings: display ubuntu version 2021-05-18 14:38:06 -07:00
Johannes Zellner 5d6a794d52 Add sshfs volume UI bits 2021-05-18 17:20:59 +02:00
Girish Ramakrishnan a54a404dac backups: add mount configuration and status 2021-05-17 22:50:49 -07:00
Girish Ramakrishnan a3ea2a32f1 volumes: auto-refresh state when activating 2021-05-14 10:46:31 -07:00
Girish Ramakrishnan 0148a46244 Fix color of mount status 2021-05-14 10:01:23 -07:00
Girish Ramakrishnan c680428b3c volume: show status message in edit dialog 2021-05-13 18:06:52 -07:00
Girish Ramakrishnan 247dcbfe11 add volume status indicator 2021-05-13 16:09:04 -07:00
Girish Ramakrishnan 95e2b726c1 volumes: update UI 2021-05-13 10:48:39 -07:00
Girish Ramakrishnan fd3fb23955 volumes: add mount UI 2021-05-12 23:47:45 -07:00
Girish Ramakrishnan c56c43c464 app backup filename now has fqdn
Part of cloudron/box#782
2021-05-08 17:32:12 -07:00
Girish Ramakrishnan 445325453b Source app specific bashrc 2021-05-03 23:08:48 -07:00
Girish Ramakrishnan d072682e82 load rc profile 2021-05-03 23:01:06 -07:00
Girish Ramakrishnan a3245278f0 Use ts to invalidate the browser image cache 2021-04-30 16:19:59 -07:00
Johannes Zellner 80d00577e5 Move username indicators to top 2021-04-20 21:05:52 +02:00
Johannes Zellner 2dd46b31a2 Only show login indicator for non-admins 2021-04-20 20:50:30 +02:00
Johannes Zellner 84bc28b371 Show usermanagement indicator in app grid 2021-04-20 20:43:37 +02:00
Johannes Zellner 8b0fbd8e77 Stop using deprecated developer/login in test scripts 2021-04-20 17:49:31 +02:00
Girish Ramakrishnan 375978b526 Setup -> Set up
Setup - noun
Set up - action/verb
2021-04-19 18:20:32 -07:00
Girish Ramakrishnan 1487823641 Disable 2FA -> Reset 2FA 2021-04-19 17:48:21 -07:00
Johannes Zellner a68a4ce36b Tone down app item focus and reduce UI shakiness 2021-04-16 15:52:56 +02:00
Girish Ramakrishnan 81c393153b mail: add active flag to mailbox and lists 2021-04-15 11:38:19 -07:00
Girish Ramakrishnan e4076d7a75 minor fixes to reset dialog 2021-04-15 11:38:19 -07:00
Johannes Zellner d5201a29da Add new translations 2021-04-15 17:38:32 +02:00
Johannes Zellner f06b4e5b1d Give feedback on password email sent 2021-04-15 17:31:08 +02:00
Johannes Zellner 11cc074a09 Rework invite/password reset UI 2021-04-15 17:16:15 +02:00
Johannes Zellner 737b9fb73e Add 2FA disable button in password reset dialog 2021-04-15 10:54:55 +02:00
Girish Ramakrishnan d0f0dc7339 Add client API to disable user 2fa 2021-04-14 20:58:43 -07:00
Girish Ramakrishnan 09368dd267 2fa routes have changed 2021-04-14 20:42:37 -07:00
Johannes Zellner f964178682 Update translation 2021-04-13 15:49:56 +02:00
Johannes Zellner ba7ef8e7f0 Add app stop modal 2021-04-13 13:39:46 +02:00
Johannes Zellner 4e2a5e6f15 Remove extra p-tag closing 2021-04-13 12:53:50 +02:00
Johannes Zellner f54ffa796f Fix drop-shadow for tabview 2021-04-13 12:49:47 +02:00
Johannes Zellner 3e00e924f8 Use power-off icon for app stop 2021-04-13 12:49:36 +02:00
Johannes Zellner 592c50ba75 Ensure the hitbox for action button is not overlayed 2021-04-13 12:29:57 +02:00
Johannes Zellner 5c79ac8893 Use single cog and dark-mode fixes 2021-04-07 14:03:13 +02:00
Johannes Zellner 01dddade5a Fixup the app grid item height 2021-04-07 13:02:50 +02:00
Johannes Zellner b83f263919 Better show focus on app items 2021-04-07 13:02:27 +02:00
Johannes Zellner 82e8a893fd Cleanup and reorder some scss 2021-04-07 13:02:12 +02:00
Johannes Zellner 27236a5692 Move shared image error handler to client.js 2021-04-07 10:47:29 +02:00
Johannes Zellner 6e4b9d8196 Fix indentation 2021-04-07 10:41:50 +02:00
Johannes Zellner 36df6b9e1e Group terminal/logs/filemanager toolbar buttons 2021-04-07 10:36:01 +02:00
Johannes Zellner ab1d3f41fa Replace action button dropdown with simple button to app details view 2021-04-07 10:22:21 +02:00
Johannes Zellner dbeb523882 Replace hover actions in app grid with dropdown 2021-04-06 18:27:59 +02:00
Johannes Zellner aae2a36d1e Put play/pause or start/stop button toplevel 2021-04-06 16:22:44 +02:00
Girish Ramakrishnan 2d4323a72c make the configure button disabled instead of hidden 2021-04-05 10:24:56 -07:00
Girish Ramakrishnan da008874dc repair: reinstall if no backup 2021-04-05 10:22:50 -07:00
Johannes Zellner ae36ce07d1 Make prettyDates translatable 2021-04-01 17:04:46 +02:00
Johannes Zellner b9ef941b80 Make backup check messages translatable 2021-04-01 16:36:52 +02:00
Johannes Zellner 465fc427d6 Fix translation for graph time selection and display 2021-04-01 16:05:13 +02:00
Johannes Zellner 850ff87849 Also make multiselect filter placeholer translatable 2021-03-31 17:27:48 +02:00
Johannes Zellner bc45423eca Make the multiselect widget translatable 2021-03-31 17:21:45 +02:00
Johannes Zellner 92cb5f3583 Consolidate and improve search placeholder translation 2021-03-31 17:01:00 +02:00
Johannes Zellner 9a5bd8a846 Use 24h time instead of 12h to avoid localization requirement 2021-03-31 14:42:10 +02:00
Johannes Zellner 4393143ee8 Fixup the missing translation for registry settings 2021-03-31 14:32:11 +02:00
Johannes Zellner ddb29fd85b Enable translation for external LDAP other and disabled option 2021-03-31 14:20:16 +02:00
Johannes Zellner c04951c45e Add missing translation key for mailinglist name input label 2021-03-31 14:09:17 +02:00
Johannes Zellner ec50163b66 Ensure appstore categories are alphabetical in all languages 2021-03-31 14:03:49 +02:00
Johannes Zellner 9c7241e9ac Update translation for new category keys 2021-03-30 15:04:01 +02:00
Johannes Zellner 3b5c0c2e63 Sort categories in code alphabetically 2021-03-30 15:03:51 +02:00
Johannes Zellner c69c8b57c4 Always allow long tooltips
This is required since the translation we don't know upfront
2021-03-30 13:31:25 +02:00
Johannes Zellner c9628970d9 Do not show scary red error notifications on timeouts or other 500
Instead dump to terminal with email instructions
2021-03-29 10:37:15 +02:00
Girish Ramakrishnan ee0c50bea2 debug mode: make fs writable
otherwise, people have to use CLI tool.
2021-03-26 13:20:40 -07:00
Johannes Zellner 3c527b7064 Poor man's quoting fix for auth proxy in french 2021-03-23 20:59:01 +01:00
Johannes Zellner 0bd250a34b filemanager: Fix deep copying 2021-03-23 14:51:51 +01:00
Johannes Zellner ff5ad8b062 Avoid rendering native select widget 2021-03-22 10:46:39 +01:00
Johannes Zellner 3b38889f32 Fixup admin link in app grid for mobile 2021-03-19 10:53:56 +01:00
Girish Ramakrishnan a6f202be04 enableMailbox is a string for the radio buttons 2021-03-18 18:26:33 -07:00
Johannes Zellner 9161e5f7e8 filemanager: Fix state issue with editor 2021-03-18 16:43:33 +01:00
Johannes Zellner 12e32cc8ff Disable mail from input if app manages it on its own 2021-03-18 15:46:43 +01:00
Johannes Zellner 394b784106 Give the mail from save button a bit more space 2021-03-18 15:45:11 +01:00
Johannes Zellner a3a928367b Fix mail from setting description indentation 2021-03-18 15:44:35 +01:00
Johannes Zellner bacdf2c87c Fix radio button alignment like checkboxes 2021-03-18 15:42:18 +01:00
Johannes Zellner 53e50912e6 Allow local network development to test mobile/iOS better 2021-03-18 14:43:17 +01:00
Johannes Zellner 5d5c712f1c Some cleanups for showing popular and section names depending on category and search 2021-03-18 14:28:22 +01:00
Johannes Zellner 050ea48e3e Show popular apps first when no category is selected 2021-03-18 14:14:22 +01:00
Girish Ramakrishnan 09e07868bb fix up optional sendmail ui 2021-03-17 15:52:21 -07:00
Girish Ramakrishnan 613ac16601 Fix display of user management/dashboard visiblity for email apps 2021-03-17 14:22:33 -07:00
Girish Ramakrishnan 84cf5809a0 Do not show user management string for sogo 2021-03-17 14:11:08 -07:00
Johannes Zellner 20b42042da Put radiobuttons instead of checkboxes for mail from setting 2021-03-17 16:40:17 +01:00
Girish Ramakrishnan 10fee49e9a make mailbox optional 2021-03-17 02:36:01 -07:00
Girish Ramakrishnan b18f42b372 tokens: remove expiresAt and add lastUsed 2021-03-16 16:06:35 -07:00
Johannes Zellner 515d93f5ef filemanager: find a new unique name when file copy clashes 2021-03-16 21:24:26 +01:00
Johannes Zellner 10d1bb861a filemanager: Skip keyboard actions if modal dialogs are open 2021-03-16 18:17:38 +01:00
Johannes Zellner 0abf1e76d4 filemanager: Select entry on context menu button clicked 2021-03-16 18:11:28 +01:00
Girish Ramakrishnan 168636e493 renewCerts: do not pass domain 2021-03-13 21:52:37 -08:00
Johannes Zellner 4dffce0b71 Apply node module security updates 2021-03-10 10:40:55 +01:00
Johannes Zellner a547d0dc05 Update monaco editor 2021-03-10 10:38:12 +01:00
Johannes Zellner 19d0af9e71 Update xterm.js 2021-03-10 10:36:38 +01:00
Girish Ramakrishnan 14e1e2fc71 add note on ovh storage vs s3 subdomain usage 2021-03-05 10:31:20 -08:00
Girish Ramakrishnan 7e02996b28 Revert "ovh: storage URLs have changed"
This reverts commit 9e853c07eb.

The URL change is incorrect. storage subdomain is for openstack
2021-03-05 00:15:43 -08:00
Girish Ramakrishnan 68c67f351e add ionos to import and restore UI 2021-03-04 12:03:48 -08:00
Girish Ramakrishnan 818185bf5b DO: add SFO3 region 2021-03-03 08:53:27 -08:00
Johannes Zellner 26847d52f7 filemanager: implement direct entry and thus history support 2021-03-03 17:01:51 +01:00
Johannes Zellner f1625ed345 filemanager: Add explicit context-menu button 2021-03-03 14:22:21 +01:00
Girish Ramakrishnan 244d84e168 Add doc link for dashboard visiblity 2021-03-02 20:51:38 -08:00
Girish Ramakrishnan 6c01a5d9bb hide registy config with noop backend 2021-03-02 18:43:15 -08:00
Girish Ramakrishnan a1b6b20bdd there is a fake unset group 2021-03-02 17:44:36 -08:00
Girish Ramakrishnan 530bef34f0 translate group filter header 2021-03-02 17:34:06 -08:00
Johannes Zellner f85e787e01 We actually move files not copy 2021-03-02 16:54:14 +01:00
Johannes Zellner 44ce9024eb Do not show error if item is dropped on itself 2021-03-02 16:51:21 +01:00
Johannes Zellner a79c435bdc Better handle this ourselves instead of angular 2021-03-02 16:31:31 +01:00
Johannes Zellner 5f961bada4 Revert "encodeURI all REST api paths to preserve whitespaces"
This reverts commit a63e0ed287.
2021-03-02 16:24:15 +01:00
Johannes Zellner a63e0ed287 encodeURI all REST api paths to preserve whitespaces 2021-03-02 16:09:58 +01:00
Johannes Zellner 99f096c971 Make sure paths in request json bodies are not url encoded 2021-03-02 11:12:43 +01:00
Johannes Zellner 2ccbe61b27 Never pre-select any acl if at least one group exists 2021-03-02 09:47:24 +01:00
Girish Ramakrishnan 282edca008 Fix doc link 2021-03-01 11:36:07 -08:00
Johannes Zellner 09840df51a Also enable app visibility selector for email apps 2021-03-01 20:16:05 +01:00
Johannes Zellner 10a459726a Fix html rendering in translated email access string 2021-03-01 19:45:09 +01:00
Johannes Zellner b9501d69a5 Allow to select dashboard visibility during app installation also 2021-03-01 18:52:10 +01:00
Johannes Zellner 34cd750121 Update en translation to add default Spanish language 2021-03-01 10:03:55 +01:00
Johannes Zellner 8ccbb03d69 Add spanish translation 2021-03-01 09:58:48 +01:00
Johannes Zellner 16d209e0c8 Add french translation 2021-03-01 09:58:26 +01:00
Girish Ramakrishnan d61e3407b4 show the cancel task button sooner 2021-02-26 11:47:48 -08:00
Girish Ramakrishnan ca27288d43 update translations 2021-02-26 10:13:21 -08:00
Girish Ramakrishnan d787c430ce Better footnote for dry run 2021-02-25 10:53:32 -08:00
Girish Ramakrishnan 7fb4fbdaf3 update translations 2021-02-24 22:46:52 -08:00
Girish Ramakrishnan 8dc0236e89 Add UI to sync dns 2021-02-24 22:18:39 -08:00
Girish Ramakrishnan 5f0ff047d4 setDnsRecords API has changed 2021-02-24 22:00:05 -08:00
Girish Ramakrishnan bdbbc78497 2021 is here 2021-02-24 17:08:43 -08:00
Girish Ramakrishnan 0980a394b7 restore: skipDnsSetup flag 2021-02-24 15:54:11 -08:00
Girish Ramakrishnan 5cd23ff19f typo 2021-02-24 09:52:07 -08:00
Girish Ramakrishnan 79e4d6e317 remove untranslated string 2021-02-24 09:36:21 -08:00
Johannes Zellner 2ae80312ee Do not update task info for non-admins 2021-02-24 17:12:18 +01:00
Johannes Zellner cec4d51649 Allow portbindings to be below 1024
This is required for example for adguard using port 53
2021-02-19 17:34:05 +01:00
Johannes Zellner f39fd36b85 Ensure if no sso option is there we preset correctly 2021-02-19 17:02:03 +01:00
Johannes Zellner ad94b75607 Update translations and add vietnamese 2021-02-18 17:11:00 +01:00
Johannes Zellner 670f34fde6 Allow to set access to apps from groups edit form 2021-02-18 17:01:49 +01:00
Johannes Zellner bfb22c04b7 Make callback optional for refreshInstalledApps() 2021-02-18 17:01:35 +01:00
Johannes Zellner 550d6b9868 Fix linter issues 2021-02-18 16:16:42 +01:00
Johannes Zellner abb5a2c985 Move email logs button to the same place as system logs 2021-02-18 11:25:38 +01:00
Johannes Zellner d377a23ebc If groups are used, do not allow app installation without choosing the access settings 2021-02-17 17:10:39 +01:00
Girish Ramakrishnan 9ca6ac7080 mail: refresh config on location change 2021-02-16 12:40:36 -08:00
Johannes Zellner 3098917d55 Add access control group filter 2021-02-16 20:15:16 +01:00
Girish Ramakrishnan b7bc5d38bd services: add a refresh button 2021-02-15 11:38:51 -08:00
Johannes Zellner 891cb135f4 Update lock file 2021-02-15 17:42:11 +01:00
Johannes Zellner 6427f16743 Always show English versions of languages to avoid re-setting issues 2021-02-15 17:41:59 +01:00
Johannes Zellner 1efcfc69f4 Add netcup to dns setup screen 2021-02-11 17:01:37 +01:00
Girish Ramakrishnan 25e59adb2a Update translations 2021-02-09 15:39:56 -08:00
Girish Ramakrishnan eb7530b5fd add provider to registry config
this way there is a clear way to disable/remove config
2021-02-09 15:36:28 -08:00
Johannes Zellner 419e1d65e4 Ensure we always set the correct language 2021-02-08 21:36:22 +01:00
Girish Ramakrishnan bec228b854 ionos: add profit bricks object storage 2021-02-04 10:14:42 -08:00
Girish Ramakrishnan 9e853c07eb ovh: storage URLs have changed
https://forum.cloudron.io/topic/4362/possibly-needing-to-update-ovh-object-storage-url
2021-02-03 16:33:43 -08:00
Girish Ramakrishnan 4d2ba3b082 make it clear what app password gives access to 2021-02-03 13:15:38 -08:00
Johannes Zellner 5f0889c281 Remove duplicate string in terminal 2021-02-03 20:10:07 +01:00
Girish Ramakrishnan 36552f651d backups: fix broken UI in config page 2021-02-01 14:16:35 -08:00
Johannes Zellner 64ea5e827b filemanager: make copy of entries into select to avoid reference 2021-02-01 20:32:32 +01:00
Johannes Zellner da6ee44a2a filemanager: Give visual feedback on breadcrumb drop 2021-02-01 20:28:55 +01:00
Johannes Zellner cc34847de9 filemanager: allow drop on breadcrumbs 2021-02-01 20:19:54 +01:00
Johannes Zellner 567f430b7b filemanager: Prepare for multiselect drag'n'drop but don't use it yet 2021-02-01 19:59:45 +01:00
Johannes Zellner ccd481e64a filemanager: ensure files are refreshed on paste from copy 2021-02-01 18:20:48 +01:00
Johannes Zellner f420107704 filemanager: only call callback if it is a function 2021-02-01 18:20:25 +01:00
Johannes Zellner cd4b6c448c filemanager: Add select all and deselect on escape 2021-02-01 18:17:54 +01:00
Johannes Zellner 3e04da7062 Implement keyboard shortcuts for copy/cut/paste 2021-02-01 17:48:57 +01:00
Johannes Zellner f47015223c filemanager: recursive copy 2021-02-01 17:20:51 +01:00
Johannes Zellner 2935fa6a36 filemanager: implement copy for files 2021-02-01 16:45:55 +01:00
Girish Ramakrishnan 436c54b829 Update italian translation 2021-01-31 11:15:50 -08:00
Johannes Zellner 7e970a175d filemanager: Add more UI actions 2021-01-30 23:54:00 +01:00
Johannes Zellner e18b522ff3 Allow to paste into directory entry 2021-01-30 23:26:32 +01:00
Johannes Zellner 77cb64369b filemanager: add basic cut/paste 2021-01-30 23:23:34 +01:00
Johannes Zellner bd4423c9c6 filemanager: Remove individual action buttons 2021-01-30 22:21:19 +01:00
Johannes Zellner af390db21e filemanager: Add Edit menu item 2021-01-30 22:02:55 +01:00
Johannes Zellner 1f3eeb4f43 filemanager: make chown support multiple files 2021-01-30 18:20:06 +01:00
Johannes Zellner 778317aa8a Update translation 2021-01-30 17:16:22 +01:00
Johannes Zellner c12dfcef54 filemanager: allow to delete all selected files 2021-01-30 17:08:32 +01:00
Johannes Zellner 5880101d9a Update dependencies 2021-01-30 12:17:29 +01:00
Johannes Zellner 13cc4fb045 revision needs to be quoted to become a string 2021-01-30 11:55:21 +01:00
Johannes Zellner 66450913b5 filemanager: Implement multiselect with cmd/ctrl key 2021-01-29 18:35:35 +01:00
Johannes Zellner 3d55cfaaca filemanager: Use double click to open 2021-01-29 11:55:35 +01:00
Johannes Zellner c435d4d35a Add modified column to filemanager 2021-01-29 11:53:02 +01:00
Johannes Zellner 719b7485bd Move pretty date filter to shared client.js 2021-01-29 11:52:47 +01:00
Johannes Zellner 7d60ef5fd6 Bust translation cache on updates 2021-01-29 11:18:26 +01:00
Johannes Zellner 6820a3def1 Fixup linter issues 2021-01-29 11:15:33 +01:00
Girish Ramakrishnan 364fe2f29f $timeout is not used 2021-01-28 10:07:35 -08:00
Girish Ramakrishnan b146d78525 Fix disk usage graphs 2021-01-27 21:48:16 -08:00
Johannes Zellner 012968003f The progress callback already gives the whole uploaded size 2021-01-27 16:47:29 +01:00
Girish Ramakrishnan 0d9bc325fa Fix doc urls 2021-01-26 22:09:36 -08:00
Girish Ramakrishnan 3a26f63c2a fix reading matrix config 2021-01-26 22:07:21 -08:00
Johannes Zellner de74b05703 Update english translation 2021-01-26 19:32:39 +01:00
Girish Ramakrishnan df24a6ab32 update translations 2021-01-25 22:41:41 -08:00
Johannes Zellner 08e8ce4d75 Fix sfp docs link typo 2021-01-25 16:42:33 +01:00
Girish Ramakrishnan d627b6b0b0 add some space between image and description 2021-01-22 12:41:26 -08:00
Girish Ramakrishnan ed395d74bc rebuild if service not found 2021-01-21 17:41:16 -08:00
Girish Ramakrishnan 1c5d56c28f remove note on memory/swap split 2021-01-20 09:17:05 -08:00
Girish Ramakrishnan 370f8d7cad memorySwap/memory is now just a single memoryLimit 2021-01-19 19:48:33 -08:00
Johannes Zellner a33e662c47 Swap description and select in owner transfer dialog 2021-01-19 22:27:55 +01:00
Johannes Zellner d881dd6228 Update translations 2021-01-19 22:26:50 +01:00
Johannes Zellner 43c0cd034c revert owner transfer flow to avoid too many action buttons 2021-01-19 22:26:43 +01:00
Johannes Zellner 8fa890e0d0 Update translations 2021-01-19 16:13:30 +01:00
Johannes Zellner 98f6871d8b Add netcup dns provider 2021-01-19 10:53:05 +01:00
Girish Ramakrishnan fa1104fd75 update translations 2021-01-18 19:44:44 -08:00
Girish Ramakrishnan 2e17e444aa Add domain alias UI 2021-01-18 18:02:06 -08:00
Girish Ramakrishnan 7835785aac Fix incorrect app password filter 2021-01-17 18:17:54 -08:00
Johannes Zellner 3a031064fb Add italian and chinese translation, since they are over 60% done 2021-01-17 20:24:02 +01:00
Johannes Zellner 83e425dccc Update translations 2021-01-17 20:23:12 +01:00
Johannes Zellner 1b09a80caa Use new ownership transfer route 2021-01-15 14:28:52 +01:00
Johannes Zellner 32d7b2fe34 Only show transfer ownership button for owner 2021-01-14 21:16:28 +01:00
Johannes Zellner e0f6ddfcf7 Call rest api to transfer ownership 2021-01-14 20:47:30 +01:00
Johannes Zellner 2086444a9e Add dutch translation 2021-01-14 17:15:10 +01:00
Johannes Zellner 117b58fcbe Add initial UI for transferring ownership 2021-01-14 17:14:32 +01:00
Johannes Zellner 3d441748c4 Update translations 2021-01-14 17:14:12 +01:00
Johannes Zellner 7f3eae23a1 Add userRoles and userGroups comment for override 2021-01-14 16:49:26 +01:00
Girish Ramakrishnan 8392642f5f catch exception for custom hour 2021-01-13 17:09:55 -08:00
Girish Ramakrishnan e15639583d ensure box.update is actually valid 2021-01-13 17:09:55 -08:00
Johannes Zellner 2ae62957d4 Do not translate subscription strings in user view 2021-01-13 16:54:25 +01:00
Johannes Zellner 9678b7d966 Restrict user roles 2021-01-13 16:19:14 +01:00
Johannes Zellner 896f55f6d3 Add user group subscription dialog 2021-01-13 15:03:26 +01:00
Johannes Zellner 74faa29012 Restrict user groups 2021-01-13 14:49:23 +01:00
Girish Ramakrishnan 5fc8a50ae1 more caas code removal 2021-01-12 19:49:20 -08:00
Girish Ramakrishnan ee68315e70 make email view content larger 2021-01-12 19:38:35 -08:00
Girish Ramakrishnan d16d8729c0 Add refresh button in mail eventlog 2021-01-12 19:30:30 -08:00
Girish Ramakrishnan b28fb72670 function name has changed 2021-01-12 19:22:18 -08:00
Girish Ramakrishnan 1b6e157525 Add federated section 2021-01-12 09:25:52 -08:00
Johannes Zellner 383147b26a Fixup linter issues in filemanager.js 2021-01-12 14:29:04 +01:00
Johannes Zellner 6ede67512d Add .jshintrc 2021-01-12 14:28:54 +01:00
Johannes Zellner 57b19457e4 Handle ctrl/cmd + s for saving in filemanager editor 2021-01-11 16:15:01 +01:00
Girish Ramakrishnan 9a41594ec3 rename splash to notfound.html
part of cloudron/box#755
2021-01-08 09:37:38 -08:00
Girish Ramakrishnan b5ef4e0e6d make copy of aliases array 2021-01-07 22:07:20 -08:00
Girish Ramakrishnan d7a2732dc6 use the new mailbox API that has aliases
part of cloudron/box#738
2021-01-07 22:02:26 -08:00
Girish Ramakrishnan f594abaa71 domains: add wellknown ui
fixes cloudron/box#703
2021-01-07 19:54:51 -08:00
Girish Ramakrishnan 9c35be2fdc updated translations 2021-01-07 19:32:04 -08:00
Johannes Zellner 79bd284179 Ensure cloudron name cannot be longer than 64 2021-01-07 22:52:57 +01:00
Johannes Zellner 8e6ceb2e66 Add sshfs/cifs/nfs to restore ui 2021-01-07 19:41:39 +01:00
Johannes Zellner 07c9699895 Fix syntax error in angular template 2021-01-07 19:16:34 +01:00
Johannes Zellner af0d78e720 Do not error on form itself 2021-01-07 18:40:51 +01:00
Johannes Zellner 46dabee6ee Add sshfs/cifs/nfs backup storage to app import 2021-01-07 18:28:32 +01:00
Girish Ramakrishnan a723cee47f Add @ symbol 2021-01-06 22:20:09 -08:00
Girish Ramakrishnan bbf71d8e88 eventlog: logout event 2021-01-06 21:59:17 -08:00
Girish Ramakrishnan 0867924a01 graphs: show the volume usage
part of cloudron/box#756
2021-01-04 15:14:30 -08:00
Girish Ramakrishnan 26662b9ed9 dashboard: if access_token is provided, automatically attempt login
fixes cloudron/box#747
2020-12-22 10:04:37 -08:00
Girish Ramakrishnan 29f7b771a1 add setup_token to setup and restore
part of cloudron/box#751
2020-12-21 23:59:30 -08:00
Girish Ramakrishnan de5c1ca1cf update object has changed
part of cloudron/box#749
2020-12-21 12:49:21 -08:00
Girish Ramakrishnan 94040cf3f9 proxyauth: Enable 2fa
part of cloudron/box#748
2020-12-20 13:25:42 -08:00
Girish Ramakrishnan 2e187ce012 Fix the for 2020-12-20 13:03:51 -08:00
Girish Ramakrishnan 71e4b687b9 lint 2020-12-20 13:02:58 -08:00
Girish Ramakrishnan dd3522a34c filemanager: show host path for volumes as root label 2020-12-20 12:58:09 -08:00
Johannes Zellner 0bed8c89f6 Ensure catchall uses the same origin array as the dropdown model
Otherwise angular would not reliably detect the same objects
2020-12-18 16:56:11 +01:00
Johannes Zellner 98ac637ada Add one more missing translation 2020-12-16 17:31:24 +01:00
Girish Ramakrishnan 82e77d36a6 make it 12 for aesthetics 2020-12-15 13:49:05 -08:00
Johannes Zellner b0f9ba5483 Fix missing network view translations 2020-12-15 17:31:03 +01:00
Johannes Zellner 39b50b2aa9 Make category button translatable in appstore 2020-12-15 15:58:13 +01:00
Johannes Zellner 4170bf3a7a Add dutch language name 2020-12-15 15:51:10 +01:00
Johannes Zellner 632bb64b96 Add missing translations for apps grid 2020-12-15 15:48:25 +01:00
Johannes Zellner a33760a688 Show actual real /app/data/ path in filemanger 2020-12-15 10:39:19 +01:00
Johannes Zellner 0abddd8665 Improve some elements in darkmode 2020-12-10 17:10:27 +01:00
Girish Ramakrishnan 1dc1cb92b9 Display the appstore id 2020-12-09 17:27:22 -08:00
Johannes Zellner 0adcc2af4f Do not follow redirects in authproxy login
Since we redirect on the server already on success, the fetch() would
follow that and handle whatever response from the upstream app is
delivered.
2020-12-09 14:11:27 +01:00
Johannes Zellner 24397aa25e Fix appstore category icon padding 2020-12-09 12:16:00 +01:00
Johannes Zellner fbaa3ad15f Add appstore category translations 2020-12-09 12:15:08 +01:00
Girish Ramakrishnan aa2d357de5 Add media category 2020-12-08 10:58:22 -08:00
Johannes Zellner 397a2b8803 Always prepend the version to assets when sourcing to avoid cache hits on update 2020-12-08 15:38:02 +01:00
Johannes Zellner b63d1fa8e1 List volumes of the app in filemanager 2020-12-08 13:02:14 +01:00
Johannes Zellner 6590c06f5b Display upload size and size progress 2020-12-08 12:01:47 +01:00
Johannes Zellner b608859ef9 Avoid flickering of app actions when cursor is between grid items 2020-12-08 11:36:59 +01:00
Johannes Zellner c9ae24cf62 Show app restart status as a banner in filemanager 2020-12-07 20:13:05 +01:00
Johannes Zellner e1f5fea303 Add logs, terminal and restart action to filemanager 2020-12-07 19:49:53 +01:00
Girish Ramakrishnan 8ef05c850d mail: Do not query settings per domain 2020-12-06 23:31:29 -08:00
Girish Ramakrishnan 05a558165a missing space 2020-12-06 11:32:09 -08:00
Girish Ramakrishnan 68c36ca353 add note on the ts/creationDate fields 2020-12-06 11:32:05 -08:00
Girish Ramakrishnan 6c79aaae49 fts: immediately show status in ui 2020-12-04 15:51:36 -08:00
Girish Ramakrishnan 35ba5fc766 show spinner for some time 2020-12-04 10:23:29 -08:00
Johannes Zellner 6d32ab6095 Rework the filemanager toolbar to not overflow with long paths 2020-12-04 19:06:08 +01:00
Johannes Zellner ed96c83953 decode foldernames in breadcrumb 2020-12-04 18:39:10 +01:00
Johannes Zellner 7108d0fe31 Pull in solr related German translation updates 2020-12-04 17:41:13 +01:00
Johannes Zellner bc4bce6db5 Put services reset to standard memory where it belongs 2020-12-04 17:36:38 +01:00
Johannes Zellner c9fd832af1 Use the same icon for filemanager buttons 2020-12-04 13:58:46 +01:00
Johannes Zellner fa54de2960 Update file size after save in filemanager 2020-12-04 13:53:08 +01:00
Johannes Zellner 58a60d3bd5 Set filemanager document title dynamically to indicate open app 2020-12-04 12:08:42 +01:00
Johannes Zellner 3dd8642a60 Do not show hostpath in volume mount dropdown 2020-12-04 11:35:01 +01:00
Girish Ramakrishnan 734542b3c4 redis: logs 2020-12-04 00:45:53 -08:00
Girish Ramakrishnan 89e62541b5 Link back to our docs and not to some github page 2020-12-03 21:57:41 -08:00
Girish Ramakrishnan cf620ee355 Disable solr if not enough memory 2020-12-02 17:45:24 -08:00
Girish Ramakrishnan 7269e2c64f Display solr status 2020-12-02 17:16:59 -08:00
Girish Ramakrishnan 357d4d72d7 Update solr text 2020-12-02 16:56:17 -08:00
Girish Ramakrishnan cd3fbda09c Show spinner when solr config is toggled 2020-12-02 16:48:20 -08:00
Johannes Zellner 9b8d8ed544 And more overflow fixes 2020-12-02 18:32:49 +01:00
Johannes Zellner 424f6b2b1b Fix more overflow issues in settings view 2020-12-02 18:29:02 +01:00
Johannes Zellner 709a12e74c Add china S3 regions 2020-12-02 18:20:58 +01:00
Johannes Zellner 46ffcdbf75 Fix overflow issue 2020-12-02 17:57:15 +01:00
Johannes Zellner 847751e3e0 Update german translation 2020-12-01 16:50:55 +01:00
Johannes Zellner 54724e209a Do not add basically empty french and italien translation by default for now 2020-12-01 16:50:33 +01:00
Girish Ramakrishnan d5ab13a00e Enable download for directories
downloads as zip
2020-11-30 10:27:45 -08:00
Johannes Zellner 529d445d06 Make reboot dialog translatable 2020-11-30 10:57:42 +01:00
Johannes Zellner 72f6a3213e Fixup some translation issues in the backups view 2020-11-25 18:29:15 +01:00
Johannes Zellner ec32711e7e Ensure backup settings don't overflow in the info box 2020-11-25 16:36:55 +01:00
Johannes Zellner 764fe92f1b translatable proxyauth login page 2020-11-24 20:58:10 +01:00
Johannes Zellner 68a9170251 One more dialog cancel translation missing 2020-11-24 19:52:24 +01:00
Johannes Zellner 1581b5bbfd Add missing app listing action tooltips 2020-11-24 19:08:17 +01:00
Johannes Zellner 7e0bd28bc2 Add missing translation for task rety and fix an issue in the retry button 2020-11-24 19:00:16 +01:00
Johannes Zellner 06b25501a0 German volume translation 2020-11-24 15:12:01 +01:00
Girish Ramakrishnan 6be1d3bacb mailbox: disable save button with no owner 2020-11-23 21:22:15 -08:00
Girish Ramakrishnan c211428897 add info on where volume is mounted 2020-11-23 17:25:13 -08:00
Girish Ramakrishnan 786a1d0c2a move volume description from apps to volumes view 2020-11-23 17:10:40 -08:00
Johannes Zellner dcecd166d0 Translate the volume view 2020-11-23 22:13:54 +01:00
Johannes Zellner ba59fbac48 Fix typo in translation id 2020-11-23 18:03:26 +01:00
Johannes Zellner c704129975 Fix translation for user roles 2020-11-23 17:25:47 +01:00
Johannes Zellner 259d70c63e Fixup translation if disk info is not yet available 2020-11-23 16:47:09 +01:00
Johannes Zellner e438e8e9ac Make user search translatable 2020-11-23 16:34:48 +01:00
Johannes Zellner e8091cb5f3 robots indexing action was not translated 2020-11-23 16:29:48 +01:00
Johannes Zellner 949ec91cc8 Fix overlooked save buttons for translation 2020-11-23 16:27:51 +01:00
Johannes Zellner bc62eaef90 Try to not overflow as quickly 2020-11-23 16:24:55 +01:00
Johannes Zellner 6e1b5cacd6 Move some input errors below the input field 2020-11-23 12:54:34 +01:00
Johannes Zellner 7f57c18444 Fix app location overflow in header 2020-11-23 12:34:19 +01:00
Johannes Zellner 3579992f62 Fix overflow on app task error 2020-11-23 12:30:39 +01:00
Johannes Zellner 3b7ec409e9 Add volume description and warning text 2020-11-23 11:53:20 +01:00
Johannes Zellner d885bfa93f German translation is now mostly done 2020-11-23 11:36:40 +01:00
Johannes Zellner c0f4f8979a Pull in more german translations 2020-11-22 10:58:47 +01:00
Johannes Zellner 728aa9dc35 Pull in some more German translations 2020-11-20 22:46:45 +01:00
Johannes Zellner 38fc329b9f Use language names for selectors 2020-11-20 18:09:09 +01:00
Johannes Zellner f252b01ae2 Add password reset mail translations 2020-11-20 16:23:13 +01:00
Johannes Zellner 2c29eecc22 Add welcome mail translations 2020-11-20 16:10:36 +01:00
Girish Ramakrishnan 3125e1b386 get/set solr config 2020-11-19 20:05:13 -08:00
Girish Ramakrishnan cac8659fbd Add elasticemail 2020-11-19 11:12:46 -08:00
Johannes Zellner 1230a7bb3c Update some translations 2020-11-19 12:25:11 +01:00
Johannes Zellner 1d57d4d9a4 Translate setupAccount view 2020-11-19 11:33:46 +01:00
Johannes Zellner 2e12d1765a Use system language to translate login screen 2020-11-19 00:31:45 +01:00
Johannes Zellner fb03e34765 Fix license term checkbox
input elements don't have an innerHtml
2020-11-18 09:35:23 +01:00
Girish Ramakrishnan b1addd05dc backup: make password and passwordRepeat the same when not set 2020-11-17 21:12:27 -08:00
Johannes Zellner 5c636cca0b Use availalbe languages in profile 2020-11-18 00:48:55 +01:00
Johannes Zellner c4d8699f91 Fix typo 2020-11-18 00:45:19 +01:00
Johannes Zellner 8bbcb119ce Avoid having two red action buttons in the uninstall view 2020-11-18 00:40:15 +01:00
Johannes Zellner 15b9ce0ee3 add ability to set cloudron language for admins 2020-11-18 00:28:10 +01:00
Johannes Zellner fd8077d9f5 Add language settings UI 2020-11-18 00:10:45 +01:00
Johannes Zellner 360e6e36cd use consistent styling of automatic backups and updates 2020-11-17 23:37:58 +01:00
Johannes Zellner 51b0f603c5 Only preset skip backup on update if autoupdates are also disabled 2020-11-17 23:31:16 +01:00
Johannes Zellner 01de0250ac Set skip backup on update based on backup config 2020-11-17 22:56:36 +01:00
Johannes Zellner e43160f972 Add missing translations 2020-11-17 16:29:53 +01:00
Johannes Zellner 74a65946b4 Remove unused additional postinstall dialog code 2020-11-17 16:24:43 +01:00
Johannes Zellner a46f7341e7 Add translation for app configure dialogs 2020-11-17 13:59:01 +01:00
Girish Ramakrishnan e6814ca4d2 linode: remove dns warnings
they propagate fine now
2020-11-16 22:50:35 -08:00
Girish Ramakrishnan 3fdbcb436b if provider is linode, set the default dns to linode 2020-11-16 21:04:18 -08:00
Johannes Zellner 6f12fb200c First bunch of app configure translations 2020-11-16 16:52:51 +01:00
Johannes Zellner 04456e2d8c Remove leftover debug log 2020-11-16 14:45:42 +01:00
Johannes Zellner 0dcf82a120 Remove console view in app configure screen 2020-11-16 14:42:55 +01:00
Johannes Zellner 5435f3f9c1 Move app start/stop to uninstall 2020-11-16 14:42:02 +01:00
Johannes Zellner 5d2f3d51bf Make user repeat the backup encryption password 2020-11-14 11:01:46 +01:00
Johannes Zellner 5ee64c827a Allow up to 4 appstore columns 2020-11-13 22:32:09 +01:00
Johannes Zellner a0e7cf59b6 Add appstore translations 2020-11-13 22:25:55 +01:00
Girish Ramakrishnan 722f45050a Translate the strings 2020-11-13 13:24:11 -08:00
Girish Ramakrishnan 7f3732d43c Add divider for users and groups 2020-11-13 13:09:59 -08:00
Girish Ramakrishnan 9ac5889262 Make it user/group listing searchable 2020-11-13 09:59:53 -08:00
Johannes Zellner 4fd1f31ab7 Translate users view 2020-11-13 16:44:39 +01:00
Girish Ramakrishnan 42d8dae900 mailbox: select group as owner 2020-11-13 00:21:20 -08:00
Johannes Zellner c2799658ba Creating new file or folder is no danger 2020-11-12 23:16:34 +01:00
Johannes Zellner bf2965dba0 Use new translation argument passing 2020-11-12 23:13:52 +01:00
Johannes Zellner 50433206a3 Do not use ng-href in translation strings 2020-11-12 22:23:39 +01:00
Johannes Zellner b9b31afeca Translate mail view 2020-11-12 22:18:30 +01:00
Johannes Zellner 29f7478d86 Translate paginator in emails view 2020-11-12 16:38:48 +01:00
Johannes Zellner 47de719fc8 Translate mails view 2020-11-12 11:42:11 +01:00
Johannes Zellner 5f716669fe Translate table actions 2020-11-11 22:50:57 +01:00
Johannes Zellner 302a45d6e0 Save some space use 2 space indent like the rest of the file 2020-11-11 22:00:00 +01:00
Johannes Zellner 9becad268d Fixup some profile translations 2020-11-11 21:14:53 +01:00
Johannes Zellner 017e8bf0e5 Finish settings translation 2020-11-11 21:10:43 +01:00
Johannes Zellner 5617e19e7c Ensure we reset the days and hours when schedule is disabled 2020-11-11 20:00:55 +01:00
Johannes Zellner 2a14f6c441 Ensure we don't throw for disabled update checks 2020-11-11 19:42:03 +01:00
Johannes Zellner b7e235b727 Translate the support view 2020-11-11 18:27:43 +01:00
Johannes Zellner bc6ff607ce Translate domains view 2020-11-11 17:39:19 +01:00
Johannes Zellner bbd6b47015 Better style auth proxy login 2020-11-11 14:05:15 +01:00
Girish Ramakrishnan fb4025812a proxyauth: render login as ejs 2020-11-11 00:36:56 -08:00
Girish Ramakrishnan 13ab5c1345 proxyauth: Use ajax instead of form submit 2020-11-11 00:02:02 -08:00
Girish Ramakrishnan 4cebc58576 Handle proxyAuth addon 2020-11-10 20:12:51 -08:00
Johannes Zellner a80ddc17a7 The translation key is called 'filemanager.title' 2020-11-10 01:23:09 +01:00
Johannes Zellner e50ff5e364 We need to use the translate promise for the window titles 2020-11-10 01:22:01 +01:00
Johannes Zellner ec00eecab3 Translate the filemanager 2020-11-10 01:21:44 +01:00
Johannes Zellner 89cde563f6 We use 2 space indent in html 2020-11-09 23:49:22 +01:00
Johannes Zellner a809d3fecb Set logs, filemanager and terminal title programmatically to avoid angular template leaks 2020-11-09 23:32:47 +01:00
Johannes Zellner 2ddb26761a Add terminal translations 2020-11-09 11:23:07 +01:00
Johannes Zellner f8229ba53b Ensure all views using client.js have required dependencies 2020-11-08 10:48:30 +01:00
Johannes Zellner f0a5796f2e Move translation provider setup into shared client.js 2020-11-08 10:41:42 +01:00
Johannes Zellner 5f6344f400 Translate logviewer 2020-11-08 10:40:08 +01:00
Johannes Zellner 8550b88dfe add quick action for logs/terminal/filemanager in the app config header 2020-11-08 09:54:21 +01:00
Johannes Zellner 85ff6a02bf Always show filelist header and give scroll top indicator 2020-11-08 00:46:34 +01:00
Johannes Zellner b5fa60235d Skip file moving if location unchanged 2020-11-07 23:00:08 +01:00
Johannes Zellner 6a43e184e8 Fix breadcrumbs in filemanager 2020-11-07 22:58:37 +01:00
Johannes Zellner 8b67b3b884 Add initial drag'n'drop within the filemanager 2020-11-07 22:43:24 +01:00
Johannes Zellner df20ac9190 Init filemanager cwd with explicit null 2020-11-07 11:35:22 +01:00
Johannes Zellner b6ef7cbb96 Translate systeminformation view 2020-11-06 15:32:49 +01:00
Girish Ramakrishnan 1236b64081 Also search by app id 2020-11-05 10:14:46 -08:00
Johannes Zellner c15a8dba6c Add translation for notification view 2020-11-05 16:51:41 +01:00
Johannes Zellner 93a7137d10 Move reboot server dialog and logic to main view 2020-11-05 16:45:45 +01:00
Johannes Zellner 61c1622cbf Add services view translation 2020-11-05 14:48:36 +01:00
Johannes Zellner 6298479e17 Add network view translation 2020-11-05 14:27:07 +01:00
Johannes Zellner 3bd55bbeba Add translation for eventlog 2020-11-05 13:13:57 +01:00
Johannes Zellner cf1311fe90 add 'npm run update-translations' command 2020-11-05 13:13:17 +01:00
Johannes Zellner d56653d946 Translate branding 2020-11-05 12:46:24 +01:00
Johannes Zellner 0b919bfd13 filemanager paths should not start with / 2020-11-04 13:41:29 +01:00
Johannes Zellner 1d7921a760 slightly improve the media viewer widget 2020-11-04 13:03:36 +01:00
Johannes Zellner 20af9ed911 Fix a link targets to not overwrite the hash in the url bar 2020-11-03 21:35:48 +01:00
Johannes Zellner 849ddd39e0 Finish backups view translation 2020-11-03 16:58:11 +01:00
Johannes Zellner a83dcb7c75 Add more backup view translations 2020-11-03 12:58:33 +01:00
Johannes Zellner 8ef96aefac Finish all profile translation hooks 2020-11-02 14:59:39 +01:00
Johannes Zellner 7bad9f3e3e Translate the profile main view 2020-10-31 12:24:35 +01:00
Girish Ramakrishnan f2aade3b36 Add file browser button to volume listing 2020-10-30 11:32:22 -07:00
Johannes Zellner c668b9274a Add more profile translations 2020-10-30 18:12:18 +01:00
Johannes Zellner beec65bdb6 Add all main view titles and navbar translation keys 2020-10-30 18:12:18 +01:00
Johannes Zellner b71e68581d Start using tr filter 2020-10-30 18:12:18 +01:00
Johannes Zellner 7a207650d3 Add shorthand 'tr' filter for translation 2020-10-30 18:12:18 +01:00
Johannes Zellner 53d39608b8 Change to lowercase and scoped translation keys 2020-10-30 18:12:18 +01:00
Johannes Zellner 9912698a71 Add language selector with english as fallback 2020-10-30 18:12:18 +01:00
Johannes Zellner be45a75e4d Add showcase for first string translation 2020-10-30 18:12:18 +01:00
Johannes Zellner 73c43b350a Add initial code to do basic translation 2020-10-30 18:12:18 +01:00
Johannes Zellner 75ad644769 Also show hostPath in select 2020-10-30 15:14:59 +01:00
Johannes Zellner 04299a7436 Fixup mounts ui layout in app configuration 2020-10-30 12:37:32 +01:00
Girish Ramakrishnan da726872ef Fix mounting UI 2020-10-29 22:04:29 -07:00
Girish Ramakrishnan 68f3441fbc Fix display of sso message
regression from 57e8faa8ab
2020-10-29 21:38:00 -07:00
Girish Ramakrishnan 671b9f235b add storage section in app view 2020-10-28 22:51:16 -07:00
Girish Ramakrishnan 377c2f678e Add volume UI 2020-10-28 17:17:23 -07:00
Johannes Zellner f8f0c50ed8 Change Univention name for external LDAP 2020-10-27 19:43:12 +01:00
Johannes Zellner 23592e19ad Add learning app category 2020-10-27 08:48:48 +01:00
Girish Ramakrishnan 409e4beaaa rsync: Add warning to remove s3 life cycle rules 2020-10-26 10:04:19 -07:00
Girish Ramakrishnan 62b369ff8a users: show error if update failed 2020-10-23 11:47:37 -07:00
Girish Ramakrishnan d557c8d9eb Show why the setting exists 2020-10-23 11:47:23 -07:00
Johannes Zellner 4457b5879b Support retry with optional overwrite 2020-10-22 12:45:05 +02:00
Johannes Zellner f586791c71 Add new file creation action and collapse new and upload actions 2020-10-22 10:25:54 +02:00
Girish Ramakrishnan 210d522ec3 sftp: add checkbox for non-admin access 2020-10-21 23:38:19 -07:00
Johannes Zellner e54c8bbf99 Handle memory limit errors 2020-10-21 13:27:31 +02:00
Johannes Zellner bd96073429 Move domain in use error below the input field 2020-10-21 13:11:00 +02:00
Johannes Zellner 3ebf250ad9 Use displayed instead of enabled in filemanager context-menu 2020-10-20 13:49:46 +02:00
Johannes Zellner 920181752d Add link which context-menu lib we are using 2020-10-20 13:46:29 +02:00
Johannes Zellner 660b92cd3b Support more tar and zip formats 2020-10-20 12:31:48 +02:00
Girish Ramakrishnan c7c16ee167 Add extract progress bar 2020-10-19 21:45:13 -07:00
Girish Ramakrishnan 1e6ea77a8f Add extract context menu item 2020-10-19 19:31:37 -07:00
Girish Ramakrishnan 58e4bd1077 Fix various linter issues 2020-10-19 15:54:26 -07:00
Girish Ramakrishnan 2c53dc9514 Change button color when update available 2020-10-17 08:50:48 -07:00
Girish Ramakrishnan ecf1852367 better filenames for backup configs 2020-10-15 16:44:26 -07:00
Johannes Zellner 57e8faa8ab Also fixup sso message display in app configuration 2020-10-15 16:51:17 +02:00
Johannes Zellner 7948d68ac7 Fixup sso message display in postinstall and info dialogs 2020-10-15 16:49:49 +02:00
Johannes Zellner 48f089e136 Reword the password/invite link UI bits 2020-10-14 11:28:28 +02:00
Girish Ramakrishnan 4432e65e8a Move the tips to be tooltips like the other buttons 2020-10-09 17:23:17 -07:00
Girish Ramakrishnan 10aded5de4 Fix typo 2020-10-09 17:14:56 -07:00
Johannes Zellner 5481a65ab1 Add refresh button to eventlog 2020-10-09 12:31:25 +02:00
Girish Ramakrishnan 75b867550b Run ack code 20 in parallel
only trigger refresh at the end of it all
2020-10-08 17:56:06 -07:00
Girish Ramakrishnan 23f1b0f584 Missed this wording 2020-10-08 11:16:40 -07:00
Girish Ramakrishnan 790b8bed42 Fix redirection wording 2020-10-08 11:15:42 -07:00
Johannes Zellner 1e35b621eb Show error in filemanager if upload fails 2020-10-08 16:05:24 +02:00
Girish Ramakrishnan b14828e8e1 Add stopped state 2020-10-06 13:02:33 -07:00
Johannes Zellner 4274b8f459 Immediately indicate if remote ssh is now enabled 2020-10-06 16:21:38 +02:00
Johannes Zellner 41e8bcd02f Add enableSshSupport option to support tickets 2020-10-06 16:02:02 +02:00
Girish Ramakrishnan 2fe86f9b8a robots: ensure trailing and leading whitespaces are preserved 2020-10-05 21:31:30 -07:00
Johannes Zellner 589f19f370 Make app bug report the default, this is what mostly happens 2020-10-05 15:10:23 +02:00
Girish Ramakrishnan 8e20db664f Pre-select app domain by default in redirection dropdown 2020-10-04 16:39:59 -07:00
Girish Ramakrishnan fdcd457ce1 Add link to forum 2020-09-30 09:41:20 -07:00
Girish Ramakrishnan 95516a2383 Update readme 2020-09-30 09:40:18 -07:00
Girish Ramakrishnan ba92b1e667 Keep things alphabetical 2020-09-29 14:52:11 -07:00
Johannes Zellner f3a159823a Mention why an app update cannot be applied and provide shortcut to start the app if stopped 2020-09-29 17:32:25 +02:00
Johannes Zellner 8388491e58 Remove version from footer 2020-09-29 16:41:00 +02:00
Johannes Zellner e87d206dda Show Cloudron version in settings -> updates 2020-09-29 16:38:31 +02:00
Johannes Zellner db4c8d92da Make the autoupdate disabled text explicit 2020-09-29 16:34:49 +02:00
Johannes Zellner daab4a95c2 Move services menu entry up 2020-09-29 15:24:24 +02:00
Girish Ramakrishnan 22b8b9b9bd lint 2020-09-28 16:10:55 -07:00
Johannes Zellner c87f3a8cb4 Give services panel a separate top-level view 2020-09-28 15:16:02 +02:00
Johannes Zellner 72118a0b66 Add app state filter 2020-09-26 17:50:23 +02:00
Girish Ramakrishnan 68573ceb18 unhide the volume UI 2020-09-24 14:38:34 -07:00
Johannes Zellner 510b88cd68 Make the splash default more fun, needs a minigame 2020-09-24 13:25:34 +02:00
Girish Ramakrishnan 490720e6a7 Add a splash page
part of cloudron/box#739
2020-09-23 22:14:29 -07:00
Girish Ramakrishnan 990f75dddc import: when importing filesystem backups, the input box is a path 2020-09-21 21:58:11 -07:00
Girish Ramakrishnan a3c6b82283 Fix exception when getStatus errored 2020-09-21 21:48:22 -07:00
Girish Ramakrishnan f5e0ff51f2 preserve sorting order when doing async queries 2020-09-16 16:03:14 -07:00
Girish Ramakrishnan f114a629f3 stash the length separately 2020-09-14 12:12:39 -07:00
Girish Ramakrishnan 5fca372ddf blocklist is now a text file in the backend 2020-09-14 12:00:47 -07:00
Johannes Zellner d9d1f13bf9 Ensure blocked ips are deduped and empty strings removed 2020-09-12 20:53:12 +02:00
Johannes Zellner 63b212bea5 Fix blocklist error form state handling 2020-09-12 19:13:10 +02:00
Girish Ramakrishnan 5a1e09936f Change step size to 1MB 2020-09-11 09:48:22 -07:00
Girish Ramakrishnan e21a504c35 we upload 3 parts in parallel 2020-09-10 08:30:59 -07:00
Johannes Zellner 3ba6c387e9 Update dependencies and xtermjs 2020-09-10 15:37:05 +02:00
Johannes Zellner 2c7238b2c9 Make logviewer timestamp sticky 2020-09-10 15:31:19 +02:00
Girish Ramakrishnan 92b9fc02fa Fix memory slider 2020-09-10 00:07:12 -07:00
Girish Ramakrishnan 576281990b Link to our docs and not external 2020-09-09 23:19:37 -07:00
Girish Ramakrishnan 6b7570df4e just call it re-configured 2020-09-09 22:36:57 -07:00
Girish Ramakrishnan b141db4776 mail location audit log 2020-09-09 22:31:57 -07:00
Girish Ramakrishnan 4cffcfff03 mail: move config eventlogs to box code 2020-09-09 22:24:38 -07:00
Girish Ramakrishnan 59ea292263 only reconfigure email apps when mail server relocated 2020-09-09 21:44:14 -07:00
Girish Ramakrishnan e0ca52b1da Disable changing location when task is active 2020-09-09 21:44:11 -07:00
Girish Ramakrishnan 0c9ea1e0f0 blocklist is only for owner 2020-09-09 20:28:26 -07:00
Girish Ramakrishnan c02cf0f5dc Fix doc links 2020-09-09 10:14:35 -07:00
Girish Ramakrishnan d0e2df5166 re-configure mail apps on mail fqdn change 2020-09-08 19:34:27 -07:00
Girish Ramakrishnan b9cda71413 adminFqdn -> mailFqdn 2020-09-08 15:18:46 -07:00
Johannes Zellner e008e44566 No need to mention logs in logviewer 2020-09-06 10:16:59 +02:00
Johannes Zellner c100539736 button group in logviewer looks wrong 2020-09-06 10:16:18 +02:00
Johannes Zellner 32aa3febf9 Do not linebreak loglines 2020-09-06 10:15:41 +02:00
Girish Ramakrishnan 1249b3b3e8 Put save and close together 2020-09-05 23:04:51 -07:00
Girish Ramakrishnan 18ba66afcc add linode singapore region 2020-09-02 19:35:02 -07:00
Girish Ramakrishnan 1000d88508 ovh: add sydney region 2020-09-02 19:30:55 -07:00
Girish Ramakrishnan e13cb1debd Fix placeholder text 2020-09-02 14:27:09 -07:00
Johannes Zellner 2c3c8f8c4a Show graph labels based on locale 2020-09-02 18:53:46 +02:00
Johannes Zellner b81196fa87 Update Chart.js to v2.9.3 2020-09-02 17:57:01 +02:00
Johannes Zellner c7291af970 Instead of random string for app icon invalidation use app version
This still leaves a potential issue, where an app gets updated using the
cli while not bumping the version and changing the icon, but maybe we
can ignore that for now in favor of the browser cache for 99% of the
cases
2020-09-02 15:14:46 +02:00
Johannes Zellner 92c3237552 Ensure mail location progress starts at 0 2020-09-02 14:12:38 +02:00
Girish Ramakrishnan 848e446b93 Explain what domain is 2020-09-01 21:49:38 -07:00
Johannes Zellner 2f96f565eb Use TASK_TYPES in backup view 2020-09-01 16:36:07 +02:00
Johannes Zellner 8fa58eb108 Show mail domain change task progress 2020-09-01 16:31:23 +02:00
Johannes Zellner 31947127d9 Add TASK_TYPE definitions to client.js 2020-09-01 16:31:09 +02:00
Johannes Zellner 2c7cfa1a93 Also add Filemanager button to logviewer 2020-09-01 15:18:42 +02:00
Johannes Zellner b856c4f995 Indent logviewer with 2 spaces since html 2020-09-01 15:17:33 +02:00
Johannes Zellner 497be710a7 Only provide save for filemanager editor 2020-09-01 12:39:02 +02:00
Girish Ramakrishnan d7287b5c3c require owner for firewall config 2020-08-31 22:55:30 -07:00
Girish Ramakrishnan 854010b823 warn user about block list 2020-08-31 22:12:33 -07:00
Girish Ramakrishnan 39f7a5be70 Add firewall UI 2020-08-31 21:45:56 -07:00
Girish Ramakrishnan dbc53b8d09 Remove blocked IPs
This will be implemented in the firewall level for now
2020-08-31 17:57:45 -07:00
Girish Ramakrishnan c4fe362a08 Typo 2020-08-31 15:28:28 -07:00
Girish Ramakrishnan f55ec5de9b Add note on backups in initial screen 2020-08-31 11:37:29 -07:00
Johannes Zellner b2279c9acc Make texteditor fullscreen and add saving without closing action 2020-08-31 17:46:26 +02:00
Girish Ramakrishnan b420d054ae show ldap login into in all the post install dialogs 2020-08-28 17:08:18 -07:00
Girish Ramakrishnan 566f0f7783 mail: add banner ui
part of cloudron/box#341
2020-08-24 10:36:24 -07:00
Girish Ramakrishnan ae24c1d968 Move the refresh functions 2020-08-22 19:34:06 -07:00
Girish Ramakrishnan 8ca344e3bf Various text changes 2020-08-22 16:43:17 -07:00
Girish Ramakrishnan 0458d2cb90 Fix mail eventlog to show the new events 2020-08-22 13:08:05 -07:00
Girish Ramakrishnan 7c2322e6e0 Fixup spam configuration UI 2020-08-22 13:01:25 -07:00
Girish Ramakrishnan 08abe4bff2 Add note on restart 2020-08-20 23:28:43 -07:00
Girish Ramakrishnan eb69c365fc Fix mail server location UI 2020-08-20 23:23:43 -07:00
Girish Ramakrishnan f6fef21bf7 Fixup route for setting max email size 2020-08-20 22:28:58 -07:00
Girish Ramakrishnan 4a1f8457cf Switch to the merged automatic update route 2020-08-19 22:30:48 -07:00
Girish Ramakrishnan 5eb5b952d5 Further clarification 2020-08-19 15:01:39 -07:00
Girish Ramakrishnan 8a375c6363 Add note on part size concurrency 2020-08-19 14:59:27 -07:00
Girish Ramakrishnan ac23b610bc Add upload part size slider 2020-08-19 14:56:41 -07:00
Johannes Zellner 5f8b141f62 Add stub mail settings section 2020-08-17 22:38:11 -07:00
Girish Ramakrishnan 517db50712 Hide concurrency settings for non-s3/gcs backup storage 2020-08-15 23:09:21 -07:00
Girish Ramakrishnan 6310a431dd caas: remove hyphenatedSubdomains support
this is not used since ages now
2020-08-15 18:35:51 -07:00
Girish Ramakrishnan 9996e9a6d7 Add help text for ptr record 2020-08-14 10:24:21 -07:00
Girish Ramakrishnan ddc211a8ea Looks for search string in app title as well 2020-08-14 09:39:39 -07:00
Girish Ramakrishnan 32f4f88b88 help text everywhere 2020-08-11 17:03:49 -07:00
Girish Ramakrishnan 45b3062ac6 Always init the concurrency values 2020-08-11 16:51:02 -07:00
Girish Ramakrishnan 03296b3195 Fix padding of sliders 2020-08-11 12:07:57 -07:00
Girish Ramakrishnan 97df39a16f add advanced section to tune backup settings 2020-08-11 09:16:09 -07:00
Girish Ramakrishnan 59cd6f6e93 Fix groups code to use listing API
the listing API now returns the members
2020-08-10 13:59:46 -07:00
Girish Ramakrishnan d4312507e2 Simplify wording 2020-08-10 13:11:10 -07:00
Girish Ramakrishnan 76950bdada remove superfluous wording 2020-08-10 13:08:53 -07:00
Girish Ramakrishnan 01b7bc96fa Fix layout of backup retention dialog 2020-08-10 13:03:59 -07:00
Girish Ramakrishnan efde15b848 backup: remind to not overlap with update schedule 2020-08-10 12:58:19 -07:00
Girish Ramakrishnan 941e0ba6c8 This deletes filters as well 2020-08-10 12:19:20 -07:00
Girish Ramakrishnan 3b818855dc Fix broken help link 2020-08-10 12:02:53 -07:00
Girish Ramakrishnan f73c8b00d4 global replace 2020-08-08 22:02:31 -07:00
Girish Ramakrishnan 08f116486a update showndown to 1.9.1
this has openLinksInNewWindow which is smart enough that internal links
open in same tab and external links open in new tab
2020-08-08 21:58:44 -07:00
Girish Ramakrishnan f6f5ae8578 show users name 2020-08-08 19:15:21 -07:00
Girish Ramakrishnan d82dde4b7f Add some $variables to the post install message
This way the post install messages can be better.

Removed it as a filter because I cannot figure how to pass args to it
2020-08-08 19:12:16 -07:00
Girish Ramakrishnan 91d4d95cb4 linter says the escapes are not needed 2020-08-08 17:58:48 -07:00
Girish Ramakrishnan b9973d69c3 SSO_MARKER is now standardized 2020-08-08 17:58:04 -07:00
Girish Ramakrishnan 8c8e363abc atleast is not a real word
thanks @rob
2020-08-05 10:12:13 -07:00
Girish Ramakrishnan aa240e8ee3 Remove "old" 2020-08-03 21:00:20 -07:00
Johannes Zellner cdaf9e1876 Fix typo for notification bell icon change 2020-07-31 09:25:40 +02:00
Johannes Zellner 1c8352ec56 Fontawesome SVG fonts don't play well with angular1
Essentially SVG fonts use JS to inject elements.
This does not work with ng-show/hide
2020-07-31 09:17:55 +02:00
Johannes Zellner 43ef7f088d Improve multiselect padding when item is not selected 2020-07-31 09:17:34 +02:00
Girish Ramakrishnan 28b4f66f86 wording 2020-07-30 11:48:40 -07:00
Johannes Zellner 4fb94ea162 Update xtermjs to latest v4.8.1 2020-07-30 14:39:30 +02:00
Johannes Zellner d24340f221 Update to fontawesome 5.14.0 and use svg fonts 2020-07-30 13:20:28 +02:00
Johannes Zellner 482cd123c0 Make notification a separate navbar item 2020-07-30 13:19:03 +02:00
Girish Ramakrishnan ab3abe7e5e Add a way to disable auto updates 2020-07-29 20:14:30 -07:00
Girish Ramakrishnan 31fbffb435 better wording 2020-07-29 16:51:12 -07:00
Girish Ramakrishnan 9a7f8bd861 Allow days/hours to be selected for auto update schedule 2020-07-29 16:10:29 -07:00
Girish Ramakrishnan 29c20cfcc4 rename variable 2020-07-29 15:24:10 -07:00
Girish Ramakrishnan b5c25bcaaa Fix typo in pattern 2020-07-29 12:01:00 -07:00
Girish Ramakrishnan 8abe0a174a Handle case where all days are to be selected 2020-07-29 09:27:55 -07:00
Girish Ramakrishnan 692abcd6de show backup days and hours 2020-07-28 23:09:25 -07:00
Johannes Zellner 03bdcc786e Show critical backup config warning directly in backup ui instead of notification 2020-07-28 18:08:57 +02:00
Johannes Zellner 6df2985e2a Remove wrong hand css class 2020-07-28 12:24:21 +02:00
Girish Ramakrishnan 05de8b54ec Add option to delete mails
Part of cloudron/box#720
2020-07-27 22:55:25 -07:00
Dustin Dauncey c0dad4f5a0 Update system.html 2020-07-27 22:29:22 -07:00
Dustin Dauncey 7ad425e399 Update system.html with a more accurate message on when to use the reboot function. 2020-07-27 22:29:22 -07:00
Johannes Zellner 836a3784cb Add missing mimer dependeny files 2020-07-27 11:48:00 +02:00
Girish Ramakrishnan 06d4aec850 Fix various links in README 2020-07-24 15:05:49 -07:00
Johannes Zellner 614674563a Show folders first 2020-07-23 15:01:50 +02:00
Johannes Zellner 349633c8da Better icon handling in filemanager 2020-07-23 12:22:00 +02:00
Johannes Zellner 7d4f617757 Support moving files across folders when renaming 2020-07-23 12:07:24 +02:00
Girish Ramakrishnan e82f17ab06 Show any settings save error 2020-07-22 18:09:44 -07:00
Johannes Zellner cb14592705 Make filemanager reload pickup the directory from hash 2020-07-22 21:41:32 +02:00
Johannes Zellner 77300d6858 Add burger button to also open context-menu 2020-07-22 21:38:39 +02:00
Johannes Zellner 38682e48d4 Open different mimetypes differently in filemanager 2020-07-21 16:27:51 +02:00
Johannes Zellner 1e5d28e2a2 Fix nav-bar in dark mode 2020-07-21 10:18:50 +02:00
Johannes Zellner ad86b4b1eb Fix mail domain listing in dark mode 2020-07-21 09:26:44 +02:00
Johannes Zellner 99927df991 Also ensure the admin link is not active if it shouldn't 2020-07-20 23:41:00 +02:00
Johannes Zellner 6661f21e2f Handle symlinks in filemanager 2020-07-18 19:26:18 +02:00
Girish Ramakrishnan 4ef963fe54 Don't let the user bypass 2FA by removing the 'setup2FA' in the url 2020-07-17 14:46:58 -07:00
Girish Ramakrishnan c87ddd5116 Use prettyByteSize instead of prettyDiskSize
this prevents 'not available yet' string for 0 size files
2020-07-17 14:25:00 -07:00
Girish Ramakrishnan 4f4df7d9fe appstore: fix ordering of apps
all apps: alphabetical
popular: based on ranking instead of installCount
New Apps: based on time only
Category: tag and then ranking
2020-07-17 14:22:47 -07:00
Girish Ramakrishnan 0043b3690a Make directory config UI consistent 2020-07-17 10:17:46 -07:00
Girish Ramakrishnan be6c34386d Always show the catch-all description text 2020-07-17 09:51:37 -07:00
Girish Ramakrishnan a8e9a71489 Add missing break 2020-07-17 09:32:32 -07:00
Girish Ramakrishnan 90f42fe6cd Fixup text in postinstall and info dialog 2020-07-16 15:43:30 -07:00
Johannes Zellner 6dd414fe7e Add mailbox restriction code 2020-07-16 18:51:29 +02:00
Johannes Zellner 4cb5e66ccb Make catchall premium 2020-07-16 18:14:42 +02:00
Girish Ramakrishnan 1fd4d772e4 Fixup mailbox count 2020-07-15 15:47:58 -07:00
Girish Ramakrishnan 3abdbdc7c9 Add info on what cleanup backups does 2020-07-15 15:10:44 -07:00
Johannes Zellner 6d6fba873f Use browser history to allow navigation 2020-07-15 14:48:29 +02:00
Johannes Zellner 6aa8602b96 Replace action buttons with context menu 2020-07-14 23:49:14 +02:00
Johannes Zellner 240272f7ce Cleanup console.log()s 2020-07-14 19:26:24 +02:00
Johannes Zellner 3d17a33c43 Do not rely on angular trying to parse everything in the response for filemanager GET 2020-07-14 17:17:43 +02:00
Johannes Zellner 6956cfa32d Try to figure out the file language for monaco 2020-07-14 16:41:40 +02:00
Johannes Zellner 3a54e662c2 Give monaco some time to settle the ui and add tooltips 2020-07-14 16:28:46 +02:00
Johannes Zellner 823cfca3c3 Support download links 2020-07-14 16:28:18 +02:00
Johannes Zellner 9da2484bab Chrome does not have dragexit event :-/ 2020-07-14 15:32:31 +02:00
Johannes Zellner 0b50d62ef3 Fix filedrop highlight for current folder 2020-07-14 15:28:08 +02:00
Johannes Zellner 343e8e90ba Scroll file lists inside the card 2020-07-14 14:03:40 +02:00
Johannes Zellner 02dcb013ef Clear drop target highlight 2020-07-14 00:53:11 +02:00
Girish Ramakrishnan e77d3f4fcc import and restore: add b2 provider
part of cloudron/box#508
2020-07-13 15:36:44 -07:00
Girish Ramakrishnan 7aff747b1c backups: add b2 provider
part of cloudron/box#508
2020-07-13 14:58:14 -07:00
Johannes Zellner e97f3032cc Make monaco work 2020-07-13 23:35:49 +02:00
Johannes Zellner ebabe29d8e Add monaco-editor resources 2020-07-13 18:58:22 +02:00
Johannes Zellner b690c9bc95 Add filemanager chown dialog 2020-07-13 18:30:29 +02:00
Johannes Zellner fd3034bacc Make normal files downloadable 2020-07-13 17:48:53 +02:00
Johannes Zellner 3bcef3d9c3 Fix file rename focus and initial selection 2020-07-13 17:05:01 +02:00
Johannes Zellner da54699815 Add drag'n'drop to filemanager 2020-07-13 15:41:10 +02:00
Johannes Zellner 6b64dd52b9 Remove console.log()s 2020-07-13 12:59:50 +02:00
Girish Ramakrishnan fb07dc2294 implement mandatory 2fa
part of #716
2020-07-10 13:10:07 -07:00
Johannes Zellner 779c3ba75b Add upload progress dialog 2020-07-10 19:15:33 +02:00
Johannes Zellner 4564e501d3 Add basic upload progress bar 2020-07-10 19:15:33 +02:00
Girish Ramakrishnan d271d2db57 Allow users to change avatar even if profile is locked 2020-07-10 09:45:02 -07:00
Johannes Zellner 46ed0ab49e For now just add a filemanager button in console section 2020-07-10 16:19:01 +02:00
Johannes Zellner 35dfea03da Show hint if folder is empty 2020-07-10 16:12:34 +02:00
Johannes Zellner ff5036a55b Handle filemanager error if app does not exist 2020-07-10 16:10:49 +02:00
Johannes Zellner 799892c220 Add footer and fixup main layout 2020-07-10 15:27:44 +02:00
Johannes Zellner 8b160cbbfd Share markdown angular filter via client.js 2020-07-10 15:20:53 +02:00
Johannes Zellner 48983879ab Improve new folder dialog 2020-07-10 15:11:09 +02:00
Johannes Zellner 2cecdd7f01 Add breadcrumb to filemanager 2020-07-10 15:01:56 +02:00
Johannes Zellner 4ebaa674c3 root is uid 0 2020-07-10 14:17:30 +02:00
Johannes Zellner fb637f61f3 Add folder upload hooks 2020-07-10 14:06:32 +02:00
Johannes Zellner 805e07e65f entry.filePath -> entry.fileName 2020-07-10 14:06:32 +02:00
Johannes Zellner 049a488e08 Implement file upload 2020-07-10 14:06:32 +02:00
Johannes Zellner afc90817cf Add file rename 2020-07-10 14:06:32 +02:00
Johannes Zellner 38f3e39258 Add directory creation 2020-07-10 14:06:32 +02:00
Johannes Zellner c674d679bd Add file removal functionality 2020-07-10 14:06:32 +02:00
Johannes Zellner 7c2ab4e5bd Initial filemanager view 2020-07-10 14:06:32 +02:00
Girish Ramakrishnan b86dff8601 Clarify what profile means 2020-07-09 21:56:01 -07:00
Girish Ramakrishnan a725fc7a0b Add directory config ui 2020-07-09 21:51:51 -07:00
Girish Ramakrishnan fbe3545153 disable profile editing based on directory config
part of cloudron/box#704
2020-07-09 17:18:41 -07:00
Girish Ramakrishnan 50b528260c account setup: Make fields readonly when profile is locked
part of cloudron/box#704
2020-07-09 15:36:52 -07:00
Girish Ramakrishnan d2ece2b7f9 email is not used in setup account 2020-07-09 14:53:14 -07:00
Girish Ramakrishnan f71e47aac7 Update license year 2020-07-09 09:02:26 -07:00
Johannes Zellner 8d9c4b0476 Fix eventlog crash 2020-07-06 14:53:52 +02:00
Johannes Zellner ea1a62c3ef Finish initial round of dark moder overrides 2020-07-06 12:31:15 +02:00
Girish Ramakrishnan 2e5e459094 mail: add pagination to mailboxes UI 2020-07-05 18:21:52 -07:00
Girish Ramakrishnan f51eccdef7 mail: Add pagination to lists UI 2020-07-05 11:55:17 -07:00
Girish Ramakrishnan a9a9af9ef7 s3: add region field to import and restore UI
for s3 v4 compat providers like yandex

fixes cloudron/box#713
2020-07-05 10:58:20 -07:00
Girish Ramakrishnan 200122deee get all mailing lists in a single shot for now 2020-07-05 10:49:30 -07:00
Johannes Zellner 4170be7f34 Also add dark mode to modals and appstore view 2020-07-02 23:22:14 +02:00
Johannes Zellner 0be5a292c4 Initial css overrides for dark mode 2020-07-02 23:15:14 +02:00
Girish Ramakrishnan 4555586254 Login Page -> Admin Page 2020-07-01 17:05:15 -07:00
Girish Ramakrishnan 173531b767 Add note on updates 2020-07-01 14:29:02 -07:00
Johannes Zellner 412082d3ef Add univention external ldap provider to selection 2020-07-01 16:11:34 +02:00
Johannes Zellner 3b51b84308 Fix typo to show correct self signed cert support for external ldap 2020-07-01 14:59:50 +02:00
Johannes Zellner d6d1ad98e4 Ensure info dialog collapse starts with the closed stated 2020-06-30 10:34:07 +02:00
Johannes Zellner e8560e6905 Ensure we always order apps by fqdn 2020-06-30 10:26:00 +02:00
Girish Ramakrishnan ccaabd6f06 Fix text for custom apps 2020-06-29 19:43:45 -07:00
Girish Ramakrishnan 9ba79cfb32 Fix infinite loop when postinstall has <br/> in it 2020-06-29 19:39:26 -07:00
Johannes Zellner 62e0e34e12 Do not hide info button for custom apps but show note about that in the dialog 2020-06-29 16:27:50 +02:00
Johannes Zellner 2d50ae4b00 Show self-signed error for external ldap setup 2020-06-26 15:18:25 +02:00
Johannes Zellner 11b567391c Allow self-signed cert for external ldap 2020-06-25 17:54:55 +02:00
Johannes Zellner e50e488c8a Improve how sftp is mentioned in access controls 2020-06-25 10:36:25 +02:00
Girish Ramakrishnan 2a9d32309e Fix the app info dialog 2020-06-24 23:06:12 -07:00
Johannes Zellner de0370011c Support old default autoupdate pattern 2020-06-24 12:21:27 +02:00
Girish Ramakrishnan 4a844e582e Fix wording of subscription dialog 2020-06-23 17:25:27 -07:00
Girish Ramakrishnan d36aad4adc polish the wording 2020-06-23 09:49:11 -07:00
Girish Ramakrishnan 11240b6bbb improve wording a bit 2020-06-23 09:06:47 -07:00
Johannes Zellner b52d3231e4 Add support for special app login page like wordpress has 2020-06-23 12:50:44 +02:00
Johannes Zellner c9ba4ba50a Add nfs storage provider 2020-06-22 15:51:18 +02:00
Johannes Zellner 4db07b5254 Fix form error handling for mount points 2020-06-22 15:44:59 +02:00
Johannes Zellner 83688f9fd8 Add link to cifs/sshfs docs 2020-06-22 15:43:19 +02:00
Johannes Zellner 7a384846f8 Add MSP as purpose option 2020-06-19 22:04:41 +02:00
Girish Ramakrishnan 923f7f3aa8 Do not count stopped apps for memory use 2020-06-18 10:25:21 -07:00
Johannes Zellner 8e0cfcda88 Keep subscription setup screens consistent 2020-06-18 17:59:59 +02:00
Johannes Zellner cd90af35a1 Fix active category selection if appstore search is empty 2020-06-18 17:13:20 +02:00
Johannes Zellner d2ac8536b3 402 is a license error and also returned if no appstore account setup 2020-06-18 14:50:24 +02:00
Johannes Zellner 5100a28ff1 Remove unused features 2020-06-18 14:34:06 +02:00
Johannes Zellner 0830e9293d Disable domain remove button for dashboard domain 2020-06-18 14:06:34 +02:00
Johannes Zellner 4a981cd2e2 Show subscription setup for domain adding 2020-06-18 13:56:37 +02:00
Johannes Zellner b1d956f7bf Show subscription setup dialog for more users 2020-06-18 12:57:12 +02:00
Johannes Zellner 75b2c7236a Use the same pattern for subscription setup in settings and users view 2020-06-18 12:29:25 +02:00
Johannes Zellner c8278e7b24 Add subscription setup link to paid branding feature 2020-06-18 12:16:16 +02:00
Girish Ramakrishnan dbf6520860 more newlines 2020-06-17 20:37:28 -07:00
Johannes Zellner e593e48d40 Remove debug console.log 2020-06-17 13:16:27 +02:00
Johannes Zellner 39bccea953 Simplify footer branding html 2020-06-17 13:15:52 +02:00
Johannes Zellner 98f62eba9d Improve error case layout in setup 2020-06-17 12:43:04 +02:00
Girish Ramakrishnan 4e65728979 Better text 2020-06-16 12:31:29 -07:00
Johannes Zellner b58ca1506e Add more information to dnssetup screen 2020-06-16 15:28:58 +02:00
Johannes Zellner e0334b3ac8 Fix oversight to set correct view after admin setup 2020-06-16 13:23:32 +02:00
Johannes Zellner 0fa230527c Improve text layout in setupdns 2020-06-16 13:14:56 +02:00
Johannes Zellner 13c5085cb1 Fix scroll position in appstore when switching categories 2020-06-16 11:37:10 +02:00
Johannes Zellner 300a3919ab Fix appstore case without apps listed 2020-06-16 11:20:28 +02:00
Johannes Zellner e65d946633 Swap logs and info button 2020-06-16 11:08:37 +02:00
Johannes Zellner 412bd1c1f4 Make nginx logs available in log viewer 2020-06-15 17:30:32 +02:00
Johannes Zellner 1d15fd3178 Allow to specify region for custom s3 v4 compat 2020-06-15 16:51:56 +02:00
Johannes Zellner cb94737519 Fix bug where location change makes the app temporarily disappear 2020-06-14 16:31:06 +02:00
Johannes Zellner 01683e9383 Another attempt to fix app polling
Using taskId only to update app info leads to various outdated states if
an app task has finished. We need to also update once the task has
finished at least once. So instead of individual app polling, we can
simply rely on the all apps listing api, which we poll anyways and not
rely on the restricted properties in the main apps view.

The app configure will fetch the updated full properties now, not
relying on the Clients internal caching
2020-06-14 13:35:30 +02:00
Johannes Zellner 1960969325 Fix scrollbar quirk for chrome 2020-06-13 23:11:49 +02:00
Johannes Zellner b49721f514 Fix toolbar with layout 2020-06-13 23:11:17 +02:00
Johannes Zellner 6876e82d64 Highligh currently selected category button instead of showing the title 2020-06-13 23:01:48 +02:00
Johannes Zellner 15a7beae57 Show selected category in dropdown button 2020-06-13 22:56:24 +02:00
Johannes Zellner 297a635613 'Recently updated' becomes 'popular' used to be 'featured' 2020-06-13 22:47:10 +02:00
Johannes Zellner e0778c52e8 Fix appstore search margin on mobile 2020-06-13 22:43:35 +02:00
Johannes Zellner e09b9964be Do not overlay the scrollbar 2020-06-13 22:39:37 +02:00
Johannes Zellner 1d27926220 Shorten the appstore search placeholder text 2020-06-13 22:22:44 +02:00
Johannes Zellner 7427d549cc Make the appstore view not so wide 2020-06-13 22:22:25 +02:00
Johannes Zellner 37aeb3f713 Fix border radius in dropdown 2020-06-13 22:17:38 +02:00
Johannes Zellner 7bf06da9f8 Don't add 1sec delay for search input focus 2020-06-13 22:16:16 +02:00
Johannes Zellner b6157d58c8 Show if no app passwords are created 2020-06-12 15:46:34 +02:00
Johannes Zellner 4767fe5515 Fix z-index of appstore view to not overlay the menu 2020-06-12 15:44:13 +02:00
Johannes Zellner 750acdbcd7 Swap navbar icons to make sense 2020-06-12 15:41:23 +02:00
Johannes Zellner 29543fbc85 Use 'My Apps' everywhere 2020-06-12 15:34:54 +02:00
Johannes Zellner 05913d0ae0 Rename owner role to superadmin in UI bits 2020-06-12 15:20:18 +02:00
Johannes Zellner a31617fcb0 Rework the appstore view 2020-06-12 15:02:41 +02:00
Johannes Zellner ec71b622fc Ensure action items are aligned on mobile 2020-06-11 19:33:59 +02:00
Johannes Zellner 3dd659639d Add action item tooltips 2020-06-11 19:32:23 +02:00
Johannes Zellner 4aca2b64b9 Just show the rendered postinstall message as docs in info dialog 2020-06-11 15:32:24 +02:00
Johannes Zellner 4c2c27c686 move logs button into app grid 2020-06-11 15:24:18 +02:00
Johannes Zellner 429f45a09a add info dialog for apps 2020-06-10 18:00:50 +02:00
Johannes Zellner 886c668107 Show default memory requirement in app install 2020-06-10 11:31:29 +02:00
Johannes Zellner c0df62cd5b Give more info in dns setup what is happening with the domain 2020-06-10 11:28:47 +02:00
Johannes Zellner a8e6d727fa Add logic for email setup and invite setup forms 2020-06-09 15:39:27 +02:00
Johannes Zellner ccf1c78cbb Add ability to develop the setup screen more easily 2020-06-09 15:03:35 +02:00
Johannes Zellner 4e25688dd9 We always require owner email 2020-06-09 14:58:03 +02:00
Johannes Zellner 3378bf7a1e Remove provider from setup 2020-06-09 14:53:43 +02:00
Johannes Zellner 2bbafb5604 Remove unused pre-fill logic in setup 2020-06-09 14:53:19 +02:00
Girish Ramakrishnan 1e82774460 set poll frequency same as the apps.js 2020-06-08 20:26:25 -07:00
Girish Ramakrishnan dce865c3cb only fetch app when there is an active task
fixes cloudron/box#677
2020-06-08 18:01:19 -07:00
Girish Ramakrishnan 81bf84b50a re-use existing progress and message properties
now, when we go back to the app grid, it reflects immediately
2020-06-08 17:54:50 -07:00
Girish Ramakrishnan 94b6f5bffd Call postProcess in getApps 2020-06-08 17:20:18 -07:00
Johannes Zellner 5440a3b62b Ensure we send the info also for cifs 2020-06-08 18:00:04 +02:00
Johannes Zellner 24737382f9 Add CIFS storage backend 2020-06-08 17:52:56 +02:00
Johannes Zellner 5fa3215a4d Ensure additional group ldap settings are shown 2020-06-08 14:50:32 +02:00
Girish Ramakrishnan 105141be53 show warning for unstable updates
part of cloudron/box#698
2020-06-05 17:07:33 -07:00
Johannes Zellner e19edcb67a Do not crash when retention policy is unknown to the dashboard code 2020-06-05 14:35:34 +02:00
Johannes Zellner be0b61a628 Fix backup location display for sshfs 2020-06-05 13:03:16 +02:00
Johannes Zellner 8d79244068 Reorder backup provider list 2020-06-05 12:48:27 +02:00
Johannes Zellner 8ee66d3abf Add sshfs backup configuration 2020-06-05 12:47:33 +02:00
Johannes Zellner fb94416b1b Give more information about ldap sync 2020-06-05 08:59:04 +02:00
Johannes Zellner 70a925b416 Handle ldap groups in group edit form 2020-06-05 08:18:40 +02:00
Johannes Zellner 959f245ce4 Show ldap indicator for groups 2020-06-04 14:11:05 +02:00
Johannes Zellner b3eb650315 Allow to enable/disable group sync 2020-06-04 12:30:31 +02:00
Johannes Zellner bdf7da6ef6 Mention that ldap sync is not automatic 2020-06-04 11:01:18 +02:00
Johannes Zellner 36d49b8217 Refresh 20 apps in parallel 2020-06-03 23:38:00 +02:00
Johannes Zellner 18ac61e8ab custom asyncForEach() is gone 2020-06-03 23:17:06 +02:00
Johannes Zellner b524da23d5 custom asyncForEachParallel() is gone 2020-06-03 23:11:44 +02:00
Johannes Zellner eeac846f5a custom asyncSeries() is gone 2020-06-03 23:08:05 +02:00
Johannes Zellner 0410ba51ca Add a proper async library 2020-06-03 22:59:17 +02:00
Johannes Zellner ca3bf6fe5c Add a way to specify LDAP group related configs 2020-06-03 22:12:50 +02:00
Johannes Zellner 4353a05350 Just accept all image types for profile pictures
This works as we render things on the browser into a canvas which will
be stored as png on the server
2020-06-02 15:25:29 +02:00
Johannes Zellner d2a3bb7339 Accept all image types for cloudron avatar 2020-06-02 15:14:36 +02:00
Johannes Zellner 589ee2d0c5 Always use binary byte units 2020-06-02 14:34:38 +02:00
Girish Ramakrishnan 2178dcc963 Handle already exists
part of cloudron/box#688
2020-05-30 13:33:17 -07:00
Girish Ramakrishnan f18fdd4a46 Match default app auto-update pattern with box code 2020-05-30 10:47:00 -07:00
Girish Ramakrishnan 4352d9c698 Add note about triggering backup before stopping 2020-05-28 13:19:33 -07:00
Girish Ramakrishnan 494884595c do not allow backup, import, update in stopped state 2020-05-28 12:15:29 -07:00
Girish Ramakrishnan b17db02f9d Remove duplicate taskId check 2020-05-28 11:54:56 -07:00
Girish Ramakrishnan 0f33a6b34b Fix display of non-appstore apps 2020-05-27 22:31:10 -07:00
Girish Ramakrishnan 231dfe70d0 remove broken disk graphs 2020-05-27 22:24:10 -07:00
Girish Ramakrishnan 79eecd8b3e OVH requires region to be set
https://docs.ovh.com/gb/en/public-cloud/getting_started_with_the_swift_S3_API/#configure-aws-client
2020-05-27 18:11:28 -07:00
Girish Ramakrishnan ca09f64c12 force path style to true for minio
part of cloudron/box#680
2020-05-27 17:50:23 -07:00
Girish Ramakrishnan dea1f01998 Put some ordering 2020-05-27 09:34:04 -07:00
Girish Ramakrishnan 8cfae92c24 Keep the app backup list concise
if you have even more than 10 apps, the dialog overflows and makes for bad
screenshots...
2020-05-27 09:21:16 -07:00
Johannes Zellner 989a5ba685 Fix docs link to resurrect uninstalled apps 2020-05-27 13:38:43 +02:00
Girish Ramakrishnan a9e49d98fd Wait for sometime to refresh mail domains 2020-05-26 17:02:01 -07:00
Johannes Zellner f66d4e34d6 Bring back backupId clipboard logic 2020-05-25 21:47:58 +02:00
Johannes Zellner 989820183c Remove unused clipboard function 2020-05-25 21:47:10 +02:00
Girish Ramakrishnan 53f0e6c7d3 Fix regression in import UI 2020-05-24 18:44:42 -07:00
Girish Ramakrishnan 1608faecea Make min period as 12 hours
because we only collect disk data twice a day
2020-05-23 12:40:41 -07:00
Girish Ramakrishnan 4260082726 rename variable to avoid name conflict 2020-05-22 14:52:23 -07:00
Girish Ramakrishnan ca573dec91 hide the ruler 2020-05-22 12:05:23 -07:00
Johannes Zellner 3e252e1fd8 app disk usage is only collected twice a day 2020-05-22 19:48:18 +02:00
Johannes Zellner 7adc153e57 Also add swap to apps memory graph 2020-05-22 18:04:33 +02:00
Johannes Zellner ae105d9f83 Fixup app disk usage graphs 2020-05-22 17:16:37 +02:00
Johannes Zellner 87c895bd76 Show graph minutes, since we now have a smaller resolution 2020-05-22 16:33:27 +02:00
Johannes Zellner 034b2b2ddd Add backup details dialog
When a backup contains many apps, displaying them in the main backup
list is confusing and hides most apps
2020-05-22 13:48:29 +02:00
Girish Ramakrishnan fb5a789f55 1 monthly does not make much sense 2020-05-21 14:09:06 -07:00
Johannes Zellner 2b36a2f8cb Fix busy state for automatic updates and backups 2020-05-20 12:16:35 +02:00
Johannes Zellner d2a81ce907 Move retention and backup schedule interval into a separate settings dialog 2020-05-19 16:13:20 +02:00
Johannes Zellner 1f0b0d7bd1 Add all wasabi regions 2020-05-19 14:52:42 +02:00
Girish Ramakrishnan 735527a0f0 rework the updates ui to show the app id
browser are hiding the URL bar and it's becoming harder to get to that id
2020-05-18 14:57:24 -07:00
Girish Ramakrishnan 4dc034dd5e better redis label 2020-05-18 14:57:13 -07:00
Girish Ramakrishnan 4bfe4079cc Show enabled/disabled with appropriate class 2020-05-17 09:11:19 -07:00
Girish Ramakrishnan 66eff3a020 Add save/restore backup config to app view 2020-05-16 11:19:47 -07:00
Girish Ramakrishnan 401c561238 Fix spacing in restore view 2020-05-16 10:32:33 -07:00
Girish Ramakrishnan 606fe87ca0 backups: show the app info in contents 2020-05-16 09:47:00 -07:00
Girish Ramakrishnan f4775cc17c eventlog: handle update error 2020-05-15 21:37:27 -07:00
Girish Ramakrishnan a2e941970a Show endpoint 2020-05-15 16:05:30 -07:00
Girish Ramakrishnan c2ed909818 fixes to backups view 2020-05-15 12:48:54 -07:00
Johannes Zellner c38c440e63 Do not throw exception if no app graph data is yet available 2020-05-15 11:49:02 +02:00
Johannes Zellner 29b0785594 Fix cleanup backup button style
Sorry for messing with the btn style names, this grew
2020-05-15 11:41:55 +02:00
Johannes Zellner e15dcd41db Fix documentation links in restore view 2020-05-15 11:40:41 +02:00
Girish Ramakrishnan 6528461873 Add backup listing UI 2020-05-14 22:42:41 -07:00
Girish Ramakrishnan a8f5b5d4e4 Change label to MB 2020-05-14 21:56:22 -07:00
Girish Ramakrishnan be489744c9 Add some retention policies 2020-05-14 21:36:22 -07:00
Girish Ramakrishnan cd0b7ed3d2 rename to keepWithinSecs 2020-05-14 16:45:52 -07:00
Girish Ramakrishnan 3ebc5c6b9d retentionSecs is now retentionPolicy 2020-05-14 16:41:55 -07:00
Girish Ramakrishnan 66ada600b7 rename retentionSecs to retentionPolicy 2020-05-14 16:27:06 -07:00
Girish Ramakrishnan 4871d5df9d hide the binds ui for this release 2020-05-14 16:09:59 -07:00
Johannes Zellner 7088e6682b Add button to upload and pre-fill backup config 2020-05-15 00:32:49 +02:00
Johannes Zellner babe0adffb Remove secret values and add encryption flag in restore config json 2020-05-14 23:19:17 +02:00
Johannes Zellner 8f0a76ecef Make SECRET_PLACEHOLDER available globally 2020-05-14 23:04:19 +02:00
Girish Ramakrishnan 23607c303c typo 2020-05-13 22:37:11 -07:00
Girish Ramakrishnan 884b7062c9 rename version to packageVersion 2020-05-13 21:54:52 -07:00
Johannes Zellner 07650d424a Show a bit more explanation on the system memory graph 2020-05-14 00:01:42 +02:00
Johannes Zellner 218ec9c678 Plot app memory against the apps memory limit 2020-05-13 23:38:32 +02:00
Johannes Zellner 8b7c3308b3 Remove noisy dots in graphs 2020-05-13 23:35:14 +02:00
Johannes Zellner ca9528fa4e Show app memory in system view 2020-05-13 23:34:14 +02:00
Johannes Zellner aef625ba31 Make it explicit what the graphs show in which units 2020-05-13 23:11:30 +02:00
Johannes Zellner e5c8f2caec Fixup disk graph summary 2020-05-13 22:53:51 +02:00
Johannes Zellner 5c06305f85 Remove unnecessary graphs header and dim top actions 2020-05-13 21:44:26 +02:00
Johannes Zellner 428893d5c5 Actually use cpu values in system graphs 2020-05-13 21:41:16 +02:00
Johannes Zellner fc7277a542 Only show redis services line if we even have a redis 2020-05-13 20:45:03 +02:00
Johannes Zellner c8c6b15285 Rework the system view layout 2020-05-13 20:41:56 +02:00
Girish Ramakrishnan 0a987bdec9 show lock for encrypted backups 2020-05-13 00:03:54 -07:00
Girish Ramakrishnan ecc4fee84e restore UI fixes for encrypted backups 2020-05-12 22:30:43 -07:00
Girish Ramakrishnan 4802ecfc29 Move the download config down 2020-05-12 21:59:01 -07:00
Girish Ramakrishnan 436f415d9f Add space in save button 2020-05-12 21:43:35 -07:00
Girish Ramakrishnan 164480834a do not reset password and other settings on provider change 2020-05-12 21:41:35 -07:00
Girish Ramakrishnan 68642e056c Show error message in app view 2020-05-12 21:34:44 -07:00
Girish Ramakrishnan 9033c6e1d4 user param is not needed 2020-05-12 21:30:57 -07:00
Girish Ramakrishnan 89fc6feb5f password is not stored 2020-05-12 21:30:52 -07:00
Johannes Zellner 80dc9568ce Remove graphs page 2020-05-13 01:15:04 +02:00
Johannes Zellner 5774a7893f Improve app graph layout 2020-05-13 01:12:13 +02:00
Johannes Zellner abd9ea9ec5 Add memory and disk graphs to app view 2020-05-13 00:42:34 +02:00
Girish Ramakrishnan 8799882f09 show warning only if location or format changed 2020-05-12 14:59:06 -07:00
Girish Ramakrishnan f85a4878de rename backup key to password 2020-05-12 14:53:37 -07:00
Johannes Zellner ae87213105 Show hint about required subscription for app update 2020-05-11 23:14:19 +02:00
Johannes Zellner 33bd86a2c7 Show larger app configure icon on mobile 2020-05-11 12:01:01 +02:00
Girish Ramakrishnan 2092ae22dc redis: show app fqdn instead of location 2020-05-07 09:31:31 -07:00
Johannes Zellner aa9317069a Group redis services and have them collapsed 2020-05-05 16:36:52 +02:00
Girish Ramakrishnan a31ea92649 Add a UI for binds 2020-05-02 11:07:36 -07:00
Girish Ramakrishnan b8f18bdec2 Add OVH Object Storage regions 2020-04-29 13:13:01 -07:00
Johannes Zellner 704977d5f6 Avoid some flickering of apps ui while loading 2020-04-28 15:52:04 +02:00
Johannes Zellner 0757c20d59 Show inline text with backup config downlaod link to avoid prominent button 2020-04-21 11:26:52 +02:00
Girish Ramakrishnan fa08847d6d Query aliases for each mailbox 2020-04-20 19:18:11 -07:00
Girish Ramakrishnan f91f08628a Better variable name 2020-04-20 16:35:52 -07:00
Girish Ramakrishnan 9ebf6b06dd mail: implement aliases across domains
Part of #577
2020-04-20 16:07:23 -07:00
Johannes Zellner 357d5e46a3 Add backup config download button 2020-04-20 18:21:35 +02:00
Girish Ramakrishnan c0f5526801 Simple add a used label for disks that contains nothing we monitor 2020-04-18 23:02:16 -07:00
Girish Ramakrishnan 861204e442 sort returns compare value and not bool 2020-04-18 22:56:12 -07:00
Girish Ramakrishnan eb90b614ea disks: busy flag 2020-04-18 22:48:09 -07:00
Girish Ramakrishnan d087ed2349 graph query exceeeds param limit
node.js has some built-in http header limit. when this gets exceeded,
it terminates the connection and all the queued queries fail as well
2020-04-18 21:25:10 -07:00
Girish Ramakrishnan 6ee7e75465 do not popup error dialogs 2020-04-18 18:09:54 -07:00
Girish Ramakrishnan c2b80d7aba show icon for list 2020-04-18 17:39:13 -07:00
Girish Ramakrishnan a95e8633cd mail list: add members only checkbox 2020-04-17 17:55:07 -07:00
Johannes Zellner e3adbbe000 Only show subscription setup dialog when coming from free 2020-04-14 18:38:59 +02:00
Johannes Zellner eef360673b Also hide the app header bits to avoid empty ui fragments while loading 2020-04-12 13:20:01 +02:00
Girish Ramakrishnan 36e298c758 check for updates wants more space 2020-04-11 17:46:19 -07:00
Girish Ramakrishnan 275157f27b Show logs link when updater has error 2020-04-11 17:44:04 -07:00
Girish Ramakrishnan e776deaa3f Add note on Ext4/NFS mounts only 2020-04-09 15:49:47 -07:00
Johannes Zellner 4fc8e9b45e Ensure disable state for all form elements in backup import 2020-04-09 13:15:26 +02:00
Johannes Zellner fe41eec7c5 Fix spacing on import button in app view 2020-04-09 13:13:14 +02:00
Johannes Zellner d1d1d22734 Ensure we only show the tabs and content when app is loaded 2020-04-08 12:56:57 +02:00
Girish Ramakrishnan da8b76957a sort disk contents by usage 2020-04-03 10:41:04 -07:00
Girish Ramakrishnan 305f9fd1cf show apps with automatic backups disabled 2020-04-03 10:36:51 -07:00
Girish Ramakrishnan cd2a94ddb8 typo in variable name 2020-04-03 09:56:38 -07:00
Johannes Zellner a2df4db504 Parse task creationTime also as utc 2020-04-02 12:19:42 +02:00
Girish Ramakrishnan b7740a4758 do not count reserved as used 2020-04-01 22:15:03 -07:00
Girish Ramakrishnan 62c24de5c4 don't say ubuntu
https://forum.cloudron.io/topic/2228/what-type-area-of-data-makes-up-other-in-disk-usage/4
2020-04-01 18:39:05 -07:00
Girish Ramakrishnan 5ed3e67b76 graphs: ubuntu is only on the root mount point 2020-04-01 16:56:56 -07:00
Girish Ramakrishnan c7f2314a15 add note that memory is 1024 based 2020-04-01 16:42:20 -07:00
Girish Ramakrishnan 420c7ebd67 Fixup mail sizes to be 1000 and not 1024 2020-04-01 16:29:10 -07:00
Girish Ramakrishnan b93b1a6eec Fix prettyDiskSize to use 1000 instead of 1024 2020-04-01 16:26:47 -07:00
Girish Ramakrishnan 7d52be6e99 system: setError is not defined 2020-03-31 18:47:19 -07:00
Girish Ramakrishnan 9b1f0e394a set busy to false on error 2020-03-31 17:45:34 -07:00
Girish Ramakrishnan 1b0cb5d455 remove API calls to add/remove mail domain separately
part of cloudron/box#669
2020-03-31 10:59:01 -07:00
Girish Ramakrishnan 9b79d59d93 Add API token note 2020-03-30 22:37:25 -07:00
Girish Ramakrishnan 3e12316ea1 better wording from rob 2020-03-30 22:34:47 -07:00
Johannes Zellner 1b38c0111f Add turn to logviewer 2020-03-30 18:43:43 +02:00
Girish Ramakrishnan 5542393eb5 branding: fix login page title 2020-03-28 22:59:07 -07:00
Girish Ramakrishnan ad48bc0ee8 mail: refresh in the background 2020-03-28 17:48:11 -07:00
Girish Ramakrishnan ba0e5d0b59 query 1000 aliases and mailboxes
we don't handle pagination yet. it's not needed
2020-03-28 17:35:53 -07:00
Girish Ramakrishnan 1c5ff88e3c Use space instead of command for tag-input
this makes sure that email aliases wrap. if we used comma, it does not wrap
2020-03-28 16:46:19 -07:00
Girish Ramakrishnan bf7d4a550e ftp apps can be set a per-app password
this is useful for use in ftp clients
2020-03-26 21:50:44 -07:00
Girish Ramakrishnan 324bc763fc mail eventlog is owner only 2020-03-26 18:56:32 -07:00
Girish Ramakrishnan f9fb2ca3a1 Fixup users filter 2020-03-26 18:32:49 -07:00
Girish Ramakrishnan b5eac7c91b email: add type filter to eventlog 2020-03-25 22:07:01 -07:00
Johannes Zellner 3c858ca0fd Only show the progress bar when task is actually active 2020-03-26 00:22:46 +01:00
Johannes Zellner da9d634b83 Remove already hidden task stop button 2020-03-26 00:19:50 +01:00
Johannes Zellner 128704400f Hook up task cancel action 2020-03-26 00:19:06 +01:00
Johannes Zellner a3594322bd Show task cancel button after 5min 2020-03-26 00:16:23 +01:00
Girish Ramakrishnan fe4b3d5f1d branding: use separate css 2020-03-25 08:56:56 -07:00
Johannes Zellner da08da2b54 Use footer info from settings to show empty on default 2020-03-25 07:00:53 +01:00
Johannes Zellner 5deb5f79bd Ensure textareas don't overflow horizontally on resize 2020-03-25 06:58:38 +01:00
Johannes Zellner 9f0d694f0a Prevent angular crash when adding already existing tag 2020-03-25 06:51:42 +01:00
Johannes Zellner 4153fb7d1e Use theme for tag-input tags 2020-03-25 06:51:42 +01:00
Johannes Zellner 6994ec0f03 Allow to click anywhere in tag-input for focus 2020-03-25 06:51:42 +01:00
Johannes Zellner e1af60cfa9 Fix tag-input with flex layout to better overflow 2020-03-25 06:51:42 +01:00
Johannes Zellner 7bcec61e6d Make tag-input support dirty handling on tag deletion 2020-03-25 06:51:42 +01:00
Girish Ramakrishnan dde287f05d avatar size is 128px 2020-03-24 13:04:12 -07:00
Girish Ramakrishnan 27fc37e55c descriptive mail eventlog 2020-03-20 13:05:58 -07:00
Girish Ramakrishnan ad901760f6 move footer to separate section 2020-03-19 23:28:22 -07:00
Girish Ramakrishnan 973029865e Branding UI changes 2020-03-19 22:59:30 -07:00
Girish Ramakrishnan 52e4fedd16 fieldset must be inside form 2020-03-19 19:26:19 -07:00
Girish Ramakrishnan b81ba49370 CPU shares is a percent 2020-03-19 17:15:08 -07:00
Girish Ramakrishnan 39a0f93f69 add cpuShares 2020-03-19 17:11:51 -07:00
Girish Ramakrishnan 53cb83eacc eventlog: add start/stop/restart logs 2020-03-19 17:05:50 -07:00
Girish Ramakrishnan b307d278b0 mailboxName should have lower priority than location change 2020-03-19 16:48:46 -07:00
Girish Ramakrishnan 14348eba38 Move name and logo into branding page 2020-03-18 22:11:33 -07:00
Girish Ramakrishnan cead5b74ae if ldap is noop, show a message 2020-03-18 21:44:55 -07:00
Girish Ramakrishnan 2e2a945f7c add custom apps link 2020-03-18 21:25:19 -07:00
Girish Ramakrishnan 0e3ae2b450 add new branding view 2020-03-18 17:53:50 -07:00
Girish Ramakrishnan 19e2df65ca backups: hide configure button for non-owners 2020-03-18 17:24:20 -07:00
Girish Ramakrishnan 565d715a66 remove extra break 2020-03-18 13:43:15 -07:00
Girish Ramakrishnan abe6f55aa6 gcdns: fix add/save 2020-03-17 22:51:47 -07:00
Girish Ramakrishnan c278d0c5d4 bring back reboot button 2020-03-17 22:26:01 -07:00
Girish Ramakrishnan a7e2c74158 more linode warnings 2020-03-13 12:05:47 -07:00
Girish Ramakrishnan d84900d601 linode: dns frontend 2020-03-13 11:32:30 -07:00
Girish Ramakrishnan fdda28d67f lint 2020-03-12 17:07:17 -07:00
110 changed files with 41593 additions and 15621 deletions
+5
View File
@@ -4,3 +4,8 @@ node_modules/
# vim swap files
*.swp
# these are not done yet
src/translation/ja.json
src/translation/pl.json
src/translation/si.json
src/translation/gl.json
+20
View File
@@ -0,0 +1,20 @@
{
"node": true,
"browser": true,
"unused": true,
"esversion": 6,
"globalstrict": false,
"predef": [
"$",
"angular",
"async",
"describe",
"it",
"before",
"after",
"require",
"monaco",
"Mimer",
"ISTATES"
]
}
+1 -1
View File
@@ -1,5 +1,5 @@
The Cloudron Subscription license
Copyright (c) 2019 Cloudron UG
Copyright (c) 2022 Cloudron UG
With regard to the Cloudron Software:
+11 -55
View File
@@ -1,64 +1,20 @@
# Cloudron Dashboard
[Cloudron](https://cloudron.io) is the best way to run apps on your server.
This is the front end code of Cloudron. The backend code is [here](https://git.cloudron.io/cloudron/box).
Web applications like email, contacts, blog, chat are the backbone of the modern
internet. Yet, we live in a world where hosting these essential applications is
a complex task.
## Developing
We are building the ultimate platform for self-hosting web apps. The Cloudron allows
anyone to effortlessly host web applications on their server on their own terms.
* `npm install`
* `gulp develop --api-origin=https://my.example.com`
## Features
## License
* Single click install for apps. Check out the [App Store](https://cloudron.io/appstore.html).
Please note that the Cloudron code is under a source-available license. This is not the same as an
open source license but ensures the code is available for introspection (and hacking!).
* Per-app encrypted backups and restores.
## Contributions
* App updates delivered via the App Store.
* Secure - Cloudron manages the firewall. All apps are secured with HTTPS. Certificates are
installed and renewed automatically.
* Centralized User & Group management. Control who can access which app.
* Single Sign On. Use same credentials across all apps.
* Automatic updates for the Cloudron platform.
* Trivially migrate to another server keeping your apps and data (for example, switch your
infrastructure provider or move to a bigger server).
* Comprehensive [REST API](https://cloudron.io/developer/api/).
* [CLI](https://cloudron.io/documentation/cli/) to configure apps.
* Alerts, audit logs, graphs, dns management ... and much more
## Demo
Try our demo at https://my.demo.cloudron.io (username: cloudron password: cloudron).
## Installing
You can install the Cloudron platform on your own server or get a managed server
from cloudron.io. In either case, the Cloudron platform will keep your server and
apps up-to-date and secure.
* [Selfhosting](https://cloudron.io/documentation/installation/) - [Pricing](https://cloudron.io/pricing.html)
* [Managed Hosting](https://cloudron.io/managed.html)
## Documentation
* [Documentation](https://cloudron.io/documentation/)
## Related repos
The [base image repo](https://git.cloudron.io/cloudron/docker-base-image) is the parent image of all
the containers in the Cloudron.
## Community
* [Forum](https://forum.cloudron.io/)
* [Support](mailto:support@cloudron.io)
Just to give some heads up, we are a bit restrictive in merging changes. We are a small team and
would like to keep our maintenance burden low. It might be best to discuss features first in the [forum](https://forum.cloudron.io),
to also figure out how many other people will use it to justify maintenance for a feature.
+73 -40
View File
@@ -10,7 +10,7 @@ var argv = require('yargs').argv,
execSync = require('child_process').execSync,
gulp = require('gulp'),
rimraf = require('rimraf'),
sass = require('gulp-sass'),
sass = require('gulp-sass')(require('node-sass')),
serve = require('gulp-serve'),
sourcemaps = require('gulp-sourcemaps');
@@ -35,7 +35,7 @@ if (argv.apiOrigin) {
var appstore = {
webOrigin: argv.appstoreWebOrigin || '',
apiOrigin: argv.appstoreApiOrigin || ''
}
};
console.log();
console.log('Cloudron API: %s', apiOrigin || 'default');
@@ -47,14 +47,8 @@ console.log(' Api: %s', appstore.apiOrigin || 'no');
console.log();
gulp.task('fontawesome', function () {
return gulp.src([
'node_modules/@fortawesome/fontawesome-free/*css*/all.min.css',
'node_modules/@fortawesome/fontawesome-free/*webfonts*/*.eot',
'node_modules/@fortawesome/fontawesome-free/*webfonts*/*.svg',
'node_modules/@fortawesome/fontawesome-free/*webfonts*/*.ttf',
'node_modules/@fortawesome/fontawesome-free/*webfonts*/*.woff',
'node_modules/@fortawesome/fontawesome-free/*webfonts*/*.woff2'
]).pipe(gulp.dest('dist/3rdparty/fontawesome/'));
return gulp.src('node_modules/@fortawesome/fontawesome-free/**/*')
.pipe(gulp.dest('dist/3rdparty/fontawesome/'));
});
gulp.task('bootstrap', function () {
@@ -62,6 +56,28 @@ gulp.task('bootstrap', function () {
.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('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',
@@ -72,11 +88,11 @@ gulp.task('3rdparty-copy', function () {
'src/3rdparty/**/*.svg',
'src/3rdparty/**/*.gif',
'src/3rdparty/**/*.ttf'
])
])
.pipe(gulp.dest('dist/3rdparty/'));
});
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'bootstrap', 'fontawesome']));
gulp.task('3rdparty', gulp.series(['3rdparty-copy', 'monaco', 'xterm', 'bootstrap', 'fontawesome']));
// --------------
// JavaScript
@@ -87,8 +103,9 @@ gulp.task('js-index', function () {
'src/js/index.js',
'src/js/client.js',
'src/js/main.js',
'src/js/utils.js',
'src/views/*.js'
])
])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('index.js', { newLine: ';' }))
@@ -97,17 +114,26 @@ gulp.task('js-index', function () {
});
gulp.task('js-logs', function () {
return gulp.src(['src/js/logs.js', 'src/js/client.js'])
.pipe(ejs({ apiOrigin: apiOrigin, appstore: appstore }, {}, { ext: '.js' }))
return gulp.src(['src/js/logs.js', 'src/js/client.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'])
.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'])
.pipe(ejs({ apiOrigin: apiOrigin, appstore: appstore }, {}, { ext: '.js' }))
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())
@@ -115,8 +141,8 @@ gulp.task('js-terminal', function () {
});
gulp.task('js-login', function () {
return gulp.src(['src/js/login.js'])
.pipe(ejs({ apiOrigin: apiOrigin, appstore: appstore }, {}, { ext: '.js' }))
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(sourcemaps.write())
@@ -124,8 +150,8 @@ gulp.task('js-login', function () {
});
gulp.task('js-setupaccount', function () {
return gulp.src(['src/js/setupaccount.js'])
.pipe(ejs({ apiOrigin: apiOrigin, appstore: appstore }, {}, { ext: '.js' }))
return gulp.src(['src/js/setupaccount.js', 'src/js/utils.js'])
.pipe(ejs({ apiOrigin: apiOrigin, revision: revision, appstore: appstore }, {}, { ext: '.js' }))
.pipe(sourcemaps.init())
.pipe(concat('setupaccount.js', { newLine: ';' }))
.pipe(sourcemaps.write())
@@ -133,8 +159,8 @@ gulp.task('js-setupaccount', function () {
});
gulp.task('js-setup', function () {
return gulp.src(['src/js/setup.js', 'src/js/client.js'])
.pipe(ejs({ apiOrigin: apiOrigin, appstore: appstore }, {}, { ext: '.js' }))
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' }))
.pipe(sourcemaps.init())
.pipe(concat('setup.js', { newLine: ';' }))
.pipe(sourcemaps.write())
@@ -142,8 +168,8 @@ gulp.task('js-setup', function () {
});
gulp.task('js-setupdns', function () {
return gulp.src(['src/js/setupdns.js', 'src/js/client.js'])
.pipe(ejs({ apiOrigin: apiOrigin, appstore: appstore }, {}, { ext: '.js' }))
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())
@@ -151,15 +177,15 @@ gulp.task('js-setupdns', function () {
});
gulp.task('js-restore', function () {
return gulp.src(['src/js/restore.js', 'src/js/client.js'])
.pipe(ejs({ apiOrigin: apiOrigin, appstore: appstore }, {}, { ext: '.js' }))
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' }))
.pipe(sourcemaps.init())
.pipe(concat('restore.js', { newLine: ';' }))
.pipe(sourcemaps.write())
.pipe(gulp.dest('dist/js'));
});
gulp.task('js', gulp.series([ 'js-index', 'js-logs', 'js-terminal', 'js-login', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ]));
gulp.task('js', gulp.series([ 'js-index', 'js-logs', 'js-filemanager', 'js-terminal', 'js-login', 'js-setupaccount', 'js-setup', 'js-setupdns', 'js-restore' ]));
// --------------
// HTML
@@ -170,7 +196,7 @@ gulp.task('html-views', function () {
});
gulp.task('html-templates', function () {
return gulp.src('src/templates/**/*.html').pipe(gulp.dest('dist/templates'));
return gulp.src('src/templates/**/*').pipe(gulp.dest('dist/templates'));
});
gulp.task('html-raw', function () {
@@ -198,6 +224,11 @@ gulp.task('images', function () {
.pipe(gulp.dest('dist/img'));
});
gulp.task('translation', function () {
return gulp.src('src/translation/**')
.pipe(gulp.dest('dist/translation'));
});
gulp.task('timezones', function (done) {
execSync('./scripts/createTimezones.js ./dist/js/timezones.js');
done();
@@ -212,28 +243,30 @@ gulp.task('clean', function (done) {
done();
});
gulp.task('default', gulp.series(['clean', 'html', 'js', 'timezones', '3rdparty', 'images', 'css']));
gulp.task('default', gulp.series(['clean', 'html', 'js', 'timezones', '3rdparty', 'translation', 'images', 'css']));
gulp.task('watch', function (done) {
gulp.watch(['src/*.scss'], gulp.series(['css']));
gulp.watch(['src/img/*'], gulp.series(['images']));
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/templates/*.html'], gulp.series(['html-templates']));
gulp.watch(['scripts/createTimezones.js'], gulp.series(['timezones']));
gulp.watch(['src/js/setup.js', 'src/js/client.js'], gulp.series(['js-setup']));
gulp.watch(['src/js/setupdns.js', 'src/js/client.js'], gulp.series(['js-setupdns']));
gulp.watch(['src/js/restore.js', 'src/js/client.js'], gulp.series(['js-restore']));
gulp.watch(['src/js/logs.js', 'src/js/client.js'], gulp.series(['js-logs']));
gulp.watch(['src/js/terminal.js', 'src/js/client.js'], gulp.series(['js-terminal']));
gulp.watch(['src/js/login.js'], gulp.series(['js-login']));
gulp.watch(['src/js/setupaccount.js'], gulp.series(['js-setupaccount']));
gulp.watch(['src/js/index.js', 'src/js/client.js', 'src/js/main.js', 'src/views/*.js'], gulp.series(['js-index']));
gulp.watch(['scripts/createTimezones.js', 'src/js/utils.js'], gulp.series(['timezones']));
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'], 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/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/3rdparty/**/*'], gulp.series(['3rdparty']));
done();
});
gulp.task('serve', serve({ root: 'dist', port: 4000 }));
gulp.task('serve', serve({ root: 'dist', port: 4000, hostname: '0.0.0.0' }));
gulp.task('develop', gulp.series(['default', 'watch', 'serve']));
+10844 -1788
View File
File diff suppressed because it is too large Load Diff
+15 -9
View File
@@ -3,7 +3,8 @@
"version": "1.0.0",
"description": "[Cloudron](https://cloudron.io) is the best way to run apps on your server.",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
"test": "echo \"Error: no test specified\" && exit 1",
"update-translations": "curl https://translate.cloudron.io/api/components/cloudron/dashboard/file/ -o lang.zip && unzip -jo lang.zip -d ./src/translation/ && rm lang.zip"
},
"repository": {
"type": "git",
@@ -12,18 +13,23 @@
"author": "",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.5.0",
"@fortawesome/fontawesome-free": "^5.15.4",
"bootstrap-sass": "^3.4.1",
"gulp": "^4.0.0",
"gulp-autoprefixer": "^5.0.0",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-cssnano": "^2.1.3",
"gulp-ejs": "^3.3.0",
"gulp-sass": "^4.0.2",
"gulp-ejs": "^5.1.0",
"gulp-sass": "^5.1.0",
"gulp-serve": "^1.4.0",
"gulp-sourcemaps": "^2.6.5",
"rimraf": "^2.6.2",
"yargs": "^11.0.0"
"gulp-sourcemaps": "^3.0.0",
"monaco-editor": "^0.32.1",
"node-sass": "^7.0.1",
"rimraf": "^3.0.2",
"xterm": "^4.17.0",
"xterm-addon-attach": "^0.6.0",
"xterm-addon-fit": "^0.5.0",
"yargs": "^17.3.1"
},
"eslintConfig": {
"env": {
+1 -1
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/developer/login`, { username: username, password: password }).end(function (error, result) {
superagent.post(`https://${cloudronDomain}/api/v1/cloudron/login`, { username: username, password: password }).end(function (error, result) {
if (error || result.statusCode !== 200) {
console.log('Login failed');
return getAccessToken(callback);
+1 -1
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/developer/login`, { username: username, password: password }).end(function (error, result) {
superagent.post(`https://${cloudronDomain}/api/v1/cloudron/login`, { username: username, password: password }).end(function (error, result) {
if (error || result.statusCode !== 200) {
console.log('Login failed');
return getAccessToken(callback);
+1
View File
@@ -0,0 +1 @@
@keyframes chartjs-render-animation{from{opacity:.99}to{opacity:1}}.chartjs-render-monitor{animation:chartjs-render-animation 1ms}.chartjs-size-monitor,.chartjs-size-monitor-expand,.chartjs-size-monitor-shrink{position:absolute;direction:ltr;left:0;top:0;right:0;bottom:0;overflow:hidden;pointer-events:none;visibility:hidden;z-index:-1}.chartjs-size-monitor-expand>div{position:absolute;width:1000000px;height:1000000px;left:0;top:0}.chartjs-size-monitor-shrink>div{position:absolute;width:200%;height:200%;left:0;top:0}
File diff suppressed because one or more lines are too long
-10
View File
File diff suppressed because one or more lines are too long
+11 -4
View File
@@ -1,5 +1,12 @@
"use strict";
// -------------------------------
// WARNING
// -------------------------------
// This file is taken from https://github.com/sebastianha/angular-bootstrap-multiselect
// There are local modifications like support for translation
// -------------------------------
angular.module("ui.multiselect", ["multiselect.tpl.html"])
//from bootstrap-ui typeahead parser
.factory("optionParser", ["$parse", function($parse) {
@@ -23,7 +30,7 @@ angular.module("ui.multiselect", ["multiselect.tpl.html"])
}
};
}])
.directive("multiselect", ["$parse", "$document", "$compile", "$interpolate", "optionParser", function($parse, $document, $compile, $interpolate, optionParser) {
.directive("multiselect", ["$parse", "$document", "$compile", "$interpolate", "$translate", "optionParser", function($parse, $document, $compile, $interpolate, $translate, optionParser) {
return {
restrict: "E",
require : "ngModel",
@@ -154,7 +161,7 @@ angular.module("ui.multiselect", ["multiselect.tpl.html"])
function getHeaderText() {
if(isEmpty(modelCtrl.$modelValue)) {
scope.header = attrs.msHeader || "Select";
scope.header = attrs.msHeader || $translate.instant('main.multiselect.select');
return scope.header;
}
@@ -162,7 +169,7 @@ angular.module("ui.multiselect", ["multiselect.tpl.html"])
if(attrs.msSelected) {
scope.header = $interpolate(attrs.msSelected)(scope);
} else {
scope.header = modelCtrl.$modelValue.length + " " + "selected";
scope.header = $translate.instant('main.multiselect.selected', { n: modelCtrl.$modelValue.length });
}
} else {
var local = {};
@@ -342,7 +349,7 @@ angular.module("multiselect.tpl.html", []).run(["$templateCache", function($temp
" <div ng-style=\"maxWidth\" style=\"padding-right: 13px; overflow: hidden; text-overflow: ellipsis;\">{{header}}</div><span class=\"caret\" style=\"position:absolute;right:10px;top:14px;\"></span>\n" +
" </button>\n" +
" <ul class=\"dropdown-menu\" style=\"margin-bottom:30px;padding-left:5px;padding-right:5px;\" ng-style=\"ulStyle\">\n" +
" <input ng-show=\"items.length > filterAfterRows\" ng-model=\"filter\" style=\"padding: 0px 3px;margin-right: 15px; margin-bottom: 4px;\" placeholder=\"Type to filter options\">" +
" <input ng-show=\"items.length > filterAfterRows\" ng-model=\"filter\" style=\"padding: 0px 3px;margin-right: 15px; margin-bottom: 4px;\" placeholder=\"{{ 'main.multiselect.filterPlaceholder' | tr }}\">" +
" <li data-stopPropagation=\"true\" ng-repeat=\"i in items | filter:filter\" ng-class=\"{'dropdown-header': i.header, 'divider': i.divider}\">\n" +
" <a ng-if=\"!i.header && !i.divider\" ng-click=\"select($event, i)\" style=\"padding:3px 10px;cursor:pointer;\">\n" +
" <i class=\"fa\" ng-class=\"{'fa-check': i.checked, 'empty': !i.checked}\"></i> {{i.label}}" +
+9
View File
@@ -0,0 +1,9 @@
/*
AngularJS v1.5.8
(c) 2010-2016 Google, Inc. http://angularjs.org
License: MIT
*/
(function(n,c){'use strict';function l(b,a,g){var d=g.baseHref(),k=b[0];return function(b,e,f){var g,h;f=f||{};h=f.expires;g=c.isDefined(f.path)?f.path:d;c.isUndefined(e)&&(h="Thu, 01 Jan 1970 00:00:00 GMT",e="");c.isString(h)&&(h=new Date(h));e=encodeURIComponent(b)+"="+encodeURIComponent(e);e=e+(g?";path="+g:"")+(f.domain?";domain="+f.domain:"");e+=h?";expires="+h.toUTCString():"";e+=f.secure?";secure":"";f=e.length+1;4096<f&&a.warn("Cookie '"+b+"' possibly not set or overflowed because it was too large ("+
f+" > 4096 bytes)!");k.cookie=e}}c.module("ngCookies",["ng"]).provider("$cookies",[function(){var b=this.defaults={};this.$get=["$$cookieReader","$$cookieWriter",function(a,g){return{get:function(d){return a()[d]},getObject:function(d){return(d=this.get(d))?c.fromJson(d):d},getAll:function(){return a()},put:function(d,a,m){g(d,a,m?c.extend({},b,m):b)},putObject:function(d,b,a){this.put(d,c.toJson(b),a)},remove:function(a,k){g(a,void 0,k?c.extend({},b,k):b)}}}]}]);c.module("ngCookies").factory("$cookieStore",
["$cookies",function(b){return{get:function(a){return b.getObject(a)},put:function(a,c){b.putObject(a,c)},remove:function(a){b.remove(a)}}}]);l.$inject=["$document","$log","$browser"];c.module("ngCookies").provider("$$cookieWriter",function(){this.$get=l})})(window,window.angular);
//# sourceMappingURL=angular-cookies.min.js.map
@@ -0,0 +1,6 @@
/*!
* angular-translate - v2.18.3 - 2020-07-08
*
* Copyright (c) 2020 The angular-translate team, Pascal Precht; Licensed MIT
*/
!function(e,i){"function"==typeof define&&define.amd?define([],function(){return i()}):"object"==typeof module&&module.exports?module.exports=i():i()}(0,function(){function e(n,a){"use strict";return function(r){if(!(r&&(angular.isArray(r.files)||angular.isString(r.prefix)&&angular.isString(r.suffix))))throw new Error("Couldn't load static files, no files and prefix or suffix specified!");r.files||(r.files=[{prefix:r.prefix,suffix:r.suffix}]);for(var e=function(e){if(!e||!angular.isString(e.prefix)||!angular.isString(e.suffix))throw new Error("Couldn't load static file, no prefix or suffix specified!");var i=[e.prefix,r.key,e.suffix].join("");return angular.isObject(r.fileMap)&&r.fileMap[i]&&(i=r.fileMap[i]),a(angular.extend({url:i,method:"GET"},r.$http)).then(function(e){return e.data},function(){return n.reject(r.key)})},i=[],t=r.files.length,f=0;f<t;f++)i.push(e({prefix:r.files[f].prefix,key:r.key,suffix:r.files[f].suffix}));return n.all(i).then(function(e){for(var i=e.length,r={},t=0;t<i;t++)for(var f in e[t])r[f]=e[t][f];return r})}}return e.$inject=["$q","$http"],angular.module("pascalprecht.translate").factory("$translateStaticFilesLoader",e),e.displayName="$translateStaticFilesLoader","pascalprecht.translate"});
@@ -0,0 +1,6 @@
/*!
* angular-translate - v2.18.3 - 2020-07-08
*
* Copyright (c) 2020 The angular-translate team, Pascal Precht; Licensed MIT
*/
!function(t,e){"function"==typeof define&&define.amd?define([],function(){return e()}):"object"==typeof module&&module.exports?module.exports=e():e()}(0,function(){function t(t){"use strict";var n;if(1===angular.version.major&&4<=angular.version.minor){var o=t.get("$cookies");n={get:function(t){return o.get(t)},put:function(t,e){o.put(t,e)}}}else{var r=t.get("$cookieStore");n={get:function(t){return r.get(t)},put:function(t,e){r.put(t,e)}}}return{get:function(t){return n.get(t)},set:function(t,e){n.put(t,e)},put:function(t,e){n.put(t,e)}}}return t.$inject=["$injector"],angular.module("pascalprecht.translate").factory("$translateCookieStorage",t),t.displayName="$translateCookieStorage","pascalprecht.translate"});
@@ -0,0 +1,6 @@
/*!
* angular-translate - v2.18.3 - 2020-07-08
*
* Copyright (c) 2020 The angular-translate team, Pascal Precht; Licensed MIT
*/
!function(t,e){"function"==typeof define&&define.amd?define([],function(){return e()}):"object"==typeof module&&module.exports?module.exports=e():e()}(0,function(){function t(a,t){"use strict";var o,e={get:function(t){return o||(o=a.localStorage.getItem(t)),o},set:function(t,e){o=e,a.localStorage.setItem(t,e)},put:function(t,e){o=e,a.localStorage.setItem(t,e)}},r="localStorage"in a;if(r){var n="pascalprecht.translate.storageTest";try{r=null!==a.localStorage&&(a.localStorage.setItem(n,"foo"),a.localStorage.removeItem(n),!0)}catch(t){r=!1}}return r?e:t}return t.$inject=["$window","$translateCookieStorage"],angular.module("pascalprecht.translate").factory("$translateLocalStorage",t),t.displayName="$translateLocalStorageFactory","pascalprecht.translate"});
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+639
View File
@@ -0,0 +1,639 @@
(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);
+10
View File
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
-4
View File
@@ -1,4 +0,0 @@
/*! showdown-target-blank 02-11-2015 */
!function(){"use strict";var a=function(){return[{type:"output",regex:"<a(.*?)>",replace:function(a,b){return'<a target="_blank"'+b+">"}}]};"undefined"!=typeof window&&window.showdown&&window.showdown.extensions&&window.showdown.extension("targetblank",a),"undefined"!=typeof module&&(module.exports=a)}();
//# sourceMappingURL=showdown-target-blank.min.js.map
-130
View File
@@ -1,130 +0,0 @@
/**
* Implements the attach method, that attaches the terminal to a WebSocket stream.
* @module xterm/addons/attach/attach
* @license MIT
*/
(function (attach) {
if (typeof exports === 'object' && typeof module === 'object') {
/*
* CommonJS environment
*/
module.exports = attach(require('../../xterm'));
} else if (typeof define == 'function') {
/*
* Require.js is available
*/
define(['../../xterm'], attach);
} else {
/*
* Plain browser environment
*/
attach(window.Terminal);
}
})(function (Xterm) {
'use strict';
var exports = {};
/**
* Attaches the given terminal to the given socket.
*
* @param {Xterm} term - The terminal to be attached to the given socket.
* @param {WebSocket} socket - The socket to attach the current terminal.
* @param {boolean} bidirectional - Whether the terminal should send data
* to the socket as well.
* @param {boolean} buffered - Whether the rendering of incoming data
* should happen instantly or at a maximum
* frequency of 1 rendering per 10ms.
*/
exports.attach = function (term, socket, bidirectional, buffered) {
bidirectional = (typeof bidirectional == 'undefined') ? true : bidirectional;
term.socket = socket;
term._flushBuffer = function () {
term.write(term._attachSocketBuffer);
term._attachSocketBuffer = null;
clearTimeout(term._attachSocketBufferTimer);
term._attachSocketBufferTimer = null;
};
term._pushToBuffer = function (data) {
if (term._attachSocketBuffer) {
term._attachSocketBuffer += data;
} else {
term._attachSocketBuffer = data;
setTimeout(term._flushBuffer, 10);
}
};
term._getMessage = function (ev) {
if (buffered) {
term._pushToBuffer(ev.data);
} else {
term.write(ev.data);
}
};
term._sendData = function (data) {
if (socket.readyState !== 1) {
return;
}
socket.send(data);
};
socket.addEventListener('message', term._getMessage);
if (bidirectional) {
term.on('data', term._sendData);
}
socket.addEventListener('close', term.detach.bind(term, socket));
socket.addEventListener('error', term.detach.bind(term, socket));
};
/**
* Detaches the given terminal from the given socket
*
* @param {Xterm} term - The terminal to be detached from the given socket.
* @param {WebSocket} socket - The socket from which to detach the current
* terminal.
*/
exports.detach = function (term, socket) {
term.off('data', term._sendData);
socket = (typeof socket == 'undefined') ? term.socket : socket;
if (socket) {
socket.removeEventListener('message', term._getMessage);
}
delete term.socket;
};
/**
* Attaches the current terminal to the given socket
*
* @param {WebSocket} socket - The socket to attach the current terminal.
* @param {boolean} bidirectional - Whether the terminal should send data
* to the socket as well.
* @param {boolean} buffered - Whether the rendering of incoming data
* should happen instantly or at a maximum
* frequency of 1 rendering per 10ms.
*/
Xterm.prototype.attach = function (socket, bidirectional, buffered) {
return exports.attach(this, socket, bidirectional, buffered);
};
/**
* Detaches the current terminal from the given socket.
*
* @param {WebSocket} socket - The socket from which to detach the current
* terminal.
*/
Xterm.prototype.detach = function (socket) {
return exports.detach(this, socket);
};
return exports;
});
-86
View File
@@ -1,86 +0,0 @@
/**
* Fit terminal columns and rows to the dimensions of its DOM element.
*
* ## Approach
* - Rows: Truncate the division of the terminal parent element height by the terminal row height.
*
* - Columns: Truncate the division of the terminal parent element width by the terminal character
* width (apply display: inline at the terminal row and truncate its width with the current
* number of columns).
* @module xterm/addons/fit/fit
* @license MIT
*/
(function (fit) {
if (typeof exports === 'object' && typeof module === 'object') {
/*
* CommonJS environment
*/
module.exports = fit(require('../../xterm'));
} else if (typeof define == 'function') {
/*
* Require.js is available
*/
define(['../../xterm'], fit);
} else {
/*
* Plain browser environment
*/
fit(window.Terminal);
}
})(function (Xterm) {
var exports = {};
exports.proposeGeometry = function (term) {
if (!term.element.parentElement) {
return null;
}
var parentElementStyle = window.getComputedStyle(term.element.parentElement),
parentElementHeight = parseInt(parentElementStyle.getPropertyValue('height')),
parentElementWidth = Math.max(0, parseInt(parentElementStyle.getPropertyValue('width')) - 17),
elementStyle = window.getComputedStyle(term.element),
elementPaddingVer = parseInt(elementStyle.getPropertyValue('padding-top')) + parseInt(elementStyle.getPropertyValue('padding-bottom')),
elementPaddingHor = parseInt(elementStyle.getPropertyValue('padding-right')) + parseInt(elementStyle.getPropertyValue('padding-left')),
availableHeight = parentElementHeight - elementPaddingVer,
availableWidth = parentElementWidth - elementPaddingHor,
container = term.rowContainer,
subjectRow = term.rowContainer.firstElementChild,
contentBuffer = subjectRow.innerHTML,
characterHeight,
rows,
characterWidth,
cols,
geometry;
subjectRow.style.display = 'inline';
subjectRow.innerHTML = 'W'; // Common character for measuring width, although on monospace
characterWidth = subjectRow.getBoundingClientRect().width;
subjectRow.style.display = ''; // Revert style before calculating height, since they differ.
characterHeight = subjectRow.getBoundingClientRect().height;
subjectRow.innerHTML = contentBuffer;
rows = parseInt(availableHeight / characterHeight);
cols = parseInt(availableWidth / characterWidth);
geometry = {cols: cols, rows: rows};
return geometry;
};
exports.fit = function (term) {
var geometry = exports.proposeGeometry(term);
if (geometry) {
term.resize(geometry.cols, geometry.rows);
}
};
Xterm.prototype.proposeGeometry = function () {
return exports.proposeGeometry(this);
};
Xterm.prototype.fit = function () {
return exports.fit(this);
};
return exports;
});
-10
View File
@@ -1,10 +0,0 @@
.xterm.fullscreen {
position: fixed;
top: 0;
bottom: 0;
left: 0;
right: 0;
width: auto;
height: auto;
z-index: 255;
}
-50
View File
@@ -1,50 +0,0 @@
/**
* Fullscreen addon for xterm.js
* @module xterm/addons/fullscreen/fullscreen
* @license MIT
*/
(function (fullscreen) {
if (typeof exports === 'object' && typeof module === 'object') {
/*
* CommonJS environment
*/
module.exports = fullscreen(require('../../xterm'));
} else if (typeof define == 'function') {
/*
* Require.js is available
*/
define(['../../xterm'], fullscreen);
} else {
/*
* Plain browser environment
*/
fullscreen(window.Terminal);
}
})(function (Xterm) {
var exports = {};
/**
* Toggle the given terminal's fullscreen mode.
* @param {Xterm} term - The terminal to toggle full screen mode
* @param {boolean} fullscreen - Toggle fullscreen on (true) or off (false)
*/
exports.toggleFullScreen = function (term, fullscreen) {
var fn;
if (typeof fullscreen == 'undefined') {
fn = (term.element.classList.contains('fullscreen')) ? 'remove' : 'add';
} else if (!fullscreen) {
fn = 'remove';
} else {
fn = 'add';
}
term.element.classList[fn]('fullscreen');
};
Xterm.prototype.toggleFullscreen = function (fullscreen) {
exports.toggleFullScreen(this, fullscreen);
};
return exports;
});
-207
View File
@@ -1,207 +0,0 @@
/**
* Methods for turning URL subscrings in the terminal's content into links (`a` DOM elements).
* @module xterm/addons/linkify/linkify
* @license MIT
*/
(function (linkify) {
if (typeof exports === 'object' && typeof module === 'object') {
/*
* CommonJS environment
*/
module.exports = linkify(require('../../xterm'));
} else if (typeof define == 'function') {
/*
* Require.js is available
*/
define(['../../xterm'], linkify);
} else {
/*
* Plain browser environment
*/
linkify(window.Terminal);
}
})(function (Xterm) {
'use strict';
var exports = {},
protocolClause = '(https?:\\/\\/)',
domainCharacterSet = '[\\da-z\\.-]+',
negatedDomainCharacterSet = '[^\\da-z\\.-]+',
domainBodyClause = '(' + domainCharacterSet + ')',
tldClause = '([a-z\\.]{2,6})',
ipClause = '((\\d{1,3}\\.){3}\\d{1,3})',
portClause = '(:\\d{1,5})',
hostClause = '((' + domainBodyClause + '\\.' + tldClause + ')|' + ipClause + ')' + portClause + '?',
pathClause = '(\\/[\\/\\w\\.-]*)*',
negatedPathCharacterSet = '[^\\/\\w\\.-]+',
bodyClause = hostClause + pathClause,
start = '(?:^|' + negatedDomainCharacterSet + ')(',
end = ')($|' + negatedPathCharacterSet + ')',
lenientUrlClause = start + protocolClause + '?' + bodyClause + end,
strictUrlClause = start + protocolClause + bodyClause + end,
lenientUrlRegex = new RegExp(lenientUrlClause),
strictUrlRegex = new RegExp(strictUrlClause);
/**
* Converts all valid URLs found in the given terminal line into
* hyperlinks. The terminal line can be either the HTML element itself
* or the index of the termina line in the children of the terminal
* rows container.
*
* @param {Xterm} terminal - The terminal that owns the given line.
* @param {number|HTMLDivElement} line - The terminal line that should get
* "linkified".
* @param {boolean} lenient - The regex type that will be used to identify links. If lenient is
* false, the regex requires a protocol clause. Defaults to true.
* @param {string} target - Sets target="" attribute with value provided to links.
* Default doesn't set target attribute
* @emits linkify
* @emits linkify:line
*/
exports.linkifyTerminalLine = function (terminal, line, lenient, target) {
if (typeof line == 'number') {
line = terminal.rowContainer.children[line];
} else if (! (line instanceof HTMLDivElement)) {
var message = 'The "line" argument should be either a number';
message += ' or an HTMLDivElement';
throw new TypeError(message);
}
if (typeof target === 'undefined') {
target = '';
} else {
target = 'target="' + target + '"';
}
var buffer = document.createElement('span'),
nodes = line.childNodes;
for (var j=0; j<nodes.length; j++) {
var node = nodes[j],
match;
/**
* Since we cannot access the TextNode's HTML representation
* from the instance itself, we assign its data as textContent
* to a dummy buffer span, in order to retrieve the TextNode's
* HTML representation from the buffer's innerHTML.
*/
buffer.textContent = node.data;
var nodeHTML = buffer.innerHTML;
/**
* Apply function only on TextNodes
*/
if (node.nodeType != node.TEXT_NODE) {
continue;
}
var url = exports.findLinkMatch(node.data, lenient);
if (!url) {
continue;
}
var startsWithProtocol = new RegExp('^' + protocolClause),
urlHasProtocol = url.match(startsWithProtocol),
href = (urlHasProtocol) ? url : 'http://' + url,
link = '<a href="' + href + '" ' + target + '>' + url + '</a>',
newHTML = nodeHTML.replace(url, link);
line.innerHTML = line.innerHTML.replace(nodeHTML, newHTML);
}
/**
* This event gets emitted when conversion of all URL susbtrings
* to HTML anchor elements (links) has finished, for a specific
* line of the current Xterm instance.
*
* @event linkify:line
*/
terminal.emit('linkify:line', line);
};
/**
* Finds a link within a block of text.
*
* @param {string} text - The text to search .
* @param {boolean} lenient - Whether to use the lenient search.
* @return {string} A URL.
*/
exports.findLinkMatch = function (text, lenient) {
var match = text.match(lenient ? lenientUrlRegex : strictUrlRegex);
if (!match || match.length === 0) {
return null;
}
return match[1];
}
/**
* Converts all valid URLs found in the terminal view into hyperlinks.
*
* @param {Xterm} terminal - The terminal that should get "linkified".
* @param {boolean} lenient - The regex type that will be used to identify links. If lenient is
* false, the regex requires a protocol clause. Defaults to true.
* @param {string} target - Sets target="" attribute with value provided to links.
* Default doesn't set target attribute
* @emits linkify
* @emits linkify:line
*/
exports.linkify = function (terminal, lenient, target) {
var rows = terminal.rowContainer.children;
lenient = (typeof lenient == "boolean") ? lenient : true;
for (var i=0; i<rows.length; i++) {
var line = rows[i];
exports.linkifyTerminalLine(terminal, line, lenient, target);
}
/**
* This event gets emitted when conversion of all URL substrings to
* HTML anchor elements (links) has finished for the current Xterm
* instance's view.
*
* @event linkify
*/
terminal.emit('linkify');
};
/**
* Extend Xterm prototype.
*/
/**
* Converts all valid URLs found in the current terminal linte into
* hyperlinks.
*
* @memberof Xterm
* @param {number|HTMLDivElement} line - The terminal line that should get
* "linkified".
* @param {boolean} lenient - The regex type that will be used to identify links. If lenient is
* false, the regex requires a protocol clause. Defaults to true.
* @param {string} target - Sets target="" attribute with value provided to links.
* Default doesn't set target attribute
*/
Xterm.prototype.linkifyTerminalLine = function (line, lenient, target) {
return exports.linkifyTerminalLine(this, line, lenient, target);
};
/**
* Converts all valid URLs found in the current terminal into hyperlinks.
*
* @memberof Xterm
* @param {boolean} lenient - The regex type that will be used to identify links. If lenient is
* false, the regex requires a protocol clause. Defaults to true.
* @param {string} target - Sets target="" attribute with value provided to links.
* Default doesn't set target attribute
*/
Xterm.prototype.linkify = function (lenient, target) {
return exports.linkify(this, lenient, target);
};
return exports;
});
-135
View File
@@ -1,135 +0,0 @@
/**
* This module provides methods for attaching a terminal to a terminado WebSocket stream.
*
* @module xterm/addons/terminado/terminado
* @license MIT
*/
(function (attach) {
if (typeof exports === 'object' && typeof module === 'object') {
/*
* CommonJS environment
*/
module.exports = attach(require('../../xterm'));
} else if (typeof define == 'function') {
/*
* Require.js is available
*/
define(['../../xterm'], attach);
} else {
/*
* Plain browser environment
*/
attach(window.Terminal);
}
})(function (Xterm) {
'use strict';
var exports = {};
/**
* Attaches the given terminal to the given socket.
*
* @param {Xterm} term - The terminal to be attached to the given socket.
* @param {WebSocket} socket - The socket to attach the current terminal.
* @param {boolean} bidirectional - Whether the terminal should send data
* to the socket as well.
* @param {boolean} buffered - Whether the rendering of incoming data
* should happen instantly or at a maximum
* frequency of 1 rendering per 10ms.
*/
exports.terminadoAttach = function (term, socket, bidirectional, buffered) {
bidirectional = (typeof bidirectional == 'undefined') ? true : bidirectional;
term.socket = socket;
term._flushBuffer = function () {
term.write(term._attachSocketBuffer);
term._attachSocketBuffer = null;
clearTimeout(term._attachSocketBufferTimer);
term._attachSocketBufferTimer = null;
};
term._pushToBuffer = function (data) {
if (term._attachSocketBuffer) {
term._attachSocketBuffer += data;
} else {
term._attachSocketBuffer = data;
setTimeout(term._flushBuffer, 10);
}
};
term._getMessage = function (ev) {
var data = JSON.parse(ev.data)
if( data[0] == "stdout" ) {
if (buffered) {
term._pushToBuffer(data[1]);
} else {
term.write(data[1]);
}
}
};
term._sendData = function (data) {
socket.send(JSON.stringify(['stdin', data]));
};
term._setSize = function (size) {
socket.send(JSON.stringify(['set_size', size.rows, size.cols]));
};
socket.addEventListener('message', term._getMessage);
if (bidirectional) {
term.on('data', term._sendData);
}
term.on('resize', term._setSize);
socket.addEventListener('close', term.terminadoDetach.bind(term, socket));
socket.addEventListener('error', term.terminadoDetach.bind(term, socket));
};
/**
* Detaches the given terminal from the given socket
*
* @param {Xterm} term - The terminal to be detached from the given socket.
* @param {WebSocket} socket - The socket from which to detach the current
* terminal.
*/
exports.terminadoDetach = function (term, socket) {
term.off('data', term._sendData);
socket = (typeof socket == 'undefined') ? term.socket : socket;
if (socket) {
socket.removeEventListener('message', term._getMessage);
}
delete term.socket;
};
/**
* Attaches the current terminal to the given socket
*
* @param {WebSocket} socket - The socket to attach the current terminal.
* @param {boolean} bidirectional - Whether the terminal should send data
* to the socket as well.
* @param {boolean} buffered - Whether the rendering of incoming data
* should happen instantly or at a maximum
* frequency of 1 rendering per 10ms.
*/
Xterm.prototype.terminadoAttach = function (socket, bidirectional, buffered) {
return exports.terminadoAttach(this, socket, bidirectional, buffered);
};
/**
* Detaches the current terminal from the given socket.
*
* @param {WebSocket} socket - The socket from which to detach the current
* terminal.
*/
Xterm.prototype.terminadoDetach = function (socket) {
return exports.terminadoDetach(this, socket);
};
return exports;
});
-2261
View File
File diff suppressed because it is too large Load Diff
-5132
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
+2 -4
View File
@@ -23,7 +23,6 @@
width: 100%;
text-align: center;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
font-size: 13px;
line-height: 1.846;
}
@@ -71,9 +70,8 @@
<body>
<div class="content">
<h1>&#128577;</h1>
<h2>Something has gone wrong</h2>
<p>This app is currently not responding. Try refreshing the page.</p>
<h1>&#x231B;</h1>
<p>This app is currently not responding. Please try refreshing the page in a few minutes.</p>
</div>
<footer>
+396
View File
@@ -0,0 +1,396 @@
<!DOCTYPE html>
<html ng-app="Application" ng-controller="FileManagerController">
<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> FileManager </title>
<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.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)">
<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 image/video viewer -->
<div class="modal fade" id="mediaViewerModal" 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>
<!-- Modal remove entry -->
<div class="modal fade" id="entryRemoveModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-body">
<p class="text-bold text-danger" ng-show="entryRemove.error">{{ entryRemove.error }}</p>
<!-- TODO remove fileName later once all translations have been updated -->
<h4 ng-hide="entryRemove.error">{{ 'filemanager.removeDialog.reallyDelete' | tr:{ fileName: selected[0].fileName } }}</h4>
<ul>
<li ng-repeat="entry in selected">{{ 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="entryRemove.submit()" ng-hide="entryRemove.error" ng-disabled="entryRemove.busy"><i class="fa fa-circle-notch fa-spin" ng-show="entryRemove.busy"></i> {{ 'main.dialog.yes' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal new directory -->
<div class="modal fade" id="newDirectoryModal" 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="newDirectoryForm" ng-submit="newDirectory.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': newDirectory.error || (newDirectoryForm.directoryName.$dirty && newDirectoryForm.directoryName.$invalid) }">
<input type="text" class="form-control" id="inputDirectoryName" name="directoryName" ng-model="newDirectory.name" required autofocus>
<div class="control-label" ng-show="newDirectory.error === 'exists'">{{ 'filemanager.newDirectory.errorAlreadyExists' | tr }}</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="newDirectoryForm.$invalid || newDirectory.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="newDirectory.submit()" ng-disabled="newDirectory.busy"><i class="fa fa-circle-notch fa-spin" ng-show="newDirectory.busy"></i> {{ 'filemanager.newDirectoryDialog.create' | 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 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 chown entry -->
<div class="modal fade" id="chownEntryModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<!-- TODO remove fileName later once all translations have been updated -->
<h4 class="modal-title">{{ 'filemanager.chownDialog.title' | tr:{ fileName: selected[0].fileName } }}</h4>
</div>
<div class="modal-body">
<form role="form" name="chownEntryForm" ng-submit="chownEntry.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (chownEntryForm.newOwner.$dirty && chownEntry.error) }">
<label class="control-label">{{ 'filemanager.chownDialog.newOwner' | tr }}</label>
<div class="control-label" for="inputNewOwner" ng-show="chownEntry.error">{{ chownEntry.error }}</div>
<select class="form-control" id="inputNewOwner" name="newOwner" ng-model="chownEntry.newOwner" ng-options="a.value as a.name for a in owners" ng-disabled="chownEntry.busy"></select>
</div>
<div class="form-group" ng-show="chownEntry.showRecursiveOption">
<input type="checkbox" id="inputNewOwnerRecursive" ng-model="chownEntry.recursive">
<label class="control-label" for="inputNewOwnerRecursive">{{ 'filemanager.chownDialog.recursiveCheckbox' | tr }}</label>
</div>
<input class="ng-hide" type="submit" ng-disabled="chownEntryForm.$invalid || chownEntry.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="chownEntry.submit()" ng-hide="chownEntry.error" ng-disabled="chownEntry.busy"><i class="fa fa-circle-notch fa-spin" ng-show="chownEntry.busy"></i> {{ 'filemanager.chownDialog.change' | 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 | prettyByteSize }}</b> (total {{ uploadStatus.size | prettyByteSize }})</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>
<!-- 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 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>
<div class="main-container animateMe ng-hide layout-root" ng-show="initialized">
<div ng-show="view === 'fileTree'" class="layout-content container">
<div class="row" ng-hide="title">
<div class="col-md-12 text-center">
<h3>{{ 'filemanager.notFound' | tr }}</h3>
</div>
</div>
<div class="card card-large" ng-show="title">
<input type="file" id="uploadFileInput" style="display: none" multiple/>
<input type="file" id="uploadFolderInput" style="display: none" multiple webkitdirectory directory/>
<h4 class="text-center" ng-show="type === 'app'"><a ng-href="{{ applicationLink }}" target="_blank">{{ title }}</a></h4>
<h4 class="text-center" ng-hide="type === 'app'">{{ title }}</h4>
<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="busy || cwd === '/'"><i class="fas fa-arrow-left"></i></button><button class="btn btn-primary" ng-disabled="busy" ng-click="refresh()"><i class="fas fa-sync-alt"></i></button>
</div>
<div class="btn-group path-parts" role="group">
<button class="btn btn-default" ng-disabled="busy || 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="busy || 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="newFile.show()">{{ 'filemanager.toolbar.newFile' | tr }}</a></li>
<li><a class="hand" ng-click="newDirectory.show()">{{ '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 class="btn-group" ng-show="type === 'app' || type === 'mail'">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"><i class="fas fa-ellipsis-h"></i></button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a class="hand" ng-show="type === 'app'" ng-click="onRestartApp()"><i class="fas fa-sync-alt fa-fw"></i> {{ 'filemanager.toolbar.restartApp' | tr }}</a></li>
<li><a class="hand" ng-show="type === 'mail'" ng-click="onRestartMail()"><i class="fas fa-sync-alt fa-fw"></i> {{ 'filemanager.toolbar.restartApp' | tr }}</a></li>
<li><a class="hand" ng-href="/logs.html?{{ type === 'app' ? 'appId=' + id : 'id=mail' }}" target="_blank"><i class="fas fa-align-left fa-fw"></i> {{ 'filemanager.toolbar.openLogs' | tr }}</a></li>
<li><a class="hand" ng-show="type === 'app'" ng-href="{{ '/terminal.html?id=' + id }}" target="_blank"><i class="fa fa-terminal fa-fw"></i> {{ 'filemanager.toolbar.openTerminal' | tr }}</a></li>
<li role="separator" class="divider" ng-show="volumes.length"></li>
<li class="disabled" ng-show="volumes.length"><a href="#">Volumes</a></li>
<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>
</div>
</div>
<div class="file-list-header">
<table class="table" style="margin: 0;">
<thead>
<tr>
<th style="width: 40px;">&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="select($event, null)">
<table class="table table-hover" style="margin: 0;">
<tbody>
<tr ng-show="busy">
<td colspan="6"><center><h2><i class="fa fa-circle-notch fa-spin"></i></h2></center></td>
</tr>
<tr ng-show="!busy && entries.length === 0">
<td colspan="" class="text-center">{{ 'filemanager.list.empty' | tr }}</td>
</tr>
<tr ng-hide="busy" ng-repeat="entry in entries | orderBy:sortProperty:sortAsc | orderBy:'isDirectory':true" draggable="true" ng-dragstart="dragStart($event, entry)" ng-drop="drop($event, entry)" context-menu="menuOptions" 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: 40px" ng-mousedown="select($event, entry)" ng-dblclick="open(entry)" class="text-center">
<i class="fas fa-lg {{ entry.icon }}" ng-class="{ 'text-primary': entry.isDirectory && !isSelected(entry) }"></i>
</td>
<td class="elide-table-cell" ng-mousedown="select($event, entry)" ng-dblclick="open(entry)">{{ 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-mousedown="select($event, entry)" ng-dblclick="open(entry)">{{ entry.uid | prettyOwner }}</td>
<td style="width: 80px" class="elide-table-cell" ng-mousedown="select($event, entry)" ng-dblclick="open(entry)">{{ entry.size | prettyByteSize }}</td>
<td style="width:100px" class="elide-table-cell" ng-mousedown="select($event, entry)" ng-dblclick="open(entry)" uib-tooltip="{{ entry.mtime | prettyLongDate }}" tooltip-append-to-body="true">{{ entry.mtime | prettyDate }}</td>
<td style="width: 45px; padding: 7px;">
<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>
</div>
</div>
<div ng-show="view === 'textEditor'" 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>
+133
View File
@@ -0,0 +1,133 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="16"
height="16"
id="svg2"
sodipodi:version="0.32"
inkscape:version="0.91 r13725"
version="1.0"
sodipodi:docname="avatar-default-symbolic.svg"
inkscape:output_extension="org.inkscape.output.svg.inkscape">
<defs
id="defs4" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#e7e7e7"
borderopacity="1"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="11.964497"
inkscape:cx="6.5536056"
inkscape:cy="-0.025360958"
inkscape:document-units="px"
inkscape:current-layer="layer1"
showgrid="true"
inkscape:showpageshadow="false"
showguides="true"
inkscape:guide-bbox="true"
inkscape:window-width="1920"
inkscape:window-height="1030"
inkscape:window-x="0"
inkscape:window-y="25"
inkscape:window-maximized="1"
inkscape:snap-global="true">
<sodipodi:guide
orientation="1,0"
position="0,112"
id="guide2383" />
<sodipodi:guide
orientation="0,1"
position="78.156291,0"
id="guide2389" />
<inkscape:grid
type="xygrid"
id="grid3672"
visible="true"
enabled="true" />
<sodipodi:guide
orientation="1,0"
position="22.008699,4.1542523"
id="guide2950" />
<sodipodi:guide
orientation="0,1"
position="11.22532,22.008699"
id="guide2952" />
</sodipodi:namedview>
<metadata
id="metadata7">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Calque 1"
inkscape:groupmode="layer"
id="layer1">
<path
inkscape:connector-curvature="0"
sodipodi:nodetypes="ccccsccccc"
style="fill:#000000;fill-opacity:1;stroke:none"
id="path3935"
d="m -13.771529,5.9050966 c 0.181174,0.8569201 0.2823,1.5051186 0.135325,2.3620387 -1.145861,0.9506717 -4.076448,1.3778558 -4.072056,2.3620387 l -0.393673,2.558875 c 0,0.978388 2.731928,1.771529 6.101933,1.771529 3.370005,0 6.101933,-0.793141 6.101933,-1.771529 L -6.29174,10.629174 c -0.0047,-0.8423279 -2.952548,-1.377856 -4.084358,-2.3620387 -0.09668,-0.7953524 -0.01972,-1.5666863 0.147627,-2.3620387 l -3.543058,0 z" />
<path
transform="matrix(0.34209356,0,0,0.34209356,-8.638748,-12.26548)"
d="m -9.75,73.09375 c -3.766412,0.121068 -7.468069,1.386362 -11.40625,3.25 a 1.25331,1.25331 0 0 0 -0.6875,1.4375 l 0.625,2.53125 a 1.25331,1.25331 0 0 0 0.78125,0.84375 c 0.161757,0.06256 0.275429,0.183794 0.71875,0.3125 2.335298,0.677989 5.907957,1.15625 9.90625,1.15625 3.9982931,0 7.5709518,-0.478261 9.90625,-1.15625 0.44332111,-0.128707 0.55699247,-0.24994 0.71875,-0.3125 a 1.25331,1.25331 0 0 0 0.78125,-0.8125 L 2.25,78.03125 a 1.25331,1.25331 0 0 0 -0.53125,-1.375 C -2.2051532,74.042333 -5.9835879,72.972682 -9.75,73.09375 z"
id="path3937"
style="fill:#000000;fill-opacity:1;stroke:none"
inkscape:original="M -9.71875 74.34375 C -13.230599 74.456635 -16.76467 75.641953 -20.625 77.46875 L -20 80 C -19.731211 80.103955 -19.729288 80.147142 -19.375 80.25 C -17.218663 80.876033 -13.703662 81.375 -9.8125 81.375 C -5.9213382 81.375 -2.4063369 80.876033 -0.25 80.25 C 0.10428761 80.147142 0.10621054 80.103955 0.375 80 L 1.03125 77.6875 C -2.7172738 75.190412 -6.2069011 74.230865 -9.71875 74.34375 z "
inkscape:radius="1.2531847"
sodipodi:type="inkscape:offset" />
<rect
transform="matrix(0.9205234,-0.39068744,0.39068744,0.9205234,0,0)"
ry="1.1810193"
rx="1.1810193"
y="-2.754653"
x="-15.569602"
height="2.1871843"
width="1.0935922"
id="rect3939"
style="fill:#000000;fill-opacity:1;stroke:none" />
<rect
style="fill:#000000;fill-opacity:1;stroke:none"
id="rect3941"
width="1.0935922"
height="2.1871843"
x="6.5567312"
y="6.6361833"
rx="1.1810193"
ry="1.1810193"
transform="matrix(-0.9205234,-0.39068744,-0.39068744,0.9205234,0,0)" />
<path
inkscape:connector-curvature="0"
sodipodi:nodetypes="cccccccc"
style="fill:#000000;fill-opacity:1;stroke:none"
id="path3943"
d="m -12,0 c -1.630647,0 -2.952548,1.2337743 -2.952548,2.7557118 0.01278,0.5632387 0.06085,1.232346 0.393673,2.7557117 0.196837,0.5905097 1.558851,2.1652021 1.574692,2.3620387 0.381733,0.1968365 1.771529,0.1968365 2.165203,0 0,-0.1968366 1.181019,-1.771529 1.377855,-2.3620387 C -9.066594,3.9281919 -9.06754,3.3462214 -9.047452,2.7557118 -9.047452,1.2337743 -10.369352,0 -12,0 z" />
<path
id="path3157"
d="m 38,0 c -1.630647,0 -2.9375,1.2280625 -2.9375,2.75 0.0037,0.1620664 0.01579,0.3963239 0.03125,0.59375 -0.27885,0.118349 -0.299198,0.6610508 -0.0625,1.21875 0.09386,0.2211566 0.213411,0.3909677 0.34375,0.53125 0.03167,0.1567366 0.02336,0.2271022 0.0625,0.40625 0.196837,0.5905097 1.577909,2.1781634 1.59375,2.375 0.381733,0.1968365 1.762576,0.1968365 2.15625,0 0,-0.1968366 1.178164,-1.7844903 1.375,-2.375 C 40.60622,5.3151913 40.62213,5.1903792 40.65625,5.03125 40.764832,4.8997227 40.857512,4.7509639 40.9375,4.5625 41.162363,4.0326858 41.147829,3.5269131 40.90625,3.375 40.920493,3.1615298 40.931227,2.9343906 40.9375,2.75 40.9375,1.2280625 39.630648,0 38,0 z m -1.78125,8.40625 c -1.233461,0.8706787 -3.941711,1.2750309 -3.9375,2.21875 l -0.375,2.5625 c 0,0.519013 0.775005,0.988493 2,1.3125 l 0.1875,0.71875 A 0.42874928,0.42874928 0 0 0 34.375,15.5 c 0.05534,0.0214 0.09834,0.04972 0.25,0.09375 C 35.42389,15.825686 36.63221,16 38,16 39.36779,16 40.60736,15.825686 41.40625,15.59375 41.557907,15.54972 41.569664,15.5214 41.625,15.5 a 0.42874928,0.42874928 0 0 0 0.28125,-0.28125 L 42.125,14.5 c 1.208619,-0.323691 1.96875,-0.797472 1.96875,-1.3125 l -0.375,-2.5625 C 43.714419,9.848863 41.21753,9.3437322 39.9375,8.5 A 0.97584188,0.97584188 0 0 1 39.625,8.75 C 39.020006,9.0524961 38.608286,9 38.09375,9 37.836482,9 37.587947,9.0004922 37.34375,8.96875 37.099553,8.937008 36.902156,8.909026 36.59375,8.75 a 0.97584188,0.97584188 0 0 1 -0.375,-0.34375 z"
style="fill:#bebebe;fill-opacity:1;stroke:none"
inkscape:connector-curvature="0" />
<path
style="fill:#bebebe;fill-opacity:1;stroke:none"
d="m 8,0.7783785 c -2.256463,0 -4.0648649,1.699373 -4.0648649,3.8054054 0.00509,0.2242649 0.021845,0.5484266 0.043244,0.8216216 -0.3858679,0.1637694 -0.4140257,0.9147514 -0.086487,1.6864865 0.1298861,0.3060329 0.295314,0.5410147 0.4756757,0.7351351 0.043823,0.2168896 0.032325,0.3142604 0.086486,0.5621622 0.1511376,0.4534118 0.7470076,1.3420395 1.2972972,2.0756757 0.05396,0.563421 0.109936,1.132004 0,1.772973 -1.5856236,1.315524 -5.67094212,1.881347 -5.66486451,3.243243 L 0,16 16,16 15.91351,15.481081 c -0.0065,-1.1656 -4.098682,-1.881347 -5.664862,-3.243243 -0.06337,-0.521335 -0.07545,-1.043272 -0.04324,-1.556757 0.501434,-0.7738141 1.172201,-1.7868737 1.34054,-2.2918917 0.0605,-0.2557354 0.08252,-0.4284482 0.129729,-0.6486487 0.150255,-0.1820053 0.278504,-0.3878554 0.38919,-0.6486486 0.311162,-0.7331483 0.291049,-1.4330284 -0.04324,-1.6432432 0.01971,-0.2953966 0.03456,-0.6097082 0.04324,-0.8648649 0,-2.1060324 -1.8084,-3.8054054 -4.064865,-3.8054054 z"
id="path3159"
inkscape:connector-curvature="0" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 7.8 KiB

+87 -44
View File
@@ -5,7 +5,7 @@
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<!-- this gets changed once we get the config (because angular has not loaded yet, we see template string for a flash) -->
<title> Cloudron </title>
<title>&lrm;</title>
<link id="favicon" type="image/png" rel="icon" href="/api/v1/cloudron/avatar">
<link rel="apple-touch-icon" href="/api/v1/cloudron/avatar">
@@ -17,12 +17,15 @@
<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 %>">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
<!-- toBlob() polyfill-->
<script type="text/javascript" src="/3rdparty/js/canvas-to-blob.min.js?<%= revision %>"></script>
@@ -36,6 +39,7 @@
<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-route.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>
@@ -52,13 +56,21 @@
<!-- 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>
<script type="text/javascript" src="/3rdparty/js/Chart.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>
<!-- Chart.js https://www.chartjs.org/ -->
<link type="text/css" rel="stylesheet" href="/3rdparty/Chart/Chart.min.css?<%= revision %>"/>
<script type="text/javascript" src="/3rdparty/Chart/Chart.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/ansi_up.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.6.4.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/showdown-target-blank.min.js?<%= revision %>"></script>
<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>
@@ -93,7 +105,26 @@
</div>
</script>
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://cloudron.io/documentation/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<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>
<!-- Modal reboot server -->
<div class="modal fade" id="rebootModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'main.rebootDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<p class="text-bold">{{ 'main.rebootDialog.warning' | tr }}</p>
<p>{{ 'main.rebootDialog.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="reboot.submit()" ng-disabled="reboot.busy"><i class="fa fa-circle-notch fa-spin" ng-show="reboot.busy"></i> {{ 'main.rebootDialog.rebootAction' | tr }}</button>
</div>
</div>
</div>
</div>
<div class="animateMe ng-hide layout-root" ng-show="initialized">
@@ -114,42 +145,55 @@
<div class="collapse navbar-collapse">
<ul class="nav navbar-nav navbar-right" ng-hide="hideNavBarActions">
<li ng-show="user.isAtLeastOwner && (subscription.plan.id === 'free' || subscription.plan.id === 'expired')">
<a ng-click="openSubscriptionSetup()" style="cursor: pointer">
<span class="badge badge-success">{{ subscription.plan.id === 'free' ? 'Setup' : 'Reactivate' }} Subscription</span>
</a>
</li>
<li>
<a ng-class="{ active: isActive('/apps')}" href="#/apps"><i class="fa fa-cloud-download-alt fa-fw"></i> My Apps</a>
</li>
<li ng-show="user.isAtLeastAdmin">
<a ng-class="{ active: isActive('/appstore')}" href="#/appstore"><i class="fa fa-th fa-fw"></i> App Store</a>
</li>
<li ng-show="user.isAtLeastUserManager">
<a ng-class="{ active: isActive('/users')}" href="#/users"><i class="fa fa-users fa-fw"></i> Users</a>
</li>
<li class="dropdown">
<a href="" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false"><img ng-src="{{user.avatarUrl}}" style="width: 24px; height: 24px;"/> {{user.username}} <span class="badge badge-danger" ng-show="notifications.length">{{ notifications.length }}</span> <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<li><a href="#/profile"><i class="fa fa-user fa-fw"></i> Profile</a></li>
<li ng-show="user.isAtLeastAdmin" class="divider"></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/backups"><i class="fa fa-archive fa-fw"></i> Backups</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/domains"><i class="fa fa-globe fa-fw"></i> Domains & Certs</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/email"><i class="fa fa-envelope fa-fw"></i> Email</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/activity"><i class="fa fa-list-alt fa-fw"></i> Event Log</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/graphs"><i class="fa fa-chart-bar fa-fw"></i> Graphs</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/network"><i class="fas fa-network-wired fa-fw"></i> Network</a></li>
<li><a href="#/notifications"><i class="fa fa-bell fa-fw"></i> Notifications <span class="badge badge-danger" ng-show="notifications.length">{{ notifications.length }}</span></a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/settings"><i class="fa fa-wrench fa-fw"></i> Settings</a></li>
<li ng-show="user.isAtLeastAdmin" class="divider"></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/support"><i class="fa fa-comment fa-fw"></i> Support</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/system"><i class="fa fa-cogs fa-fw"></i> System Info</a></li>
<li class="divider"></li>
<li><a href="" ng-click="logout($event)"><i class="fa fa-sign-out-alt fa-fw"></i> Logout</a></li>
</ul>
</li>
</ul>
<li ng-show="user.isAtLeastOwner && (subscription.plan.id === 'free' || subscription.plan.id === 'expired')">
<a ng-click="openSubscriptionSetup()" style="cursor: pointer">
<span class="badge badge-success" ng-show="subscription.plan.id === 'free'">Set up Subscription</span>
<span class="badge badge-danger" ng-show="subscription.plan.id !== 'free'">Reactivate Subscription</span>
</a>
</li>
<li ng-show="!user.isAtLeastOwner && subscription.plan.id === 'expired'">
<a>
<span class="badge badge-danger">Subscription Expired</span>
</a>
</li>
<li>
<a ng-class="{ active: isActive('/apps')}" href="#/apps"><i class="fa fa-th 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>
</li>
<li ng-show="user.isAtLeastUserManager">
<a ng-class="{ active: isActive('/users')}" href="#/users"><i class="fa fa-users fa-fw"></i> {{ 'main.navbar.users' | tr }}</a>
</li>
<li ng-show="user.isAtLeastAdmin">
<a href="#/notifications">
<i class="fas fa-bell" ng-show="notificationCount"></i>
<i class="far fa-bell" ng-hide="notificationCount"></i>
<span class="badge badge-danger" ng-show="notificationCount">{{ notificationCount }}</span>
</a>
</li>
<li class="dropdown">
<a href="" class="dropdown-toggle" data-toggle="dropdown" role="button" aria-expanded="false"><img ng-src="{{user.avatarUrl}}" style="width: 24px; height: 24px;"/> {{user.username}} <span class="caret"></span></a>
<ul class="dropdown-menu" role="menu">
<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="#/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="#/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.isAtLeastAdmin"><a href="#/support"><i class="fa fa-comment fa-fw"></i> {{ 'support.title' | tr }}</a></li>
<li ng-show="user.isAtLeastAdmin"><a href="#/system"><i class="fa fa-chart-area fa-fw"></i> {{ 'system.title' | tr }}</a></li>
<li class="divider"></li>
<li><a href="" ng-click="logout($event)"><i class="fa fa-sign-out-alt fa-fw"></i> {{ 'main.logout' | tr }}</a></li>
</ul>
</li>
</ul>
</div>
</div>
</nav>
@@ -158,7 +202,6 @@
<footer class="text-center ng-cloak">
<span class="text-muted" ng-bind-html="config.footer | markdown2html"></span>
<span class="version">v{{config.version}}</span>
</footer>
</div>
+1550 -257
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+79 -173
View File
@@ -1,7 +1,6 @@
'use strict';
/* global angular:false */
/* global showdown:false */
/* global moment:false */
/* global $:false */
/* global ERROR,ISTATES,HSTATES,RSTATES */
@@ -19,67 +18,8 @@ if (search.accessToken) {
window.location.search = encodeURIComponent(Object.keys(search).map(function (key) { return key + '=' + search[key]; }).join('&'));
}
// poor man's async in the global namespace
function asyncForEachParallel(items, handler, callback) {
var alreadyDone = 0;
var errored = false;
if (items.length === 0) return callback();
function done(error) {
// do nothing if already called back due to error
if (errored) return;
if (error) {
errored = true;
return callback(error);
}
++alreadyDone;
// we are done
if (alreadyDone === items.length) callback();
}
for (var i = 0; i < items.length; ++i) {
handler(items[i], done);
}
}
function asyncForEach(items, handler, callback) {
var cur = 0;
if (items.length === 0) return callback();
(function iterator() {
handler(items[cur], function (error) {
if (error) return callback(error);
if (cur >= items.length-1) return callback();
++cur;
iterator();
});
})();
}
function asyncSeries(funcs, callback) {
var cur = 0;
if (funcs.length === 0) return callback();
(function iterator() {
funcs[cur](function (error) {
if (error) return callback(error);
if (cur >= funcs.length-1) return callback();
++cur;
iterator();
});
})();
}
// create main application module
var app = angular.module('Application', ['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.bootstrap-slider', 'ngTld', 'ui.multiselect']);
app.config(['NotificationProvider', function (NotificationProvider) {
NotificationProvider.setOptions({
@@ -129,9 +69,9 @@ app.config(['$routeProvider', function ($routeProvider) {
}).when('/backups', {
controller: 'BackupsController',
templateUrl: 'views/backups.html?<%= revision %>'
}).when('/graphs', {
controller: 'GraphsController',
templateUrl: 'views/graphs.html?<%= revision %>'
}).when('/branding', {
controller: 'BrandingController',
templateUrl: 'views/branding.html?<%= revision %>'
}).when('/network', {
controller: 'NetworkController',
templateUrl: 'views/network.html?<%= revision %>'
@@ -141,7 +81,10 @@ app.config(['$routeProvider', function ($routeProvider) {
}).when('/email', {
controller: 'EmailsController',
templateUrl: 'views/emails.html?<%= revision %>'
}).when('/email/:domain', {
}).when('/emails-eventlog', {
controller: 'EmailsEventlogController',
templateUrl: 'views/emails-eventlog.html?<%= revision %>'
}).when('/email/:domain/:view?', {
controller: 'EmailController',
templateUrl: 'views/email.html?<%= revision %>'
}).when('/notifications', {
@@ -150,15 +93,21 @@ app.config(['$routeProvider', function ($routeProvider) {
}).when('/settings', {
controller: 'SettingsController',
templateUrl: 'views/settings.html?<%= revision %>'
}).when('/activity', {
controller: 'ActivityController',
templateUrl: 'views/activity.html?<%= revision %>'
}).when('/eventlog', {
controller: 'EventLogController',
templateUrl: 'views/eventlog.html?<%= revision %>'
}).when('/support', {
controller: 'SupportController',
templateUrl: 'views/support.html?<%= revision %>'
}).when('/system', {
controller: 'SystemController',
templateUrl: 'views/system.html?<%= revision %>'
}).when('/services', {
controller: 'ServicesController',
templateUrl: 'views/services.html?<%= revision %>'
}).when('/volumes', {
controller: 'VolumesController',
templateUrl: 'views/volumes.html?<%= revision %>'
}).otherwise({ redirectTo: '/'});
}]);
@@ -236,6 +185,35 @@ app.filter('appProgressMessage', function () {
};
});
// see apps.js $scope.states
app.filter('selectedStateFilter', function () {
return function selectedStateFilter(apps, selectedState) {
return apps.filter(function (app) {
if (!selectedState || !selectedState.state) return true;
if (selectedState.state === 'running') return app.runState === 'running' && app.health === 'healthy' && app.installationState === 'installed';
if (selectedState.state === 'stopped') return app.runState === 'stopped';
return app.runState === 'running' && (app.health !== 'healthy' || app.installationState !== 'installed'); // not responding
});
};
});
app.filter('selectedGroupAccessFilter', function () {
return function selectedGroupAccessFilter(apps, group) {
return apps.filter(function (app) {
if (!group.id) return true; // case for no filter entry
if (!app.accessRestriction) return true;
if (!app.accessRestriction.groups) return false;
if (app.accessRestriction.groups.indexOf(group.id) !== -1) return true;
return false;
});
};
});
app.filter('selectedTagFilter', function () {
return function selectedTagFilter(apps, selectedTags) {
return apps.filter(function (app) {
@@ -258,7 +236,10 @@ app.filter('selectedDomainFilter', function () {
if (selectedDomain._alldomains) return true; // magic domain for single select, see apps.js ALL_DOMAINS_DOMAIN
if (selectedDomain.domain === app.domain) return true;
return !!app.alternateDomains.find(function (ad) { return ad.domain === selectedDomain.domain; });
if (app.aliasDomains.find(function (ad) { return ad.domain === selectedDomain.domain; })) return true;
if (app.redirectDomains.find(function (ad) { return ad.domain === selectedDomain.domain; })) return true;
return false;
});
};
});
@@ -267,7 +248,11 @@ app.filter('appSearchFilter', function () {
return function appSearchFilter(apps, appSearch) {
return apps.filter(function (app) {
if (!appSearch) return true;
return app.fqdn.indexOf(appSearch) !== -1 || (app.label && app.label.indexOf(appSearch) !== -1);
appSearch = appSearch.toLowerCase();
return app.fqdn.indexOf(appSearch) !== -1
|| (app.label && app.label.toLowerCase().indexOf(appSearch) !== -1)
|| (app.manifest.title && app.manifest.title.toLowerCase().indexOf(appSearch) !== -1)
|| (appSearch.length >=6 && app.id.indexOf(appSearch) !== -1);
});
};
});
@@ -278,27 +263,6 @@ app.filter('prettyDomains', function () {
};
});
app.filter('prettyMemory', function () {
return function (memory) {
// Adjust the default memory limit if it changes
return memory ? Math.floor(memory / 1024 / 1024) : 256;
};
});
app.filter('prettyMailSize', function () {
return function (size) {
if (!size) return '0 kB';
var i = Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; };
});
app.filter('prettyDiskSize', function () {
return function (size) {
if (!size) return 'Not available yet';
var i = Math.floor(Math.log(size) / Math.log(1024));
return (size / Math.pow(1024, i)).toFixed(2) * 1 + ' ' + ['B', 'kB', 'MB', 'GB', 'TB'][i]; };
});
app.filter('installationActive', function () {
return function (app) {
if (app.installationState === ISTATES.ERROR) return false;
@@ -309,7 +273,7 @@ app.filter('installationActive', function () {
// this appears in the app grid
app.filter('installationStateLabel', function () {
return function(app, user) {
return function(app) {
if (!app) return '';
var waiting = app.progress === 0 ? ' (Queued)' : '';
@@ -330,6 +294,7 @@ app.filter('installationStateLabel', function () {
return 'Migrating data' + waiting;
case ISTATES.PENDING_UNINSTALL: return 'Uninstalling' + waiting;
case ISTATES.PENDING_RESTORE: return 'Restoring' + waiting;
case ISTATES.PENDING_IMPORT: return 'Importing' + waiting;
case ISTATES.PENDING_UPDATE: return 'Updating' + waiting;
case ISTATES.PENDING_BACKUP: return 'Backing up' + waiting;
case ISTATES.PENDING_START: return 'Starting' + waiting;
@@ -367,6 +332,7 @@ app.filter('taskName', function () {
case ISTATES.PENDING_DATA_DIR_MIGRATION: return 'data migration';
case ISTATES.PENDING_UNINSTALL: return 'uninstall';
case ISTATES.PENDING_RESTORE: return 'restore';
case ISTATES.PENDING_IMPORT: return 'import';
case ISTATES.PENDING_UPDATE: return 'update';
case ISTATES.PENDING_BACKUP: return 'backup';
case ISTATES.PENDING_START: return 'start app';
@@ -422,82 +388,15 @@ app.filter('prettyHref', function () {
};
});
app.filter('prettyDate', function () {
// http://ejohn.org/files/pretty.js
return function prettyDate(utc) {
var date = new Date(utc), // this converts utc into browser timezone and not cloudron timezone!
diff = (((new Date()).getTime() - date.getTime()) / 1000) + 30, // add 30seconds for clock skew
day_diff = Math.floor(diff / 86400);
if (isNaN(day_diff) || day_diff < 0)
return 'just now';
return day_diff === 0 && (
diff < 60 && 'just now' ||
diff < 120 && '1 minute ago' ||
diff < 3600 && Math.floor( diff / 60 ) + ' minutes ago' ||
diff < 7200 && '1 hour ago' ||
diff < 86400 && Math.floor( diff / 3600 ) + ' hours ago') ||
day_diff === 1 && 'Yesterday' ||
day_diff < 7 && day_diff + ' days ago' ||
day_diff < 31 && Math.ceil( day_diff / 7 ) + ' weeks ago' ||
day_diff < 365 && Math.round( day_diff / 30 ) + ' months ago' ||
Math.round( day_diff / 365 ) + ' years ago';
};
});
app.filter('prettyLongDate', function () {
return function prettyLongDate(utc) {
return moment(utc).format('MMMM Do YYYY, h:mm:ss a'); // this converts utc into browser timezone and not cloudron timezone!
};
});
app.filter('prettyShortDate', function () {
return function prettyShortDate(utc) {
return moment(utc).format('MMMM Do YYYY'); // this converts utc into browser timezone and not cloudron timezone!
};
});
app.filter('prettyEmailAddresses', function () {
return function prettyEmailAddresses(addresses) {
if (!addresses || addresses === '<>') return '<>';
if (!addresses) return '';
if (addresses === '<>') return '<>';
if (Array.isArray(addresses)) return addresses.map(function (a) { return a.slice(1, -1); }).join(', ');
return addresses.slice(1, -1);
};
});
app.filter('markdown2html', function () {
var converter = new showdown.Converter({
extensions: [ 'targetblank' ],
simplifiedAutoLink: true,
strikethrough: true,
tables: true
});
return function (text) {
return converter.makeHtml(text);
};
});
app.filter('postInstallMessage', function () {
var SSO_MARKER = '=== sso ===';
return function (text, app) {
if (!text) return '';
if (!app) return text;
var parts = text.split(SSO_MARKER);
if (parts.length === 1) {
// [^] matches even newlines. '?' makes it non-greedy
if (app.sso) return text.replace(/\<nosso\>[^]*?\<\/nosso\>/g, '');
else return text.replace(/\<sso\>[^]*?\<\/sso\>/g, '');
}
if (app.sso) return parts[1];
else return parts[0];
};
});
// custom directive for dynamic names in forms
// See http://stackoverflow.com/questions/23616578/issue-registering-form-control-with-interpolated-name#answer-23617401
app.directive('laterName', function () { // (2)
@@ -578,7 +477,8 @@ app.directive('tagInput', function () {
scope: {
inputTags: '=taglist'
},
link: function ($scope, element, attrs) {
require: '^form',
link: function ($scope, element, attrs, formCtrl) {
$scope.defaultWidth = 200;
$scope.tagText = ''; // current tag being edited
$scope.placeholder = attrs.placeholder;
@@ -586,18 +486,20 @@ app.directive('tagInput', function () {
if ($scope.inputTags === undefined) {
return [];
}
return $scope.inputTags.split(',').filter(function (tag) {
return $scope.inputTags.split(' ').filter(function (tag) {
return tag !== '';
});
};
$scope.addTag = function () {
var tagArray;
if ($scope.tagText.length === 0) {
return;
var tagArray = $scope.tagArray();
// prevent adding empty or existing items
if ($scope.tagText.length === 0 || tagArray.indexOf($scope.tagText) !== -1) {
return $scope.tagText = '';
}
tagArray = $scope.tagArray();
tagArray.push($scope.tagText);
$scope.inputTags = tagArray.join(',');
$scope.inputTags = tagArray.join(' ');
return $scope.tagText = '';
};
$scope.deleteTag = function (key) {
@@ -610,7 +512,8 @@ app.directive('tagInput', function () {
tagArray.splice(key, 1);
}
}
return $scope.inputTags = tagArray.join(',');
formCtrl.$setDirty();
return $scope.inputTags = tagArray.join(' ');
};
$scope.$watch('tagText', function (newVal, oldVal) {
var tempEl;
@@ -623,6 +526,9 @@ app.directive('tagInput', function () {
return tempEl.remove();
}
});
element.bind('click', function () {
element[0].firstChild.lastChild.focus();
});
element.bind('keydown', function (e) {
var key = e.which;
if (key === 9 || key === 13) {
@@ -634,7 +540,7 @@ app.directive('tagInput', function () {
});
element.bind('keyup', function (e) {
var key = e.which;
if (key === 9 || key === 13 || key === 32 || key === 188) {
if (key === 9 || key === 13 || key === 32) {
e.preventDefault();
return $scope.$apply('addTag()');
}
@@ -642,9 +548,9 @@ app.directive('tagInput', function () {
},
template:
'<div class="tag-input-container">' +
'<div class="input-tag" data-ng-repeat="tag in tagArray()">' +
'{{tag}}' +
'<div class="delete-tag" data-ng-click="deleteTag($index)">&times;</div>' +
'<div class="btn-group input-tag" data-ng-repeat="tag in tagArray()">' +
'<button type="button" class="btn btn-xs btn-primary" disabled>{{ tag }}</button>' +
'<button type="button" class="btn btn-xs btn-primary" data-ng-click="deleteTag($index)">&times;</button>' +
'</div>' +
'<input type="text" data-ng-model="tagText" ng-blur="addTag()" placeholder="{{placeholder}}"/>' +
'</div>'
+66 -17
View File
@@ -3,18 +3,17 @@
/* global angular, $, showdown */
// create main application module
var app = angular.module('Application', []);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies']);
app.filter('markdown2html', function () {
var converter = new showdown.Converter({
extensions: [ 'targetblank' ],
simplifiedAutoLink: true,
strikethrough: true,
tables: true
tables: true,
openLinksInNewWindow: true
});
return function (text) {
console.log(text)
return converter.makeHtml(text);
};
});
@@ -24,7 +23,41 @@ app.config(function ($sceProvider) {
$sceProvider.enabled(false);
});
app.controller('LoginController', ['$scope', '$http', function ($scope, $http) {
app.config(['$translateProvider', function ($translateProvider) {
$translateProvider.useStaticFilesLoader({
prefix: 'translation/',
suffix: '.json'
});
$translateProvider.preferredLanguage('en');
$translateProvider.fallbackLanguage('en');
}]);
// Add shorthand "tr" filter to avoid having ot use "translate"
// This is a copy of the code at https://github.com/angular-translate/angular-translate/blob/master/src/filter/translate.js
// If we find out how to get that function handle somehow dynamically we can use that, otherwise the copy is required
function translateFilterFactory($parse, $translate) {
var translateFilter = function (translationId, interpolateParams, interpolation, forceLanguage) {
if (!angular.isObject(interpolateParams)) {
var ctx = this || {
'__SCOPE_IS_NOT_AVAILABLE': 'More info at https://github.com/angular/angular.js/commit/8863b9d04c722b278fa93c5d66ad1e578ad6eb1f'
};
interpolateParams = $parse(interpolateParams)(ctx);
}
return $translate.instant(translationId, interpolateParams, interpolation, forceLanguage);
};
if ($translate.statefulFilter()) {
translateFilter.$stateful = true;
}
return translateFilter;
}
translateFilterFactory.displayName = 'translateFilterFactory';
app.filter('tr', translateFilterFactory);
app.controller('LoginController', ['$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; }, {});
@@ -64,7 +97,12 @@ app.controller('LoginController', ['$scope', '$http', function ($scope, $http) {
if (status !== 200) return error();
localStorage.token = data.accessToken;
window.location.href = search.returnTo || '/';
// prevent redirecting to random domains
var returnTo = search.returnTo || '/';
if (returnTo.indexOf('/') !== 0) returnTo = '/';
window.location.href = returnTo;
}).error(error);
};
@@ -88,39 +126,40 @@ app.controller('LoginController', ['$scope', '$http', function ($scope, $http) {
var data = {
resetToken: search.resetToken,
password: $scope.newPassword
password: $scope.newPassword,
totpToken: $scope.totpToken
};
function error(status) {
console.log('error', status)
function error(data, status) {
console.log('error', status);
$scope.busy = false;
if (status === 401) $scope.error = 'Invalid reset token';
if (status === 401) $scope.error = data.message;
else if (status === 409) $scope.error = 'Ask your admin for an invite link first';
else $scope.error = 'Unknown error';
}
$http.post(API_ORIGIN + '/api/v1/cloudron/password_reset', data).success(function (data, status) {
if (status !== 202) return error(status);
if (status !== 202) return error(data, status);
// set token to autologin
localStorage.token = data.accessToken;
$scope.mode = 'newPasswordDone';
}).error(function (data, status) {
error(status);
error(data, status);
});
};
$scope.showPasswordReset = function () {
window.document.title = 'Password Reset';
window.document.title = 'Password Reset Request';
$scope.mode = 'passwordReset';
$scope.passwordResetIdentifier = '';
setTimeout(function () { $('#inputPasswordResetIdentifier').focus(); }, 200);
};
$scope.showLogin = function () {
window.document.title = 'Cloudron Login';
if ($scope.status) window.document.title = $scope.status.cloudronName + ' Login';
$scope.mode = 'login';
$scope.error = false;
setTimeout(function () { $('#inputUsername').focus(); }, 200);
@@ -137,13 +176,23 @@ app.controller('LoginController', ['$scope', '$http', function ($scope, $http) {
if (status !== 200) return;
if (data.language) $translate.use(data.language);
if ($scope.mode === 'login') window.document.title = data.cloudronName + ' Login';
$scope.status = data;
}).error(function () {
$scope.initialized = false;
});
// Init into the correct view
if (search.passwordReset) $scope.showPasswordReset();
else if (search.resetToken) $scope.showNewPassword();
else $scope.showLogin();
if (search.passwordReset) {
$scope.showPasswordReset();
} else if (search.resetToken) {
$scope.showNewPassword();
} else if (search.accessToken || search.access_token) { // auto-login feature
localStorage.token = search.accessToken || search.access_token;
window.location.href = '/';
} else {
$scope.showLogin();
}
}]);
+17 -3
View File
@@ -5,9 +5,9 @@
/* global $ */
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification']);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification']);
app.controller('LogsController', ['$scope', 'Client', function ($scope, Client) {
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;
@@ -83,7 +83,15 @@ app.controller('LogsController', ['$scope', 'Client', function ($scope, Client)
}
function select(ids, callback) {
if (ids.id) {
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') },
@@ -92,8 +100,10 @@ app.controller('LogsController', ['$scope', 'Client', function ($scope, Client)
{ 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; });
@@ -185,4 +195,8 @@ app.controller('LogsController', ['$scope', 'Client', function ($scope, Client)
}
init();
$translate([ 'logs.title' ]).then(function (tr) {
if (tr['logs.title'] !== 'logs.title') window.document.title = tr['logs.title'];
});
}]);
+54 -27
View File
@@ -3,16 +3,38 @@
/* global angular */
/* global $ */
angular.module('Application').controller('MainController', ['$scope', '$route', '$timeout', '$location', 'Client', function ($scope, $route, $timeout, $location, Client) {
angular.module('Application').controller('MainController', ['$scope', '$route', '$timeout', '$location', '$interval', 'Client', function ($scope, $route, $timeout, $location, $interval, 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.notifications = [];
$scope.notificationCount = 0;
$scope.hideNavBarActions = $location.path() === '/logs';
$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;
@@ -30,29 +52,28 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
// NOTE: this function is exported and called from the appstore.js
$scope.updateSubscriptionStatus = function () {
if (!Client.getUserInfo().isAtLeastAdmin) return;
Client.getSubscription(function (error, subscription) {
if (error && error.statusCode === 412) return; // ignore if not yet registered
if (error && error.statusCode === 402) return; // ignore if not yet registered
if (error) return console.error(error);
$scope.subscription = subscription;
});
};
function refreshNotifications(poll) {
Client.getNotifications(false, 1, 100, function (error, results) {
if (error) console.error(error);
else $scope.notifications = results;
function refreshNotifications() {
if (!Client.getUserInfo().isAtLeastAdmin) return;
if (poll) $timeout(refreshNotifications, 60 * 1000);
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 (notificationId) {
// remove notification from list
$scope.notifications = $scope.notifications.filter(function (n) { return n.id !== notificationId; });
$scope.notificationAcknowledged = function () {
if ($scope.notificationCount === 0) return; // already down to 0
$scope.notificationCount--;
};
function init() {
@@ -68,17 +89,17 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
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.href = '/restore.html' + window.location.search;
} else {
window.location.href = status.adminFqdn ? '/setup.html' : '/setupdns.html';
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') {
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.href = '/setupdns.html' + window.location.search;
return;
}
@@ -99,19 +120,29 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
Client.refreshConfig(function (error) {
if (error) return Client.initError(error, init);
Client.refreshInstalledApps(function (error) {
Client.refreshAvailableLanguages(function (error) {
if (error) return Client.initError(error, init);
// now mark the Client to be ready
Client.setReady();
Client.refreshInstalledApps(function (error) {
if (error) return Client.initError(error, init);
$scope.config = Client.getConfig();
// now mark the Client to be ready
Client.setReady();
$scope.initialized = true;
$scope.config = Client.getConfig();
refreshNotifications(true);
$scope.initialized = true;
$scope.updateSubscriptionStatus();
if (Client.getConfig().mandatory2FA && !Client.getUserInfo().twoFactorAuthenticationEnabled) {
$location.path('/profile').search({ setup2fa: true });
return;
}
$interval(refreshNotifications, 60 * 1000);
refreshNotifications();
$scope.updateSubscriptionStatus();
});
});
});
});
@@ -124,10 +155,6 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
}
});
Client.onReconnect(function () {
refreshNotifications(false);
});
init();
// setup all the dialog focus handling
+130 -10
View File
@@ -5,7 +5,7 @@
/* global $ */
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification', 'ui.bootstrap']);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
@@ -20,9 +20,12 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.busy = false;
$scope.error = {};
$scope.message = ''; // progress
// variables here have to match the import config logic!
$scope.provider = '';
$scope.bucket = '';
$scope.prefix = '';
$scope.mountPoint = '';
$scope.accessKeyId = '';
$scope.secretAccessKey = '';
$scope.gcsKey = { keyFileName: '', content: '' };
@@ -34,6 +37,22 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.acceptSelfSignedCerts = false;
$scope.format = 'tgz';
$scope.advancedVisible = false;
$scope.password = '';
$scope.encrypted = false; // only used if a backup config contains that flag
$scope.setupToken = '';
$scope.skipDnsSetup = false;
$scope.mountOptions = {
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
user: '',
seal: false,
port: 22,
privateKey: ''
};
$scope.sysinfo = {
provider: 'generic',
@@ -65,6 +84,8 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
{ name: 'Asia Pacific (Sydney)', value: 'ap-southeast-2' },
{ name: 'Asia Pacific (Tokyo)', value: 'ap-northeast-1' },
{ name: 'Canada (Central)', value: 'ca-central-1' },
{ name: 'China (Beijing)', value: 'cn-north-1' },
{ name: 'China (Ningxia)', value: 'cn-northwest-1' },
{ name: 'EU (Frankfurt)', value: 'eu-central-1' },
{ name: 'EU (Ireland)', value: 'eu-west-1' },
{ name: 'EU (London)', value: 'eu-west-2' },
@@ -82,6 +103,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
{ name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' }
];
@@ -101,24 +123,56 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.linodeRegions = [
{ name: 'Newark', value: 'us-east-1.linodeobjects.com', region: 'us-east-1' }, // default
{ name: 'Frankfurt', value: 'eu-central-1.linodeobjects.com', region: 'us-east-1' },
{ name: 'Singapore', value: 'ap-south-1.linodeobjects.com', region: 'us-east-1' },
];
// note: ovh also has a storage endpoint but that only supports path style access
$scope.ovhRegions = [
{ name: 'Beauharnois (BHS)', value: 'https://s3.bhs.cloud.ovh.net', region: 'bhs' }, // default
{ name: 'Frankfurt (DE)', value: 'https://s3.de.cloud.ovh.net', region: 'de' },
{ name: 'Gravelines (GRA)', value: 'https://s3.gra.cloud.ovh.net', region: 'gra' },
{ name: 'Strasbourg (SBG)', value: 'https://s3.sbg.cloud.ovh.net', region: 'sbg' },
{ name: 'London (UK)', value: 'https://s3.uk.cloud.ovh.net', region: 'uk' },
{ name: 'Sydney (SYD)', value: 'https://s3.syd.cloud.ovh.net', region: 'syd' },
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' },
];
// https://devops.ionos.com/api/s3/
$scope.ionosRegions = [
{ name: 'DE', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
];
$scope.vultrRegions = [
{ name: 'New Jersey', value: 'https://ewr1.vultrobjects.com', region: 'us-east-1' }, // default
];
$scope.wasabiRegions = [
{ name: 'EU Central 1', value: 'https://s3.eu-central-1.wasabisys.com' },
{ name: 'US East 1', value: 'https://s3.wasabisys.com' },
{ name: 'US East 1', value: 'https://s3.us-east-1.wasabisys.com' },
{ name: 'US East 2', value: 'https://s3.us-east-2.wasabisys.com ' },
{ name: 'US West 1', value: 'https://s3.us-west-1.wasabisys.com' }
];
$scope.storageProvider = [
{ name: 'Amazon S3', value: 's3' },
{ name: 'Backblaze B2 (S3 API)', value: 'backblaze-b2' },
{ name: 'CIFS Mount', value: 'cifs' },
{ name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' },
{ name: 'EXT4 Disk', value: 'ext4' },
{ name: 'Exoscale SOS', value: 'exoscale-sos' },
{ name: 'Filesystem', value: 'filesystem' },
{ name: 'Filesystem (Mountpoint)', value: 'mountpoint' }, // legacy
{ name: 'Google Cloud Storage', value: 'gcs' },
{ name: 'IONOS (Profitbricks)', value: 'ionos-objectstorage' },
{ name: 'Linode Object Storage', value: 'linode-objectstorage' },
{ name: 'Minio', value: 'minio' },
{ name: 'Scaleway Object Storage', value: 'scaleway-objectstorage' },
{ name: 'NFS Mount', value: 'nfs' },
{ name: 'OVH Object Storage', value: 'ovh-objectstorage' },
{ name: 'S3 API Compatible (v4)', value: 's3-v4-compat' },
{ name: 'Scaleway Object Storage', value: 'scaleway-objectstorage' },
{ name: 'SSHFS Mount', value: 'sshfs' },
{ name: 'UpCloud Object Storage', value: 'upcloud-objectstorage' },
{ name: 'Vultr Object Storage', value: 'vultr-objectstorage' },
{ name: 'Wasabi', value: 'wasabi' }
];
@@ -130,7 +184,12 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.s3like = function (provider) {
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos'
|| provider === 'digitalocean-spaces' || provider === 'wasabi' || provider === 'scaleway-objectstorage'
|| provider === 'linode-objectstorage';
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'backblaze-b2'
|| provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage';
};
$scope.mountlike = function (provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4';
};
$scope.restore = function () {
@@ -139,9 +198,9 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
var backupConfig = {
provider: $scope.provider,
key: $scope.key,
format: $scope.format
format: $scope.format,
};
if ($scope.password) backupConfig.password = $scope.password;
// only set provider specific fields, this will clear them in the db
if ($scope.s3like(backupConfig.provider)) {
@@ -156,13 +215,14 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
if ($scope.region) backupConfig.region = $scope.region;
delete backupConfig.endpoint;
} else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') {
backupConfig.region = 'us-east-1';
backupConfig.region = backupConfig.region || 'us-east-1';
backupConfig.acceptSelfSignedCerts = $scope.acceptSelfSignedCerts;
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
} else if (backupConfig.provider === 'exoscale-sos') {
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'wasabi') {
backupConfig.region = 'us-east-1';
backupConfig.region = $scope.wasabiRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'scaleway-objectstorage') {
backupConfig.region = $scope.scalewayRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
@@ -170,6 +230,19 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
} else if (backupConfig.provider === 'linode-objectstorage') {
backupConfig.region = $scope.linodeRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'ovh-objectstorage') {
backupConfig.region = $scope.ovhRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'ionos-objectstorage') {
backupConfig.region = $scope.ionosRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} 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 === '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
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
}
@@ -193,6 +266,28 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
$scope.busy = false;
return;
}
} else if ($scope.mountlike(backupConfig.provider)) {
backupConfig.prefix = $scope.prefix;
backupConfig.mountOptions = {};
if (backupConfig.provider === 'cifs' || backupConfig.provider === 'sshfs' || backupConfig.provider === 'nfs') {
backupConfig.mountOptions.host = $scope.mountOptions.host;
backupConfig.mountOptions.remoteDir = $scope.mountOptions.remoteDir;
if (backupConfig.provider === 'cifs') {
backupConfig.mountOptions.username = $scope.mountOptions.username;
backupConfig.mountOptions.password = $scope.mountOptions.password;
backupConfig.mountOptions.seal = $scope.mountOptions.seal;
} else if (backupConfig.provider === 'sshfs') {
backupConfig.mountOptions.user = $scope.mountOptions.user;
backupConfig.mountOptions.port = $scope.mountOptions.port;
backupConfig.mountOptions.privateKey = $scope.mountOptions.privateKey;
}
} else if (backupConfig.provider === 'ext4') {
backupConfig.mountOptions.diskPath = $scope.mountOptions.diskPath;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountPoint = $scope.mountPoint;
}
} else if (backupConfig.provider === 'filesystem') {
backupConfig.backupFolder = $scope.backupFolder;
}
@@ -228,7 +323,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
sysinfoConfig.ifname = $scope.sysinfo.ifname;
}
Client.restore(backupConfig, $scope.backupId.replace(/\.tar\.gz(\.enc)?$/, ''), version ? version[1] : '', sysinfoConfig, function (error) {
Client.restore(backupConfig, $scope.backupId.replace(/\.tar\.gz(\.enc)?$/, ''), version ? version[1] : '', sysinfoConfig, $scope.skipDnsSetup, $scope.setupToken, function (error) {
$scope.busy = false;
if (error) {
@@ -288,7 +383,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
return;
}
$scope.message = status.restore.message;
if (!error) $scope.message = status.restore.message;
setTimeout(waitForRestore, 5000);
});
@@ -312,6 +407,29 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
document.getElementById('gcsKeyFileInput').onchange = readFileLocally($scope.gcsKey, 'content', 'keyFileName');
document.getElementById('backupConfigFileInput').onchange = function (event) {
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read backup config');
var backupConfig;
try {
backupConfig = JSON.parse(result.target.result);
} catch (e) {
console.error('Unable to parse backup config');
return;
}
$scope.$apply(function () {
// we assume property names match here, this does not yet work for gcs keys
Object.keys(backupConfig).forEach(function (k) {
if (k in $scope) $scope[k] = backupConfig[k];
});
});
};
reader.readAsText(event.target.files[0]);
};
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
@@ -325,7 +443,9 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
return;
}
$scope.status = status;
$scope.instanceId = search.instanceId;
$scope.setupToken = search.setupToken;
$scope.initialized = true;
});
}
+76 -50
View File
@@ -4,78 +4,104 @@
/* global $ */
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification', 'ui.bootstrap']);
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.busy = false;
$scope.account = {
email: '',
displayName: '',
requireEmail: false,
username: '',
password: ''
};
$scope.error = null;
$scope.provider = '';
$scope.apiServerOrigin = '';
$scope.webServerOrigin = '';
$scope.setupToken = '';
$scope.activateCloudron = function () {
$scope.busy = true;
$scope.error = null;
$scope.owner = {
error: null,
busy: false,
Client.createAdmin($scope.account.username, $scope.account.password, $scope.account.email, $scope.account.displayName, function (error) {
if (error && error.statusCode === 400) {
$scope.busy = false;
$scope.error = { username: error.message };
$scope.account.username = '';
$scope.setupForm.username.$setPristine();
setTimeout(function () { $('#inputUsername').focus(); }, 200);
return;
} else if (error) {
$scope.busy = false;
console.error('Internal error', error);
$scope.error = { generic: error.message };
return;
}
email: '',
displayName: '',
username: '',
password: '',
window.location.href = '/';
});
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;
$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');
});
}
};
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';
} else {
$scope.view = 'owner';
}
}
function init() {
Client.getStatus(function (error, status) {
if (error) return Client.initError(error, init);
// 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;
}
redirectIfNeeded(status);
setView(search.view);
// 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;
}
$scope.account.email = search.email || $scope.account.email;
$scope.account.displayName = search.displayName || $scope.account.displayName;
$scope.account.requireEmail = !search.email;
$scope.provider = status.provider;
$scope.apiServerOrigin = status.apiServerOrigin;
$scope.webServerOrigin = status.webServerOrigin;
$scope.setupToken = search.setupToken;
$scope.initialized = true;
// Ensure we have a good autofocus
+61 -14
View File
@@ -3,14 +3,14 @@
/* global angular, $, showdown */
// create main application module
var app = angular.module('Application', []);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies']);
app.filter('markdown2html', function () {
var converter = new showdown.Converter({
extensions: [ 'targetblank' ],
simplifiedAutoLink: true,
strikethrough: true,
tables: true
tables: true,
openLinksInNewWindow: true
});
return function (text) {
@@ -23,7 +23,44 @@ app.config(function ($sceProvider) {
$sceProvider.enabled(false);
});
app.controller('SetupAccountController', ['$scope', '$http', function ($scope, $http) {
app.config(['$translateProvider', function ($translateProvider) {
$translateProvider.useStaticFilesLoader({
prefix: 'translation/',
suffix: '.json'
});
$translateProvider.useLocalStorage();
$translateProvider.preferredLanguage('en');
$translateProvider.fallbackLanguage('en');
}]);
// Add shorthand "tr" filter to avoid having ot use "translate"
// This is a copy of the code at https://github.com/angular-translate/angular-translate/blob/master/src/filter/translate.js
// If we find out how to get that function handle somehow dynamically we can use that, otherwise the copy is required
function translateFilterFactory($parse, $translate) {
'use strict';
var translateFilter = function (translationId, interpolateParams, interpolation, forceLanguage) {
if (!angular.isObject(interpolateParams)) {
var ctx = this || {
'__SCOPE_IS_NOT_AVAILABLE': 'More info at https://github.com/angular/angular.js/commit/8863b9d04c722b278fa93c5d66ad1e578ad6eb1f'
};
interpolateParams = $parse(interpolateParams)(ctx);
}
return $translate.instant(translationId, interpolateParams, interpolation, forceLanguage);
};
if ($translate.statefulFilter()) {
translateFilter.$stateful = true;
}
return translateFilter;
}
translateFilterFactory.displayName = 'translateFilterFactory';
app.filter('tr', translateFilterFactory);
app.controller('SetupAccountController', ['$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; }, {});
@@ -35,6 +72,7 @@ app.controller('SetupAccountController', ['$scope', '$http', function ($scope, $
$scope.view = 'setup';
$scope.status = null;
$scope.profileLocked = !!search.profileLocked;
$scope.existingUsername = !!search.username;
$scope.username = search.username || '';
$scope.displayName = search.displayName || '';
@@ -46,13 +84,15 @@ app.controller('SetupAccountController', ['$scope', '$http', function ($scope, $
$scope.error = null;
var data = {
resetToken: search.resetToken,
email: search.email,
username: $scope.username,
displayName: $scope.displayName,
inviteToken: search.inviteToken,
password: $scope.password
};
if (!$scope.profileLocked) {
if (!$scope.existingUsername) data.username = $scope.username;
data.displayName = $scope.displayName;
}
function error(data, status) {
$scope.busy = false;
@@ -89,13 +129,20 @@ app.controller('SetupAccountController', ['$scope', '$http', function ($scope, $
}).error(error);
};
$http.get(API_ORIGIN + '/api/v1/cloudron/status').success(function (data, status) {
if (!$scope.existingUsername && $scope.profileLocked) {
$scope.view = 'noUsername';
$scope.initialized = true;
} else {
$http.get(API_ORIGIN + '/api/v1/cloudron/status').success(function (data, status) {
$scope.initialized = true;
if (status !== 200) return;
if (status !== 200) return;
$scope.status = data;
}).error(function () {
$scope.initialized = false;
});
if (data.language) $translate.use(data.language);
$scope.status = data;
}).error(function () {
$scope.initialized = false;
});
}
}]);
+50 -15
View File
@@ -1,9 +1,9 @@
'use strict';
/* global $, tld, angular */
/* global $, tld, angular, Clipboard */
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification', 'ui.bootstrap']);
var app = angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification', 'ui.bootstrap']);
app.filter('zoneName', function () {
return function (domain) {
@@ -21,9 +21,11 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
$scope.instanceId = '';
$scope.isDomain = false;
$scope.isSubdomain = false;
$scope.hyphenatedSubdomains = false;
$scope.advancedVisible = false;
$scope.webServerOrigin = '';
$scope.clipboardDone = false;
$scope.search = window.location.search;
$scope.setupToken = '';
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
@@ -83,8 +85,11 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
@@ -102,16 +107,20 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
cloudflareTokenType: 'GlobalApiKey',
godaddyApiKey: '',
godaddyApiSecret: '',
linodeToken: '',
vultrToken: '',
nameComUsername: '',
nameComToken: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
provider: 'route53',
zoneName: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
},
hyphenatedSubdomains: false
}
};
$scope.setDefaultTlsProvider = function () {
@@ -149,9 +158,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
var provider = $scope.dnsCredentials.provider;
var config = {
hyphenatedSubdomains: $scope.dnsCredentials.hyphenatedSubdomains
};
var config = {};
if (provider === 'route53') {
config.accessKeyId = $scope.dnsCredentials.accessKeyId;
@@ -184,12 +191,20 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
config.email = $scope.dnsCredentials.cloudflareEmail;
config.token = $scope.dnsCredentials.cloudflareToken;
config.tokenType = $scope.dnsCredentials.cloudflareTokenType;
} else if (provider === 'linode') {
config.token = $scope.dnsCredentials.linodeToken;
} 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;
}
var tlsConfig = {
@@ -211,7 +226,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
}
var data = {
dnsConfig: {
domainConfig: {
domain: $scope.dnsCredentials.domain,
zoneName: $scope.dnsCredentials.zoneName,
provider: provider,
@@ -219,14 +234,19 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
tlsConfig: tlsConfig
},
sysinfoConfig: sysinfoConfig,
providerToken: $scope.instanceId
providerToken: $scope.instanceId,
setupToken: $scope.setupToken
};
Client.setup(data, function (error) {
if (error) {
$scope.dnsCredentials.busy = false;
if (error.statusCode === 422) {
$scope.error.ami = error.message;
if (provider === 'ami') {
$scope.error.ami = error.message;
} else {
$scope.error.setup = error.message;
}
} else {
$scope.error.dnsCredentials = error.message;
}
@@ -247,7 +267,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
$scope.state = 'initialized';
$scope.dnsCredentials.busy = false;
} else { // proceed to activation
window.location.href = 'https://' + status.adminFqdn + '/setup.html';
window.location.href = 'https://' + status.adminFqdn + '/setup.html' + (window.location.search);
}
return;
}
@@ -270,11 +290,20 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
// domain is currently like a lock flag
if (status.adminFqdn) return waitForDnsSetup();
if (status.provider === 'digitalocean' || status.provider === 'digitalocean-mp') $scope.dnsCredentials.provider = 'digitalocean';
if (status.provider === 'gce') $scope.dnsCredentials.provider = 'gcdns';
if (status.provider === 'ami') $scope.dnsCredentials.provider = 'route53';
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.webServerOrigin = status.webServerOrigin;
$scope.state = 'initialized';
@@ -283,5 +312,11 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
});
}
var clipboard = new Clipboard('.clipboard');
clipboard.on('success', function () {
$scope.$apply(function () { $scope.clipboardDone = true; });
$timeout(function () { $scope.clipboardDone = false; }, 5000);
});
initialize();
}]);
+25 -13
View File
@@ -1,14 +1,11 @@
'use strict';
/* global angular */
/* global $ */
/* global Terminal */
/* global ISTATES */
/* global angular, $, Terminal, AttachAddon, FitAddon, ISTATES */
// create main application module
var app = angular.module('Application', ['angular-md5', 'ui-notification']);
angular.module('Application', ['pascalprecht.translate', 'ngCookies', 'angular-md5', 'ui-notification']);
app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client', function ($scope, $timeout, $location, Client) {
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();
@@ -18,6 +15,7 @@ app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client
$scope.selected = '';
$scope.terminal = null;
$scope.terminalSocket = null;
$scope.fitAddon = null;
$scope.restartAppBusy = false;
$scope.appBusy = false;
$scope.selectedAppInfo = null;
@@ -110,7 +108,7 @@ app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client
function reset() {
if ($scope.terminal) {
$scope.terminal.destroy();
$scope.terminal.dispose();
$scope.terminal = null;
}
@@ -151,9 +149,10 @@ app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client
function createTerminalSocket() {
try {
// websocket cannot use relative urls
var url = Client.apiOrigin.replace('https', 'wss') + '/api/v1/apps/' + $scope.selected.value + '/execws?tty=true&rows=' + $scope.terminal.rows + '&columns=' + $scope.terminal.cols + '&access_token=' + Client.getToken();
var cmd = JSON.stringify([ '/bin/bash' ]);
var url = Client.apiOrigin.replace('https', 'wss') + '/api/v1/apps/' + $scope.selected.value + '/execws?tty=true&rows=' + $scope.terminal.rows + '&columns=' + $scope.terminal.cols + '&access_token=' + Client.getToken() + '&cmd=' + cmd;
$scope.terminalSocket = new WebSocket(url);
$scope.terminal.attach($scope.terminalSocket);
$scope.terminal.loadAddon(new AttachAddon.AttachAddon($scope.terminalSocket));
$scope.terminalSocket.onclose = function () {
// retry in one second
@@ -192,7 +191,11 @@ app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client
$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.terminal.open(document.querySelector('#terminalContainer'), true);
$scope.fitAddon = new FitAddon.FitAddon();
$scope.terminal.loadAddon($scope.fitAddon);
$scope.terminal.open(document.querySelector('#terminalContainer'));
window.terminal = $scope.terminal;
@@ -207,14 +210,19 @@ app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client
// 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;
$scope.terminal.fit();
// 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);
createTerminalSocket(); // create exec container after we fit() since we cannot resize exec container post-creation
// 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();
$scope.terminal.focus();
}, 1000);
});
}
@@ -299,7 +307,7 @@ app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client
});
window.addEventListener('resize', function () {
if ($scope.terminal) $scope.terminal.fit();
if ($scope.fitAddon) $scope.fitAddon.fit();
});
Client.getStatus(function (error, status) {
@@ -349,6 +357,10 @@ app.controller('TerminalController', ['$scope', '$timeout', '$location', 'Client
});
});
$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 () {
+40
View File
@@ -0,0 +1,40 @@
/* This file contains helpers which should not be part of client.js */
angular.module('Application').directive('passwordReveal', 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>';
return {
link: function (scope, elements) {
var element = elements[0];
if (!element.parentNode) {
console.error('Wrong password-reveal directive usage. Element has no parent.');
return;
}
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);
}
};
});
+54 -41
View File
@@ -5,44 +5,54 @@
<meta name="viewport" content="user-scalable=no, initial-scale=1, maximum-scale=1, minimum-scale=1, width=device-width, height=device-height" />
<meta http-equiv="Content-Security-Policy" content="default-src <%= apiOrigin %> 'unsafe-inline' 'unsafe-eval' 'self'; img-src <%= apiOrigin %> 'self'" />
<title>Cloudron Login</title>
<!-- this gets changed once we get the status (because angular has not loaded yet, we see template string for a flash) -->
<title>&lrm;</title>
<link id="favicon" href="<%= apiOrigin %>/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
<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"></script>
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js?<%= revision %>"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.6.4.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/showdown-target-blank.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/showdown-1.9.1.min.js?<%= revision %>"></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.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-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>
<script type="text/javascript" src="/3rdparty/js/angular-cookies.min.js?<%= revision %>"></script>
<script type="text/javascript" src="/3rdparty/js/autofill-event.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>
<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>
<!-- Setup Application -->
<script type="text/javascript" src="/js/login.js"></script>
<script type="text/javascript" src="/js/login.js?<%= revision %>"></script>
</head>
<body ng-app="Application" ng-controller="LoginController">
<div class="layout-root" ng-show="initialized">
<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;">
@@ -50,33 +60,33 @@
<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 to</small> {{ status.cloudronName || 'Cloudron' }}</h1>
<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">Incorrect username or password</h4>
<h4 class="has-error" ng-show="error">{{ 'login.errorIncorrectCredentials' | 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">Username</label>
<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">
<label class="control-label" for="inputPassword">Password</label>
<input type="password" class="form-control" name="password" id="inputPassword" ng-model="password" ng-disabled="busy" required>
<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">
<label class="control-label" for="inputPassword">2FA Token (if enabled)</label>
<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> Sign in</button>
<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()">Reset password</a>
<a ng-href="" class="hand" ng-click="showPasswordReset()">{{ 'login.resetPasswordAction' | tr }}</a>
</div>
</div>
</div>
@@ -88,7 +98,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/>
<h2>Password reset</h2>
<h2>{{ 'passwordReset.title' | tr }}</h2>
</div>
</div>
<br/>
@@ -96,13 +106,13 @@
<div class="col-md-12">
<form name="passwordResetForm" ng-submit="onPasswordReset()">
<div class="form-group">
<label class="control-label" for="inputPasswordResetIdentifier">Username or Email</label>
<label class="control-label" for="inputPasswordResetIdentifier">{{ 'passwordReset.usernameOrEmail' | tr }}</label>
<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> Reset</button>
<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>
</form>
<a ng-href="" class="hand" ng-click="showLogin()">Back to login</a>
<a ng-href="" class="hand" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
@@ -114,9 +124,9 @@
<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>Password reset email sent</h2>
<h2>{{ 'passwordReset.emailSent.title' | tr }}</h2>
<br/>
<button class="btn btn-primary" ng-click="showLogin()">Back to login</button>
<button class="btn btn-primary" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</button>
</div>
</div>
</div>
@@ -128,7 +138,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/>
<h2>Set new password</h2>
<h2>{{ 'passwordReset.newPassword.title' | tr }}</h2>
</div>
</div>
<br/>
@@ -143,23 +153,27 @@
<form name="newPasswordForm" ng-submit="onNewPassword()">
<input type="password" style="display: none;"/>
<div class="form-group" ng-class="{ 'has-error': newPasswordForm.newPassword.$dirty && newPasswordForm.newPassword.$invalid }">
<label class="control-label" for="inputNewPassword">New Password</label>
<label class="control-label" for="inputNewPassword">{{ 'passwordReset.newPassword.password' | tr }}</label>
<div class="control-label" ng-show="newPasswordForm.newPassword.$dirty && newPasswordForm.newPassword.$invalid">
<small ng-show="newPasswordForm.newPassword.$dirty && newPasswordForm.newPassword.$invalid">Password must be atleast 8 and at most 265 characters</small>
<small ng-show="newPasswordForm.newPassword.$dirty && newPasswordForm.newPassword.$invalid">{{ 'passwordReset.newPassword.errorLength' | tr }}</small>
</div>
<input type="password" class="form-control" id="inputNewPassword" ng-model="newPassword" name="newPassword" ng-minlength="8" ng-maxlength="256" autofocus required>
<input type="password" class="form-control" id="inputNewPassword" ng-model="newPassword" name="newPassword" ng-minlength="8" ng-maxlength="256" autofocus required password-reveal>
</div>
<div class="form-group" ng-class="{ 'has-error': newPasswordForm.newPasswordRepeat.$dirty && (newPassword !== newPasswordRepeat) }">
<label class="control-label" for="inputNewPasswordRepeat">Repeat Password</label>
<label class="control-label" for="inputNewPasswordRepeat">{{ 'passwordReset.newPassword.passwordRepeat' | tr }}</label>
<div class="control-label" ng-show="newPasswordForm.newPasswordRepeat.$dirty && (newPassword !== newPasswordRepeat)">
<small ng-show="newPasswordForm.newPasswordRepeat.$dirty && (newPassword !== newPasswordRepeat)">Passwords don't match</small>
<small ng-show="newPasswordForm.newPasswordRepeat.$dirty && (newPassword !== newPasswordRepeat)">{{ 'passwordReset.newPassword.errorMismatch' | tr }}</small>
</div>
<input type="password" class="form-control" id="inputNewPasswordRepeat" ng-model="newPasswordRepeat" name="newPasswordRepeat" required>
<input type="password" class="form-control" id="inputNewPasswordRepeat" ng-model="newPasswordRepeat" name="newPasswordRepeat" required password-reveal>
</div>
<div class="form-group">
<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> Submit</button>
<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>
</form>
<a ng-href="" class="hand" ng-click="showLogin()">Back to login</a>
<a ng-href="" class="hand" ng-click="showLogin()">{{ 'passwordReset.backToLoginAction' | tr }}</a>
</div>
</div>
</div>
@@ -171,17 +185,16 @@
<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>Password changed</h2>
<h2>{{ 'passwordReset.success.title' | tr }}</h2>
<br/>
<a href="/" class="btn btn-primary">Open Dashboard</a>
<a href="/" class="btn btn-primary">{{ 'passwordReset.success.openDashboardAction' | tr }}</a>
</div>
</div>
</div>
</div>
<footer class="text-center ng-cloak">
<footer class="text-center">
<span class="text-muted" ng-bind-html="status.footer | markdown2html"></span>
<span class="version">v{{status.version}}</span>
</footer>
</div>
+58 -47
View File
@@ -1,72 +1,83 @@
<!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" />
<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" />
<!-- this gets changed once we get the config (because angular has not loaded yet, we see template string for a flash) -->
<title> Logs </title>
<title> Logs </title>
<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">
<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"/>
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js?<%= revision %>"></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-animate.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-base64.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-sanitize.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.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>
<!-- 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>
<!-- colors -->
<script type="text/javascript" src="/3rdparty/js/colors.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?<%= revision %>"></script>
<!-- moment -->
<script type="text/javascript" src="/3rdparty/js/moment.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>
<!-- Main Application -->
<script type="text/javascript" src="/js/logs.js"></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.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://cloudron.io/documentation/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<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 }} Logs</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</a>
<a class="btn btn-primary" ng-click="clear()"><i class="fa fa-trash"></i> Clear View</a>
<a class="btn btn-primary" ng-href="{{ selected.url }}&format=short&lines=-1"><i class="fa fa-download"></i> Download Full Logs</a>
</div>
</div>
<div class="logs-container"></div>
<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>
+67
View File
@@ -0,0 +1,67 @@
<!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 - Not Found</title>
<!-- Use static style as we can't include local stylesheets -->
<style>
html {
height: 100%;
width: 100%;
padding: 0;
}
body {
background-color: white;
padding: 0;
margin: 0;
height: 100%;
width: 100%;
text-align: center;
font-family: "Roboto","Helvetica Neue",Helvetica,Arial,sans-serif;
font-size: 13px;
line-height: 1.846;
}
.content {
display: flex;
width: 100%;
height: 100%;
flex-direction: column;
justify-content: center;
}
a {
color: #2196f3;
text-decoration: none;
background-color: transparent;
}
a:hover {
color: #0a6ebd;
text-decoration: underline;
}
</style>
<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'
+ ' but Cloudron has no app configured for this domain.';
});
</script>
</head>
<body>
<div class="content">
<p id="message"></p>
</div>
</body>
</html>
+123 -12
View File
@@ -11,18 +11,22 @@
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
@@ -33,6 +37,15 @@
<!-- 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/restore.js"></script>
@@ -40,7 +53,7 @@
<body class="setup" ng-app="Application" ng-controller="RestoreController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://cloudron.io/documentation/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<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">
@@ -60,19 +73,87 @@
<div class="col-md-10 col-md-offset-1 text-center">
<h2>Cloudron Restore</h2>
<p>Provide the backup to restore from</p>
<br/>
</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">
<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="{{ config.webServerOrigin }}/documentation/backups/#storage-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<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 storageProvider" 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="username" 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="password" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4 -->
<div class="form-group" ng-class="{ 'has-error': error.diskPath }" ng-show="provider === 'ext4'">
<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'">
</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>
@@ -80,9 +161,9 @@
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 's3-v4-compat'">
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'upcloud-objectstorage' || provider === 'backblaze-b2' || provider === 's3-v4-compat'">
<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 of Minio/S3 Compatible" ng-required="provider === 'minio' || provider === 's3-v4-compat'">
<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 === 's3-v4-compat'">
</div>
<div class="checkbox" ng-show="provider === 'minio' || provider === 's3-v4-compat'" >
@@ -98,7 +179,7 @@
<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="s3like(provider) || provider === 'gcs'">
<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>
@@ -108,6 +189,11 @@
<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>
@@ -133,6 +219,21 @@
<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)">
@@ -167,15 +268,25 @@
</div>
<div class="form-group" ng-class="{ 'has-error': error.key }" ng-show="provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupKey">Encryption key (optional)</label>
<input type="text" class="form-control" ng-model="key" id="inputConfigureBackupKey" name="prefix" ng-disabled="busy" placeholder="Passphrase used to encrypt the backups">
<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">
<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="{{ config.webServerOrigin }}/documentation/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<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>
@@ -216,7 +327,7 @@
</div>
<footer class="text-center">
<span class="text-muted">&copy;2020 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<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>
+108 -60
View File
@@ -4,25 +4,29 @@
<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 Admin Setup </title>
<title> Cloudron Setup </title>
<link id="favicon" href="/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
@@ -30,6 +34,15 @@
<!-- 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/setup.js"></script>
@@ -37,70 +50,105 @@
<body class="setup" ng-app="Application" ng-controller="SetupController">
<a class="offline-banner animateMe" ng-show="client.offline" ng-cloak href="https://cloudron.io/documentation/troubleshooting/" target="_blank"><i class="fa fa-circle-notch fa-spin"></i> Cloudron is offline. Reconnecting...</a>
<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>
<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>
</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 role="form" name="setupForm" ng-submit="activateCloudron()" novalidate>
<div class="row">
<div class="col-md-12 text-center">
<h1>Welcome to Cloudron</h1>
<h3>Setup Admin Account</h3>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-8 col-md-offset-2">
<div class="form-group" ng-class="{ 'has-error': setupForm.displayName.$dirty && setupForm.displayName.$invalid }">
<label class="control-label">Full Name</label>
<input type="text" class="form-control" ng-model="account.displayName" id="inputDisplayName" name="displayName" placeholder="Full Name" required autocomplete="off" autofocus>
</div>
<div ng-show="account.requireEmail" class="form-group" ng-class="{ 'has-error': setupForm.email.$dirty && setupForm.email.$invalid }">
<label class="control-label">Email <sup><a ng-href="{{ webServerOrigin }}/documentation/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="account.email" id="inputEmail" name="email" placeholder="Email" required autocomplete="off" tooltip-class="long" tooltip-trigger="focus" uib-tooltip="This email address is local to your Cloudron and used for notifications and password reset. A valid email is also required for Let's Encrypt certificates.">
</div>
<div class="form-group" ng-class="{ 'has-error': (setupForm.username.$dirty && setupForm.username.$invalid) || (!setupForm.username.$dirty && error.username) }">
<label class="control-label">Username</label>
<p ng-show="!setupForm.username.$dirty && error.username">{{ error.username }}</p>
<input type="text" class="form-control" ng-model="account.username" id="inputUsername" name="username" placeholder="Username" ng-maxlength="512" ng-minlength="1" required autocomplete="off">
</div>
<div class="form-group" ng-class="{ 'has-error': setupForm.password.$dirty && setupForm.password.$invalid }">
<label class="control-label">Password</label>
<input type="password" class="form-control" ng-model="account.password" id="inputPassword" name="password" placeholder="Password" ng-pattern="/^.{8,}$/" required autocomplete="off">
<div class="control-label" ng-show="setupForm.password.$dirty && setupForm.password.$invalid">
<small ng-show="setupForm.password.$dirty && setupForm.password.$invalid">Password must be atleast 8 characters</small>
</div>
</div>
</div>
</div>
<br/>
<br/>
<div class="row">
<div class="col-md-12 text-center">
<input type="submit" class="btn btn-success" ng-disabled="setupForm.$invalid" value="Done">
</div>
</div>
</form>
<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 }">
<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>
<footer class="text-center">
<span class="text-muted">&copy;2020 <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>
<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">
<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>
+58 -42
View File
@@ -10,33 +10,40 @@
<link id="favicon" href="<%= apiOrigin %>/api/v1/cloudron/avatar" rel="icon" type="image/png">
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
<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"></script>
<!-- Showdown (markdown converter) -->
<script type="text/javascript" src="/3rdparty/js/showdown-1.6.4.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/showdown-target-blank.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js?<%= revision %>"></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-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>
<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/autofill-event.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>
<script type="text/javascript" src="/3rdparty/js/ui-bootstrap-tpls-1.3.3.min.js?<%= revision %>"></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/setupaccount.js"></script>
<script type="text/javascript" src="/js/setupaccount.js?<%= revision %>"></script>
</head>
@@ -50,8 +57,8 @@
<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>Welcome to</small> {{ status.cloudronName || 'Cloudron' }}</h1>
<h3>Please setup your account</h3>
<h1><small>{{ 'setupAccount.welcomeTo' | tr }}</small> {{ status.cloudronName || 'Cloudron' }}</h1>
<h3>{{ 'setupAccount.description' | tr }}</h3>
</div>
</div>
<br/>
@@ -65,57 +72,67 @@
<form name="setupAccountForm" ng-submit="onSubmit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-show="existingUsername">
<label class="control-label">Username</label>
<input type="text" class="form-control" ng-model="username" name="username" readonly required>
</div>
<div class="form-group" ng-class="{ 'has-error': ((setupAccountForm.username.$dirty && setupAccountForm.username.$invalid) || (!setupAccountForm.username.$dirty && error.username))}" ng-hide="existingUsername">
<label class="control-label">Username</label>
<div class="form-group" ng-class="{ 'has-error': ((setupAccountForm.username.$dirty && setupAccountForm.username.$invalid) || (!setupAccountForm.username.$dirty && error.username))}">
<label class="control-label" for="inputUsername">{{ 'setupAccount.username' | tr }}</label>
<input type="text" class="form-control" ng-model="username" name="username" id="inputUsername" ng-disabled="profileLocked || existingUsername" required autofocus>
<div class="control-label" ng-show="setupAccountForm.username.$dirty && setupAccountForm.username.$invalid">
<small ng-show="setupAccountForm.username.$error.minlength">The username is too short</small>
<small ng-show="setupAccountForm.username.$error.maxlength">The username is too long</small>
<small ng-show="setupAccountForm.username.$dirty && setupAccountForm.username.$invalid">Not a valid username</small>
<small ng-show="setupAccountForm.username.$error.minlength">{{ 'setupAccount.errorUsernameTooShort' | tr }}</small>
<small ng-show="setupAccountForm.username.$error.maxlength">{{ 'setupAccount.errorUsernameTooLong' | tr }}</small>
<small ng-show="setupAccountForm.username.$dirty && setupAccountForm.username.$invalid">{{ 'setupAccount.errorUsernameInvalid' | tr }}</small>
</div>
<input type="text" class="form-control" ng-model="username" name="username" id="inputUsername" required autofocus>
</div>
<div class="form-group">
<label class="control-label">Full Name</label>
<input type="displayName" class="form-control" ng-model="displayName" name="displayName" required>
<label class="control-label" for="inputDisplayName">{{ 'setupAccount.fullName' | tr }}</label>
<input type="displayName" class="form-control" ng-model="displayName" name="displayName" id="inputDisplayName" ng-disabled="profileLocked" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (setupAccountForm.password.$dirty && setupAccountForm.password.$invalid) }">
<label class="control-label">New Password</label>
<label class="control-label" for="inputPassword">{{ 'setupAccount.password' | tr }}</label>
<input type="password" class="form-control" ng-model="password" name="password" id="inputPassword" ng-pattern="/^.{8,}$/" required password-reveal>
<div class="control-label" ng-show="setupAccountForm.password.$dirty && setupAccountForm.password.$invalid">
<small ng-show="setupAccountForm.password.$dirty && setupAccountForm.password.$invalid">Password must be atleast 8 characters</small>
<small ng-show="setupAccountForm.password.$dirty && setupAccountForm.password.$invalid">{{ 'setupAccount.errorPassword' | tr }}</small>
</div>
<input type="password" class="form-control" ng-model="password" name="password" ng-pattern="/^.{8,}$/" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (setupAccountForm.passwordRepeat.$dirty && (password !== passwordRepeat)) }">
<label class="control-label">Repeat Password</label>
<label class="control-label" for="inputPasswordRepeat">{{ 'setupAccount.passwordRepeat' | tr }}</label>
<input type="password" class="form-control" ng-model="passwordRepeat" name="passwordRepeat" id="inputPasswordRepeat" required password-reveal>
<div class="control-label" ng-show="setupAccountForm.passwordRepeat.$dirty && (password !== passwordRepeat)">
<small ng-show="setupAccountForm.passwordRepeat.$dirty && (password !== passwordRepeat)">Passwords don't match</small>
<small ng-show="setupAccountForm.passwordRepeat.$dirty && (password !== passwordRepeat)">{{ 'setupAccount.errorPasswordNoMatch' | tr }}</small>
</div>
<input type="password" class="form-control" ng-model="passwordRepeat" name="passwordRepeat" required>
</div>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || setupAccountForm.$invalid || password !== passwordRepeat"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> Setup</button>
<button class="btn btn-primary btn-outline pull-right" type="submit" ng-disabled="busy || setupAccountForm.$invalid || password !== passwordRepeat"><i class="fa fa-circle-notch fa-spin" ng-show="busy"></i> {{ 'setupAccount.setupAction' | tr }}</button>
</form>
</div>
</div>
</div>
</div>
<div class="layout-content" ng-show="view === 'noUsername'">
<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/>
<h2>{{ 'setupAccount.noUsername.title' | tr }}</h2>
<br/>
<p>{{ 'setupAccount.noUsername.description' | tr }}</p>
</div>
</div>
</div>
</div>
<div class="layout-content" ng-show="view === 'invalidToken'">
<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/>
<h2>Invalid or Expired Invite Link</h2>
<h2>{{ 'setupAccount.invalidToken.title' | tr }}</h2>
<br/>
<p>Contact your server admin to get a new invite link.</p>
<p>{{ 'setupAccount.invalidToken.description' | tr }}</p>
</div>
</div>
</div>
@@ -127,9 +144,9 @@
<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>Your Account is ready</h2>
<h2>{{ 'setupAccount.success.title' | tr }}</h2>
<br/>
<a href="/" class="btn btn-primary">Open Dashboard</a>
<a href="/" class="btn btn-primary">{{ 'setupAccount.success.openDashboardAction' | tr }}</a>
</div>
</div>
</div>
@@ -137,7 +154,6 @@
<footer class="text-center ng-cloak">
<span class="text-muted" ng-bind-html="status.footer | markdown2html"></span>
<span class="version">v{{status.version}}</span>
</footer>
</div>
+83 -37
View File
@@ -11,28 +11,42 @@
<!-- Theme CSS -->
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
@@ -40,14 +54,26 @@
<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">
<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="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">
@@ -55,23 +81,25 @@
<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 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">
<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">
<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="{{ webServerOrigin }}/documentation/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>
<p style="margin-top: 5px; font-size: 13px;">Apps will be installed on subdomains of this domain. You can add more domains later.</p>
</div>
</div>
<div class="row">
<div class="col-md-10 col-md-offset-1">
<h3 class="text-center">Domain Configuration <sup><a ng-href="{{ webServerOrigin }}/documentation/domains/#dns-providers" class="help" target="_blank" tabindex="-1"><i class="fa fa-question-circle"></i></a></sup> </h3>
<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">
@@ -123,6 +151,20 @@
<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>
@@ -163,14 +205,26 @@
<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>
<!-- 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>Setup A records for <b>*.{{ dnsCredentials.domain || 'example.com' }}</b> and <b>{{ dnsCredentials.domain || 'example.com' }}</b> to this server's IP.</span>
<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>Setup an A record for <b>my.{{ dnsCredentials.domain || 'example.com' }}</b> to this server's IP.<br/></span>
<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>
@@ -188,26 +242,18 @@
<br/>
<div uib-collapse="!advancedVisible">
<div ng-show="false">
<label>
<input type="checkbox" ng-model="dnsCredentials.hyphenatedSubdomains" name="hyphenatedSubdomains" ng-disabled="dnsCredentials.busy"/>&nbsp; Hyphenate Subdomains
</label>
<p>When enabled, apps are installed into <code>&lt;location&gt;-&lt;domain&gt;</code></p>
<br/>
</div>
<div class="form-group">
<label class="control-label">Zone Name (Optional) <sup><a ng-href="{{ webServerOrigin }}/documentation/domains/#zone-name" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<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="{{ webServerOrigin }}/documentation/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<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="{{ webServerOrigin }}/documentation/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<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>
@@ -241,7 +287,7 @@
</div>
<br/>
<div class="row">
<div class="col-md-12 text-center"><small>Looking to <a href="/restore.html">restore?</a></small></div>
<div class="col-md-12 text-center"><small>Looking to <a ng-href="/restore.html{{ search }}">restore?</a></small></div>
</div>
</form>
</div>
@@ -250,7 +296,7 @@
</div>
<footer class="text-center">
<span class="text-muted">&copy;2020 <a href="https://cloudron.io" target="_blank">Cloudron</a></span>
<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>
+283
View File
@@ -0,0 +1,283 @@
<!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">
<!-- Fontawesome -->
<link type="text/css" rel="stylesheet" href="<%= dashboardOrigin %>/3rdparty/fontawesome/css/all.css"/>
<style>
html, body {
height: 100%;
width: 100%;
padding: 0;
font-family: Roboto,Helvetica,Arial,sans-serif;
font-size: 14px;
line-height: 1.42857;
color: #333;
background-color: #e5e5e5;
margin: 0;
}
.layout-root {
display: flex;
flex-direction: column;
height: 100%;
max-height: 100%;
width: 100%;
}
*, ::after, ::before {
box-sizing: border-box;
}
.layout-content {
flex-grow: 1;
overflow: auto;
}
.has-error {
color: red;
}
.center {
text-align: center;
}
.right {
text-align: right;
}
.row {
margin-right: -15px;
margin-left: -15px;
}
.col-md-12 {
width: 100%;
float: left;
position: relative;
min-height: 1px;
padding-right: 15px;
padding-left: 15px;
}
.form-group {
margin-bottom: 15px;
}
label {
display: inline-block;
max-width: 100%;
margin-bottom: 5px;
font-weight: 700;
}
.form-control {
display: block;
margin: 0;
font-size: 14px;
line-height: 1.42857;
color: #555;
width: 100%;
height: 34px;
padding: 6px 12px;
background-color: #fff;
background-image: none;
border: 1px solid #ccc;
border-radius: 2px;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075);
transition: border-color .15s ease-in-out,box-shadow .15s ease-in-out;
}
.form-control:focus {
border-color: #66afe9;
outline: 0;
box-shadow: inset 0 1px 1px rgba(0,0,0,.075),0 0 8px rgba(102,175,233,.6);
}
h1 {
font-size: 36px;
font-weight: 500;
line-height: 1.1;
margin-top: 20px;
margin-bottom: 10px;
}
small {
font-size: 65%;
font-weight: 400;
line-height: 1;
color: #777;
}
.card {
display: flex;
flex-direction: column;
justify-content: center;
background-color: white;
padding: 20px;
margin-top: 100px;
max-width: 620px;
position: relative;
background-color: #fff;
margin: 0 auto;
box-shadow: 0 2px 5px rgba(0,0,0,.1);
}
.btn.pull-right {
margin-left: 5px;
}
.pull-right {
float: right !important;
}
.btn-primary {
color: #fff;
background-color: #2196f3;
border-color: #2196f3;
}
.btn {
display: inline-block;
margin-bottom: 0;
font-weight: 400;
text-align: center;
white-space: nowrap;
vertical-align: middle;
touch-action: manipulation;
cursor: pointer;
background-image: none;
border: 1px solid transparent;
padding: 6px 12px;
font-size: 14px;
line-height: 1.42857;
border-radius: 2px;
user-select: none;
}
.btn-primary:hover {
color: #fff;
background-color: #0c7cd5;
border-color: #0b76cc;
}
.btn-primary.active.focus, .btn-primary.active:focus, .btn-primary.active:hover, .btn-primary:active.focus, .btn-primary:active:focus, .btn-primary:active:hover, .open > .btn-primary.dropdown-toggle.focus, .open > .btn-primary.dropdown-toggle:focus, .open > .btn-primary.dropdown-toggle:hover {
color: #fff;
background-color: #0a68b4;
border-color: #08528d;
}
</style>
</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>
+142 -132
View File
@@ -1,161 +1,171 @@
<!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" />
<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" />
<!-- this gets changed once we get the config (because angular has not loaded yet, we see template string for a flash) -->
<title> Terminal </title>
<title> Terminal </title>
<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">
<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"/>
<link type="text/css" rel="stylesheet" href="/3rdparty/xterm/xterm.css">
<link type="text/css" rel="stylesheet" href="/theme.css">
<!-- CSS -->
<link type="text/css" rel="stylesheet" href="/3rdparty/angular-ui-notification.css?<%= revision %>"/>
<link type="text/css" rel="stylesheet" href="/theme.css?<%= revision %>">
<!-- Custom Fonts -->
<link type="text/css" rel="stylesheet" href="/3rdparty/fontawesome/css/all.min.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>
<!-- jQuery-->
<script type="text/javascript" src="/3rdparty/js/jquery.min.js?<%= revision %>"></script>
<!-- Bootstrap Core JavaScript -->
<script type="text/javascript" src="/3rdparty/js/bootstrap.min.js"></script>
<!-- async -->
<script type="text/javascript" src="/3rdparty/js/async-3.2.0.min.js?<%= revision %>"></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-animate.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-base64.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-md5.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-sanitize.min.js"></script>
<script type="text/javascript" src="/3rdparty/js/angular-ui-notification.js"></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"></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>
<!-- Clipboard handling -->
<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>
<!-- xterm -->
<script type="text/javascript" src="/3rdparty/xterm/xterm.js"></script>
<script type="text/javascript" src="/3rdparty/xterm/addons/attach/attach.js"></script>
<script type="text/javascript" src="/3rdparty/xterm/addons/fit/fit.js"></script>
<!-- Clipboard handling -->
<script type="text/javascript" src="/3rdparty/js/clipboard.min.js?<%= revision %>"></script>
<!-- Main Application -->
<script type="text/javascript" src="/js/terminal.js"></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">Download from {{ 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">Path to file or directory</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">Cancel</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> Download</button>
</div>
</div>
<!-- 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 | prettyByteSize }}</b> (total {{ uploadProgress.total | prettyByteSize }})</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>
<!-- 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">Uploading file to {{ selected.name }}</h4>
</div>
<div class="modal-body">
<span><b>{{ (uploadProgress.current/1000/1000).toFixed(2) }}MB</b> (total {{ (uploadProgress.total/1000/1000).toFixed(2) }}MB)</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 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="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">
Scheduler/Cron <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> Restart</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> Upload to /tmp</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> Uploading...</button>
<button class="btn btn-primary" ng-click="downloadFile.show()" ng-show="selected.type === 'app'" ng-disabled="appBusy"><i class="fa fa-download"></i> Download</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">Restarting app...</span>
<span ng-show="selectedAppInfo.installationState === 'pending_debug' && selectedAppInfo.debugMode">Restarting app in paused mode...</span>
<span ng-show="selectedAppInfo.installationState === 'pending_debug' && !selectedAppInfo.debugMode ">App is being resumed...</span>
<span ng-show="selectedAppInfo.installationState === 'pending_installed'">App is being installed...</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()">Copy</a></li>
<li class="disabled"><a>For Paste use Ctrl+v</a></li>
<li role="separator" class="divider"></li>
<li><a href="" ng-click="terminalClear()">Clear</a></li>
</ul>
</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>
+731 -128
View File
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
-419
View File
@@ -1,419 +0,0 @@
'use strict';
/* global angular:false */
/* global $:false */
angular.module('Application').controller('ActivityController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.config = Client.getConfig();
$scope.busy = false;
$scope.eventLogs = [];
$scope.activeEventLog = null;
// TODO sync this with the eventlog filter
$scope.actions = [
{ name: '-- All app events --', value: 'app.' },
{ name: '-- All user events --', value: 'user.' },
{ name: 'app.configure', value: 'app.configure' },
{ name: 'app.install', value: 'app.install' },
{ name: 'app.restore', value: 'app.restore' },
{ name: 'app.uninstall', value: 'app.uninstall' },
{ name: 'app.update', value: 'app.update' },
{ name: 'app.update.finish', value: 'app.update.finish' },
{ name: 'app.login', value: 'app.login' },
{ name: 'app.oom', value: 'app.oom' },
{ name: 'app.down', value: 'app.down' },
{ name: 'app.up', value: 'app.up' },
{ 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' },
{ name: 'backup.start', value: 'backup.start' },
{ name: 'certificate.new', value: 'certificate.new' },
{ name: 'certificate.renew', value: 'certificate.renew' },
{ name: 'cloudron.activate', value: 'cloudron.activate' },
{ name: 'cloudron.provision', value: 'cloudron.provision' },
{ name: 'cloudron.restore', value: 'cloudron.restore' },
{ name: 'cloudron.start', value: 'cloudron.start' },
{ name: 'cloudron.update', value: 'cloudron.update' },
{ name: 'cloudron.update.finish', value: 'cloudron.update.finish' },
{ name: 'dashboard.domain.update', value: 'dashboard.domain.update' },
{ 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: 'mail.enabled', value: 'mail.enabled' },
{ name: 'mail.box.add', value: 'mail.box.add' },
{ name: 'mail.box.update', value: 'mail.box.update' },
{ name: 'mail.box.remove', value: 'mail.box.remove' },
{ name: 'mail.list.add', value: 'mail.list.add' },
{ name: 'mail.list.update', value: 'mail.list.update' },
{ name: 'mail.list.remove', value: 'mail.list.remove' },
{ name: 'support.ticket', value: 'support.ticket' },
{ name: 'support.ssh', value: 'support.ssh' },
{ name: 'user.add', value: 'user.add' },
{ name: 'user.login', value: 'user.login' },
{ name: 'user.remove', value: 'user.remove' },
{ name: 'user.transfer', value: 'user.transfer' },
{ name: 'user.update', value: 'user.update' },
{ name: 'System Crash', value: 'system.crash' }
];
$scope.pageItemCount = [
{ name: 'Show 20 per page', value: 20 },
{ name: 'Show 50 per page', value: 50 },
{ name: 'Show 100 per page', value: 100 }
];
$scope.currentPage = 1;
$scope.pageItems = $scope.pageItemCount[0];
$scope.action = '';
$scope.selectedActions = [];
$scope.search = '';
function eventLogDetails(eventLog) {
var ACTION_ACTIVATE = 'cloudron.activate';
var ACTION_PROVISION = 'cloudron.provision';
var ACTION_RESTORE = 'cloudron.restore';
var ACTION_APP_CLONE = 'app.clone';
var ACTION_APP_REPAIR = 'app.repair';
var ACTION_APP_CONFIGURE = 'app.configure';
var ACTION_APP_INSTALL = 'app.install';
var ACTION_APP_RESTORE = 'app.restore';
var ACTION_APP_UNINSTALL = 'app.uninstall';
var ACTION_APP_UPDATE = 'app.update';
var ACTION_APP_UPDATE_FINISH = 'app.update.finish';
var ACTION_APP_LOGIN = 'app.login';
var ACTION_APP_OOM = 'app.oom';
var ACTION_APP_UP = 'app.up';
var ACTION_APP_DOWN = 'app.down';
var ACTION_BACKUP_FINISH = 'backup.finish';
var ACTION_BACKUP_START = 'backup.start';
var ACTION_BACKUP_CLEANUP_START = 'backup.cleanup.start';
var ACTION_BACKUP_CLEANUP_FINISH = 'backup.cleanup.finish';
var ACTION_CERTIFICATE_NEW = 'certificate.new';
var ACTION_CERTIFICATE_RENEWAL = 'certificate.renew';
var ACTION_DASHBOARD_DOMAIN_UPDATE = 'dashboard.domain.update';
var ACTION_DOMAIN_ADD = 'domain.add';
var ACTION_DOMAIN_UPDATE = 'domain.update';
var ACTION_DOMAIN_REMOVE = 'domain.remove';
var ACTION_START = 'cloudron.start';
var ACTION_UPDATE = 'cloudron.update';
var ACTION_UPDATE_FINISH = 'cloudron.update.finish';
var ACTION_USER_ADD = 'user.add';
var ACTION_USER_LOGIN = 'user.login';
var ACTION_USER_REMOVE = 'user.remove';
var ACTION_USER_UPDATE = 'user.update';
var ACTION_USER_TRANSFER = 'user.transfer';
var ACTION_MAIL_ENABLED = 'mail.enabled';
var ACTION_MAIL_DISABLED = 'mail.disabled';
var ACTION_MAIL_MAILBOX_ADD = 'mail.box.add';
var ACTION_MAIL_MAILBOX_UPDATE = 'mail.box.update';
var ACTION_MAIL_MAILBOX_REMOVE = 'mail.box.remove';
var ACTION_MAIL_LIST_ADD = 'mail.list.add';
var ACTION_MAIL_LIST_UPDATE = 'mail.list.update';
var ACTION_MAIL_LIST_REMOVE = 'mail.list.remove';
var ACTION_SUPPORT_TICKET = 'support.ticket';
var ACTION_SUPPORT_SSH = 'support.ssh';
var ACTION_DYNDNS_UPDATE = 'dyndns.update';
var ACTION_SYSTEM_CRASH = 'system.crash';
var data = eventLog.data;
var errorMessage = data.errorMessage;
var details;
switch (eventLog.action) {
case ACTION_ACTIVATE:
return 'Cloudron was activated';
case ACTION_PROVISION:
return 'Cloudron was setup';
case ACTION_RESTORE:
return 'Cloudron was restored using backup ' + data.backupId;
case ACTION_APP_CONFIGURE:
if (!data.app) return '';
var q = function (x) {
return '"' + x + '"';
};
var name = (data.app.label || data.app.fqdn || data.app.location) + ' (' + data.app.manifest.title + ')';
if ('accessRestriction' in data) { // since it can be null
return 'Access restriction of ' + name + ' was changed';
} else if (data.label) {
return 'Label of ' + name + ' was set to ' + q(data.label);
} else if (data.tags) {
return 'Tags of ' + name + ' was set to ' + q(data.tags.join(','));
} else if (data.icon) {
return 'Icon of ' + name + ' was changed';
} else if (data.memoryLimit) {
return 'Memory limit of ' + name + ' was set to ' + data.memoryLimit;
} else if (data.env) {
return 'Env vars of ' + name + ' was changed';
} else if ('debugMode' in data) { // since it can be null
if (data.debugMode) {
return name + ' was placed in repair mode';
} else {
return name + ' was taken out of repair mode';
}
} else if (('mailboxName' in data) && data.mailboxName !== data.app.mailboxName) {
if (data.mailboxName) {
return 'Mailbox of ' + name + ' was set to ' + q(data.mailboxName);
} else {
return 'Mailbox of ' + name + ' was reset';
}
} else if ('enableBackup' in data) {
return 'Automatic backups of ' + name + ' was ' + (data.enableBackup ? 'enabled' : 'disabled');
} else if ('enableAutomaticUpdate' in data) {
return 'Automatic updates of ' + name + ' was ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
} else if ('reverseProxyConfig' in data) {
return 'Reverse proxy configuration of ' + name + ' was updated';
} else if ('cert' in data) {
if (data.cert) {
return 'Custom certificate was set for ' + name;
} else {
return 'Certificate of ' + name + ' was reset';
}
} else if (data.location) {
if (data.fqdn !== data.app.fqdn) {
return 'Location of ' + name + ' was changed to ' + data.fqdn;
} else if (!angular.equals(data.alternateDomains, data.app.alternateDomains)) {
var altFqdns = data.alternateDomains.map(function (a) { return a.fqdn; });
return 'Alternate domains of ' + name + ' was ' + (altFqdns.length ? 'set to ' + altFqdns.join(', ') : 'reset');
} else if (!angular.equals(data.portBindings, data.app.portBindings)) {
return 'Port bindings of ' + name + ' was changed';
}
} else if ('dataDir' in data) {
if (data.dataDir) {
return 'Data directory of ' + name + ' was set ' + data.dataDir;
} else {
return 'Data directory of ' + name + ' was reset';
}
} else if ('icon' in data) {
if (data.icon) {
return 'Icon of ' + name + ' was set';
} else {
return 'Icon of ' + name + ' was reset';
}
}
return data.app.manifest.title + ' was re-configured at ' + (data.app.fqdn || data.app.location);
case ACTION_APP_INSTALL:
if (!data.app) return '';
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was installed at ' + (data.app.fqdn || data.app.location);
case ACTION_APP_RESTORE:
if (!data.app) return '';
details = data.app.manifest.title + ' was restored at ' + (data.app.fqdn || data.app.location);
// older versions (<3.5) did not have these fields
if (data.fromManifest) details += ' from version ' + data.fromManifest.version;
if (data.toManifest) details += ' to version ' + data.toManifest.version;
if (data.backupId) details += ' using backup ' + data.backupId;
return details;
case ACTION_APP_UNINSTALL:
if (!data.app) return '';
return data.app.manifest.title + ' (package v' + data.app.manifest.version + ') was uninstalled at ' + (data.app.fqdn || data.app.location);
case ACTION_APP_UPDATE:
if (!data.app) return '';
return 'Update of ' + data.app.manifest.title + ' at ' + (data.app.fqdn || data.app.location) + ' started from v' + data.fromManifest.version + ' to v' + data.toManifest.version;
case ACTION_APP_UPDATE_FINISH:
if (!data.app) return '';
return data.app.manifest.title + ' at ' + (data.app.fqdn || data.app.location) + ' was updated to v' + data.app.manifest.version;
case ACTION_APP_CLONE:
return data.newApp.manifest.title + ' at ' + (data.newApp.fqdn || data.newApp.location) + ' was cloned from ' + (data.oldApp.fqdn || data.oldApp.location) + ' using backup ' + data.backupId + ' with v' + data.oldApp.manifest.version;
case ACTION_APP_REPAIR:
return 'App at ' + data.app.fqdn + ' was repaired';
case ACTION_APP_LOGIN:
var app = Client.getCachedAppSync(data.appId);
if (!app) return '';
return 'App ' + app.fqdn + ' logged in';
case ACTION_APP_OOM:
if (!data.app) return '';
return data.app.manifest.title + ' ran out of memory';
case ACTION_APP_DOWN:
if (!data.app) return '';
return data.app.manifest.title + ' is down';
case ACTION_APP_UP:
if (!data.app) return '';
return data.app.manifest.title + ' is back online';
case ACTION_BACKUP_START:
return 'Backup started';
case ACTION_BACKUP_FINISH:
if (!errorMessage) {
return 'Cloudron backup created with Id ' + data.backupId;
} else {
return 'Cloudron backup errored with error: ' + errorMessage;
}
case ACTION_BACKUP_CLEANUP_START:
return 'Backup cleaner started';
case ACTION_BACKUP_CLEANUP_FINISH:
return data.errorMessage ? 'Backup cleaner errored: ' + data.errorMessage : 'Backup cleaner removed ' + data.removedBoxBackups.length + ' backups';
case ACTION_CERTIFICATE_NEW:
return 'Certificate install for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
case ACTION_CERTIFICATE_RENEWAL:
return 'Certificate renewal for ' + data.domain + (errorMessage ? ' failed' : ' succeeded');
case ACTION_DASHBOARD_DOMAIN_UPDATE:
return 'Dashboard domain set to ' + data.fqdn;
case ACTION_DOMAIN_ADD:
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was added';
case ACTION_DOMAIN_UPDATE:
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was updated';
case ACTION_DOMAIN_REMOVE:
return 'Domain ' + data.domain + ' was removed';
case ACTION_MAIL_ENABLED:
return 'Cloudron Mail was enabled for domain ' + data.domain;
case ACTION_MAIL_DISABLED:
return 'Cloudron Mail was disabled for domain ' + data.domain;
case ACTION_MAIL_MAILBOX_ADD:
return 'Mailbox with name ' + data.name + ' was added in domain ' + data.domain;
case ACTION_MAIL_MAILBOX_UPDATE:
return 'Mailbox with name ' + data.name + ' was updated in domain ' + data.domain;
case ACTION_MAIL_MAILBOX_REMOVE:
return 'Mailbox with name ' + data.name + ' was removed in domain ' + data.domain;
case ACTION_MAIL_LIST_ADD:
return 'Mail list with name ' + data.name + ' was added in domain ' + data.domain;
case ACTION_MAIL_LIST_UPDATE:
return 'Mail list with name ' + data.name + ' was updated in domain ' + data.domain;
case ACTION_MAIL_LIST_REMOVE:
return 'Mail list with name ' + data.name + ' was removed in domain ' + data.domain;
case ACTION_START:
return 'Cloudron started with version ' + data.version;
case ACTION_UPDATE:
return 'Cloudron update to version ' + data.boxUpdateInfo.version + ' was started';
case ACTION_UPDATE_FINISH:
return 'Cloudron updated to version ' + data.newVersion;
case ACTION_USER_ADD:
return data.email + (data.user.username ? ' (' + data.user.username + ')' : '') + ' was added';
case ACTION_USER_UPDATE:
return (data.user ? (data.user.email + (data.user.username ? ' (' + data.user.username + ')' : '')) : data.userId) + ' was updated';
case ACTION_USER_REMOVE:
return (data.user ? (data.user.email + (data.user.username ? ' (' + data.user.username + ')' : '')) : data.userId) + ' was removed';
case ACTION_USER_TRANSFER:
return 'Apps of ' + data.oldOwnerId + ' was transferred to ' + data.newOwnerId;
case ACTION_USER_LOGIN:
return (data.user ? data.user.username : data.userId) + ' logged in';
case ACTION_DYNDNS_UPDATE:
return 'DNS was updated from ' + data.fromIp + ' to ' + data.toIp;
case ACTION_SUPPORT_SSH:
return 'Remote Support was ' + (data.enable ? 'enabled' : 'disabled');
case ACTION_SUPPORT_TICKET:
return 'Support ticket was created';
case ACTION_SYSTEM_CRASH:
return 'A system process crashed';
default: return eventLog.action;
}
}
function eventLogSource(eventLog) {
var source = eventLog.source;
var line = '';
line = source.username || source.userId || source.mailboxId || source.authType || 'system';
if (source.appId) {
var app = Client.getCachedAppSync(source.appId);
line += ' - ' + (app ? app.fqdn : source.appId);
} else if (source.ip) {
line += ' - ' + source.ip;
}
return line;
}
function fetchEventLogs() {
$scope.busy = true;
var actions = $scope.selectedActions.map(function (a) { return a.value; }).join(', ');
Client.getEventLogs(actions, $scope.search || null, $scope.currentPage, $scope.pageItems.value, function (error, result) {
$scope.busy = false;
if (error) return console.error(error);
$scope.eventLogs = [];
result.forEach(function (e) {
$scope.eventLogs.push({ raw: e, details: eventLogDetails(e), source: eventLogSource(e) });
});
});
}
$scope.showNextPage = function () {
$scope.currentPage++;
fetchEventLogs();
};
$scope.showPrevPage = function () {
if ($scope.currentPage > 1) $scope.currentPage--;
else $scope.currentPage = 1;
fetchEventLogs();
};
$scope.updateFilter = function (fresh) {
if (fresh) $scope.currentPage = 1;
fetchEventLogs();
};
$scope.showEventLogDetails = function (eventLog) {
if ($scope.activeEventLog === eventLog) $scope.activeEventLog = null;
else $scope.activeEventLog = eventLog;
};
Client.onReady(function () {
fetchEventLogs();
});
$('.modal-backdrop').remove();
}]);
+976 -558
View File
File diff suppressed because it is too large Load Diff
+845 -234
View File
File diff suppressed because it is too large Load Diff
+47 -49
View File
@@ -6,69 +6,63 @@
<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">Package <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
<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}}">Documentation</a> </span>
<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-bind-html="appPostInstallConfirm.app.manifest.postInstallMessage | postInstallMessage:appPostInstallConfirm.app | markdown2html"></div>
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl">
Please see the <a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">documentation</a> for more information.
</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">Acknowledge instructions</label>
<label class="control-label" for="appPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
</div>
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">Open {{ appPostInstallConfirm.app.manifest.title }}</a>
<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>
</div>
</div>
</div>
</div>
<script>
function imageErrorHandler(elem) {
'use strict';
elem.src = elem.getAttribute('fallback-icon');
elem.onerror = null; // avoid retry after default icon cannot be loaded
}
</script>
<div class="content content-large">
<!-- Workaround for select-all issue, see commit message -->
<div style="font-size: 1px;">&nbsp;</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length === 0 && user.isAtLeastAdmin">
<div class="col-md-12" style="text-align: center;">
<br/><br/><br/><br/>
<h1><i class="fa fa-cloud-download fa-fw"></i> No apps installed yet!</h1>
<br/></br>
<h3>How about installing some? Check out the <a href="#/appstore">App Store</a></h3>
</div>
<div class="col-md-12" style="text-align: center;">
<br/><br/><br/><br/>
<h1><i class="fa fa-cloud-download fa-fw"></i> {{ 'apps.noApps.title' | tr }}</h1>
<br/></br>
<h3 ng-bind-html="'apps.noApps.description' | tr:{ appStoreLink: '#/appstore' }"></h3>
</div>
</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length === 0 && !user.isAtLeastAdmin">
<div class="col-md-12" style="text-align: center;">
<br/><br/><br/><br/>
<h1>You don't have access to any apps yet!</h1>
<br/></br>
<h3>Once you do, they will show up here.</h3>
</div>
<div class="col-md-12" style="text-align: center;">
<br/><br/><br/><br/>
<h1>{{ 'apps.noAccess.title' | tr }}</h1>
<br/></br>
<h3>{{ 'apps.noAccess.description' | tr }}</h3>
</div>
</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<h1 class="view-header">
Your Apps
{{ 'apps.title' | tr }}
<div class="pull-right">
<form class="form-inline">
<input type="text" class="form-control" placeholder="Search Apps" id="appSearch" ng-model="appSearch" ng-show="installedApps.length > 10"/>
<multiselect ng-model="selectedTags" ng-show="tags.length > 0" ms-header="All Tags" ms-selected="Tags: {{ selectedTags.join(', ') }}" options="tag for tag in tags" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<input type="text" class="form-control" ng-show="installedApps.length > 8" placeholder="{{ 'apps.searchPlaceholder' | tr }}" id="appSearch" ng-model="appSearch"/>
<multiselect ng-model="selectedGroup" ng-show="user.isAtLeastAdmin && installedApps.length > 1 && 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 && installedApps.length > 1" 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" ng-show="filterDomains.length > 2" 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>
</form>
</div>
@@ -77,42 +71,46 @@
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="app-grid">
<div class="grid-item" ng-repeat="app in installedApps | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:'location'">
<a ng-href="{{ app | applicationLink }}" ng-click="user.isAtLeastAdmin && (((app | installError) === true || (app | installationActive) === true) && showAppConfigure(app, 'repair')) || ((app | appIsInstalledAndHealthy) && app.pendingPostInstallConfirmation && appPostInstallConfirm.show(app))" target="_blank" ng-class="{ 'hand': (app | appIsInstalledAndHealthy) || (app | installError) || (app | installationActive)}">
<div style="background-color: white;" class="highlight grid-item-content" uib-tooltip="{{ app.fqdn }}" tooltip-class="long nowrap">
<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'" ng-class="{ 'admin-action': app.manifest.configurePath && (app | applicationLink) }">
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}">
<a ng-show="isOperator(app)" ng-href="#/app/{{ app.id}}/display" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
<a ng-href="{{ app | applicationLink }}" ng-click="isOperator(app) && (((app | installError) === true || (app | installationActive) === true) && showAppConfigure(app, 'repair')) || ((app | appIsInstalledAndHealthy) && app.pendingPostInstallConfirmation && appPostInstallConfirm.show(app))" target="_blank" ng-class="{ 'hand': (app | appIsInstalledAndHealthy) || (app | installError) || (app | installationActive)}">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12 text-center" style="padding-left: 5px; padding-right: 5px;">
<br/>
<img ng-src="{{ app.iconUrl || 'img/appicon_fallback.png' }}" fallback-icon="img/appicon_fallback.png" onerror="imageErrorHandler(this)" class="app-icon"/>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-12 text-center">
<div class="grid-item-top-title" data-fittext>{{ app.label || app.location || app.fqdn }}</div>
<div class="text-muted status" style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden" uib-tooltip="{{ app | appProgressMessage }}" tooltip-class="long nowrap">
{{ app | installationStateLabel:user }}
<div class="grid-item-top-title" data-fittext>{{ app.label || app.subdomain || app.fqdn }}</div>
<div class="text-muted status" style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden" uib-tooltip="{{ app | appProgressMessage }}">
{{ app | installationStateLabel }}
</div>
<div class="status" ng-style="{ 'visibility': user.isAtLeastAdmin && (app | installationActive) ? 'visible' : 'hidden' }">
<div class="status" ng-style="{ 'visibility': isOperator(app) && (app | installationActive) ? 'visible' : 'hidden' }">
<div class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ app.progress }}%"></div>
</div>
</div>
</div>
</div>
<div class="usermanagement-indicator" ng-hide="user.isAtLeastAdmin">
<i class="fas fa-cog" ng-show="isOperator(app)"></i>
<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="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>
<div class="grid-item-actions" ng-show="user.isAtLeastAdmin">
<a ng-href="#/app/{{ app.id}}/display" class="scale"><i class="fas fa-cogs"></i></a>
</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.apps[app.id].manifest.version && config.update.apps[app.id].manifest.version !== app.manifest.version && (app | installSuccess)">
<i class="fa fa-arrow-up fa-inverse"></i>
</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)">
<i class="fa fa-arrow-up fa-inverse"></i>
</div>
</a>
</div>
</div>
</div>
</div>
+73 -19
View File
@@ -3,18 +3,37 @@
/* global angular:false */
/* global $:false */
angular.module('Application').controller('AppsController', ['$scope', '$timeout', '$interval', '$location', 'Client', function ($scope, $timeout, $interval, $location, Client) {
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
var GROUP_ACCESS_UNSET = { _unset: true, name: 'Select Group' }; // dummy record for the single select filter
$scope.installedApps = Client.getInstalledApps();
$scope.tags = Client.getAppTags();
$scope.states = [
{ state: '', label: 'All States' },
{ state: 'running', label: 'Running' },
{ state: 'stopped', label: 'Stopped' },
{ state: 'not_responding', label: 'Not Responding' }
];
$scope.selectedState = $scope.states[0];
$scope.selectedTags = [];
$scope.selectedGroup = GROUP_ACCESS_UNSET;
$scope.selectedDomain = ALL_DOMAINS_DOMAIN;
$scope.filterDomains = [ ALL_DOMAINS_DOMAIN ];
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.domains = [];
$scope.appSearch = '';
$scope.groups = [ GROUP_ACCESS_UNSET ];
$translate(['apps.stateFilterHeader', 'apps.domainsFilterHeader', 'apps.groupsFilterHeader', 'app.states.running', 'app.states.stopped', 'app.states.notResponding']).then(function (tr) {
if (tr['apps.domainsFilterHeader']) ALL_DOMAINS_DOMAIN.domain = tr['apps.domainsFilterHeader'];
if (tr['apps.groupsFilterHeader']) GROUP_ACCESS_UNSET.name = tr['apps.groupsFilterHeader'];
if (tr['apps.stateFilterHeader']) $scope.states[0].label = tr['apps.stateFilterHeader'];
if (tr['app.states.running']) $scope.states[1].label = tr['app.states.running'];
if (tr['app.states.stopped']) $scope.states[2].label = tr['app.states.stopped'];
if (tr['app.states.notResponding']) $scope.states[3].label = tr['app.states.notResponding'];
});
$scope.$watch('selectedTags', function (newVal, oldVal) {
if (newVal === oldVal) return;
@@ -22,6 +41,20 @@ angular.module('Application').controller('AppsController', ['$scope', '$timeout'
localStorage.selectedTags = newVal.join(',');
});
$scope.$watch('selectedState', function (newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal === $scope.states[0]) localStorage.removeItem('selectedState');
else localStorage.selectedState = newVal.state;
});
$scope.$watch('selectedGroup', function (newVal, oldVal) {
if (newVal === oldVal) return;
if (newVal === GROUP_ACCESS_UNSET) localStorage.removeItem('selectedGroup');
else localStorage.selectedGroup = newVal.id;
});
$scope.$watch('selectedDomain', function (newVal, oldVal) {
if (newVal === oldVal) return;
@@ -58,32 +91,53 @@ angular.module('Application').controller('AppsController', ['$scope', '$timeout'
$location.path('/app/' + app.id + '/' + view);
};
$scope.isOperator = function (app) {
return app.accessLevel === 'operator' || app.accessLevel === 'admin';
};
Client.onReady(function () {
setTimeout(function () { $('#appSearch').focus(); }, 1);
// refresh the new list immediately when switching from another view (appstore)
Client.refreshInstalledApps(function () {
var refreshAppsTimer = $interval(Client.refreshInstalledApps.bind(Client, function () {}), 5000);
$scope.$on('$destroy', function () {
$interval.cancel(refreshAppsTimer);
});
if (!$scope.user.isAtLeastAdmin) return;
Client.getDomains(function (error, result) {
if (error) Client.error(error);
$scope.domains = result;
$scope.filterDomains = [ALL_DOMAINS_DOMAIN].concat(result);
// load local settings and apply
if (localStorage.selectedTags) {
if (!$scope.tags.length) localStorage.removeItem('selectedTags');
else $scope.selectedTags = localStorage.selectedTags.split(',');
}
if (localStorage.selectedDomain) $scope.selectedDomain = $scope.filterDomains.find(function (d) { return d.domain === localStorage.selectedDomain; }) || ALL_DOMAINS_DOMAIN;
setTimeout(function () { $('#appSearch').focus(); }, 1000);
});
});
if (!$scope.user.isAtLeastAdmin) return;
// load local settings and apply tag filter
if (localStorage.selectedTags) {
if (!$scope.tags.length) localStorage.removeItem('selectedTags');
else $scope.selectedTags = localStorage.selectedTags.split(',');
}
if (localStorage.selectedState) $scope.selectedState = $scope.states.find(function (s) { return s.state === localStorage.selectedState; }) || $scope.states[0];
Client.getGroups(function (error, result) {
if (error) Client.error(error);
$scope.groups = [ GROUP_ACCESS_UNSET ].concat(result);
if (localStorage.selectedGroup) $scope.selectedGroup = $scope.groups.find(function (g) { return g.id === localStorage.selectedGroup; }) || GROUP_ACCESS_UNSET;
});
Client.getDomains(function (error, result) {
if (error) Client.error(error);
$scope.domains = result;
$scope.filterDomains = [ALL_DOMAINS_DOMAIN].concat(result);
if (localStorage.selectedDomain) $scope.selectedDomain = $scope.filterDomains.find(function (d) { return d.domain === localStorage.selectedDomain; }) || ALL_DOMAINS_DOMAIN;
});
});
$('.collapse').on('shown.bs.collapse', function(){
$(this).parent().find('.fa-angle-right').removeClass('fa-angle-right').addClass('fa-angle-down');
}).on('hidden.bs.collapse', function(){
$(this).parent().find('.fa-angle-down').removeClass('fa-angle-down').addClass('fa-angle-right');
});
$('.modal-backdrop').remove();
+211 -188
View File
@@ -4,27 +4,26 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appInstall.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-icon"/>
<h3 class="appstore-install-title" title="Version {{ appInstall.app.manifest.version }}">{{ appInstall.app.manifest.title }} <span class="badge badge-danger" ng-show="appInstall.app.publishState === 'testing'">Testing</span></h3>
<img ng-src="{{appInstall.app.iconUrl}}" onerror="this.onerror=null; this.src='img/appicon_fallback.png'" class="app-icon"/>
<h3 class="appstore-install-title">{{ appInstall.app.manifest.title }}</h3>
<br/>
<span class="appstore-install-meta"><a href="{{ appInstall.app.manifest.website }}" target="_blank">{{ appInstall.app.manifest.author }}</a></span>
<br/>
<span class="appstore-install-meta">Last updated {{ appInstall.app.creationDate | prettyDate }}</span>
<span class="appstore-install-meta">{{ 'appstore.installDialog.lastUpdated' | tr:{ date: (appInstall.app.creationDate | prettyDate) } }}</span>
<br/>
<span class="appstore-install-meta hand">Requires atleast {{ appInstall.app.manifest.memoryLimit | prettyMemory }}MB memory</span>
<span class="appstore-install-meta">{{ 'appstore.installDialog.memoryRequirement' | tr:{ size: (appInstall.app.manifest.memoryLimit | prettyByteSize:'256 MB') } }}</span>
</div>
<div class="modal-body">
<div class="collapse" id="collapseInstallForm" data-toggle="false">
<form role="form" name="appInstallForm" ng-submit="appInstall.submit()" autocomplete="off">
<div class="has-error text-center" ng-show="appInstall.error.other" ng-bind-html="appInstall.error.other"></div>
<div class="form-group" ng-class="{ 'has-error': (appInstallForm.location.$dirty && appInstallForm.location.$invalid) || (!appInstallForm.location.$dirty && appInstall.error.location) }">
<label class="control-label" for="appInstallLocationInput">Location</label>
<div ng-show="appInstall.error.location"><small>{{ appInstall.error.location }}</small></div>
<label class="control-label" for="appInstallLocationInput">{{ 'appstore.installDialog.location' | tr }}</label>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="appInstall.location" id="appInstallLocationInput" name="location" placeholder="Leave empty to use bare domain" autofocus>
<input type="text" class="form-control" ng-model="appInstall.subdomain" id="appInstallLocationInput" name="location" placeholder="{{ 'appstore.installDialog.locationPlaceholder' | tr }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>{{ (appInstall.location ? (appInstall.domain.config.hyphenatedSubdomains ? '-' : '.') : '') + appInstall.domain.domain }}</span>
<span>{{ '.' + appInstall.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@@ -34,12 +33,40 @@
</ul>
</div>
</div>
<div ng-show="appInstall.error.location" class="text-small">{{ appInstall.error.location }}</div>
</div>
<p class="text-center" ng-show="appInstall.location && appInstall.domain.provider === 'manual'">
<b>Add an A record manually for {{ appInstall.location }} to this Cloudron's public IP</b>
<br>
</p>
<p class="text-small text-warning" ng-show="appInstall.domain.provider === 'noop' || appInstall.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((appInstall.subdomain ? appInstall.subdomain + '.' : '') + appInstall.domain.domain) }"></p>
<div class="has-error text-center" ng-show="appInstall.error.secondaryDomain">{{ appInstall.error.secondaryDomain }}</div>
<div ng-repeat="(env, info) in appInstall.app.manifest.httpPorts">
<ng-form name="secondaryDomainInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!secondaryDomainInfo_form.itemName{{$index}}.$dirty && appInstall.error.secondaryDomain) || (secondaryDomainInfo_form.itemName{{$index}}.$dirty && secondaryDomainInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="secondaryDomainInput{{env}}">
{{ info.title }}
<sup>
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}}"><i class="fa fa-question-circle"></i></a>
</sup>
</label>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="appInstall.secondaryDomains[env].subdomain" name="location{{$index}}" placeholder="{{ 'appstore.installDialog.locationPlaceholder' | tr }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>.{{ appInstall.secondaryDomains[env].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="appInstall.secondaryDomains[env].domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
</ng-form>
</div>
<div class="has-error text-center" ng-show="appInstall.error.port">{{ appInstall.error.port }}</div>
<div ng-repeat="(env, info) in appInstall.portBindingsInfo">
@@ -52,49 +79,51 @@
</sup>
</label>
<input type="number" class="form-control" ng-model="appInstall.portBindings[env]" ng-disabled="!appInstall.portBindingsEnabled[env]" 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>
</div>
<div class="form-group" ng-show="appInstall.customAuth && !appInstall.app.manifest.addons.email">
<label class="control-label">User management</label>
<p>This app has it's own user management.</p>
</div>
<div class="form-group" ng-show="appInstall.app.manifest.addons.email">
<label class="control-label">User management</label>
<p>All users with a mailbox on this Cloudron have access.</p>
<label class="control-label">{{ 'appstore.installDialog.userManagement' | tr }}</label>
<p>{{ 'appstore.installDialog.userManagementMailbox' | tr }}
<span ng-bind-html="'appstore.installDialog.configuredForCloudronEmail' | tr:{ emailDocsLink: 'https://docs.cloudron.io/email/' }">
</p>
</div>
<div class="form-group" ng-show="!appInstall.customAuth && !appInstall.app.manifest.addons.email">
<label class="control-label">User management</label>
<div class="form-group">
<label class="control-label" ng-show="!appInstall.customAuth && !appInstall.app.manifest.addons.email">{{ 'appstore.installDialog.userManagement' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#access-restriction" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label class="control-label" ng-show="appInstall.customAuth || appInstall.app.manifest.addons.email">{{ 'app.accessControl.userManagement.dashboardVisibility' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#dashboard-visibility" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="radio" ng-show="appInstall.optionalSso">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="nosso">
Leave user management to the app
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="nosso"> {{ 'appstore.installDialog.userManagementLeaveToApp' | tr }}
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="any">
Allow all users from this Cloudron
<span ng-show="!appInstall.customAuth">{{ 'appstore.installDialog.userManagementAllUsers' | tr }}</span>
<span ng-show="appInstall.customAuth">{{ 'app.accessControl.userManagement.visibleForAllUsers' | tr }}</span>
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="groups">
Only allow the following users and groups <span class="label label-danger" ng-show="appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()">Select at least one user or group</span>
<span ng-show="!appInstall.customAuth">{{ 'appstore.installDialog.userManagementSelectUsers' | tr }}</span>
<span ng-show="appInstall.customAuth">{{ 'app.accessControl.userManagement.visibleForSelected' | tr }}</span>
<span class="label label-danger" ng-show="appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()">{{ 'appstore.installDialog.errorUserManagementSelectAtLeastOne' | tr }}</span>
</label>
</div>
<div>
<div style="margin-left: 20px;">
<div class="col-md-5">
Users: &nbsp;
{{ 'appstore.installDialog.users' | tr }}: &nbsp;
<multiselect ng-model="appInstall.accessRestriction.users" ng-disabled="appInstall.accessRestrictionOption !== 'groups'" options="user.username for user in users" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
<div class="col-md-5">
Groups: &nbsp;
{{ 'appstore.installDialog.groups' | tr }}: &nbsp;
<multiselect ng-model="appInstall.accessRestriction.groups" ng-disabled="appInstall.accessRestrictionOption !== 'groups'" options="group.name for group in groups" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
@@ -104,14 +133,10 @@
<br/>
</div>
<p ng-show="appInstall.app.manifest.addons.email" class="text-info">
This app is pre-configured for use with <a ng-href="{{ config.webServerOrigin }}/documentation/email/" target="_blank">Cloudron Email</a>.
</p>
<div class="hide">
<label class="control-label" for="appInstallCertificateInput" ng-show="appInstall.domain.provider !== 'caas'">Certificate (optional)</label>
<div class="has-error text-center" ng-show="appInstall.error.cert && appInstall.domain.provider !== 'caas'">{{ appInstall.error.cert }}</div>
<div class="form-group" ng-class="{ 'has-error': !appInstallForm.certificate.$dirty && appInstall.error.cert }" ng-show="appInstall.domain.provider !== 'caas'">
<label class="control-label" for="appInstallCertificateInput">Certificate (optional)</label>
<div class="has-error text-center" ng-show="appInstall.error.cert">{{ appInstall.error.cert }}</div>
<div class="form-group" ng-class="{ 'has-error': !appInstallForm.certificate.$dirty && appInstall.error.cert }">
<div class="input-group">
<input type="file" id="appInstallCertificateFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Certificate" ng-model="appInstall.certificateFileName" id="appInstallCertificateInput" name="certificate" onclick="getElementById('appInstallCertificateFileInput').click();" style="cursor: pointer;" ng-required="appInstall.keyFileName">
@@ -120,7 +145,7 @@
</span>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': !appInstallForm.key.$dirty && appInstall.error.cert }" ng-show="appInstall.domain.provider !== 'caas'">
<div class="form-group" ng-class="{ 'has-error': !appInstallForm.key.$dirty && appInstall.error.cert }">
<div class="input-group">
<input type="file" id="appInstallKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Key" ng-model="appInstall.keyFileName" id="appInstallKeyInput" name="key" onclick="getElementById('appInstallKeyFileInput').click();" style="cursor: pointer;" ng-required="appInstall.certificateFileName">
@@ -131,7 +156,7 @@
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="(appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()) || appInstallForm.$invalid || busy"/>
<input class="ng-hide" type="submit" ng-disabled="(appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()) || !appInstall.accessRestrictionOption || appInstallForm.$invalid || busy"/>
</form>
</div>
<div class="collapse" id="collapseMediaLinksCarousel" data-toggle="false">
@@ -139,24 +164,25 @@
<slick init-onload="true" current-index="0" autoplay="true" arrows="false" autoplay-speed="2000" data="appInstall.mediaLinks" ng-show="appInstall.mediaLinks.length > 1">
<div ng-repeat="mediaLink in appInstall.mediaLinks" class="slick-item" style="background-image: url('{{mediaLink}}');"></div>
</slick>
<br/>
<div class="appstore-install-description">
<div ng-bind-html="appInstall.app.manifest.description | markdown2html"></div>
</div>
</div>
<div class="collapse" id="collapseResourceConstraint" data-toggle="false">
<h4 class="text-danger">This Cloudron is running low on resources.</h4>
<p>Please upgrade to a server instance with more memory. Alternately, free up resources by uninstalling unused applications.</p>
<h4 class="text-danger">{{ 'appstore.installDialog.lowOnResources' | tr }}</h4>
<p>{{ 'appstore.installDialog.pleaseUpgradeServer' | tr }}</p>
</div>
<div class="collapse" id="collapseSubscriptionRequired" data-toggle="false">
<p class="text-bold">A subscription for this Cloudron is required to install more apps.</p>
<p>{{ 'appstore.installDialog.subscriptionRequired' | tr }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" ng-click="openSubscriptionSetup()" ng-show="appInstall.state === 'subscriptionRequired'">Setup Subscription</button>
<button type="button" class="btn btn-danger" ng-show="appInstall.state === 'resourceConstraint'" ng-click="appInstall.showForm(true)">Install anyway</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'appInfo'" ng-click="appInstall.showForm()">Install</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'installForm'" ng-click="appInstall.submit()" ng-disabled="(appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()) || appInstallForm.$invalid || appInstall.busy"><i class="fa fa-circle-notch fa-spin" ng-show="appInstall.busy"></i> Install {{ appInstall.needsOverwrite ? 'and overwrite DNS' : '' }}</button>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="openSubscriptionSetup()" ng-show="appInstall.state === 'subscriptionRequired'">{{ 'appstore.installDialog.setupSubscriptionAction' | tr }}</button>
<button type="button" class="btn btn-danger" ng-show="appInstall.state === 'resourceConstraint'" ng-click="appInstall.showForm(true)">{{ 'appstore.installDialog.installAnywayAction' | tr }}</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'appInfo'" ng-click="appInstall.showForm()">{{ 'appstore.installDialog.installAction' | tr }}</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'installForm'" ng-click="appInstall.submit()" ng-disabled="(appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()) || !appInstall.accessRestrictionOption || appInstallForm.$invalid || appInstall.busy"><i class="fa fa-circle-notch fa-spin" ng-show="appInstall.busy"></i> {{ 'appstore.installDialog.doInstallAction' | tr:{ dnsOverwrite: appInstall.needsOverwrite } }}</button>
</div>
</div>
</div>
@@ -164,166 +190,163 @@
<!-- Modal app not found -->
<div class="modal fade" id="appNotFoundModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">App not found</h4>
</div>
<div class="modal-body">
There is no such app <b>{{ appNotFound.appId }}</b><span ng-show="appNotFound.version"> with version <b>{{ appNotFound.version }}</b></span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">OK</button>
</div>
</div>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'appstore.appNotFoundDialog.title' | tr }}</h4>
</div>
<div class="modal-body" ng-bind-html="'appstore.appNotFoundDialog.description' | tr:{ appId: appNotFound.appId, version: appNotFound.version }"></div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
</div>
</div>
</div>
</div>
<div ng-show="!ready" class="loading-banner">
<h1><i class="fa fa-circle-notch fa-spin"></i></h1>
<h1><i class="fa fa-circle-notch fa-spin"></i></h1>
</div>
<!-- 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">Sign up with Cloudron.io</h1>
<h1 ng-hide="appstoreLogin.register">Login to Cloudron.io</h1>
</div>
<div class="col-md-12 text-center">
<p>This account is used to access the App Store and manage your subscription</p>
</div>
<div class="col-md-12" style="margin-bottom: 10px;">
<small class="text-danger" ng-show="appstoreLogin.error.generic">{{ appstoreLogin.error.generic }}</small>
</div>
<div class="col-md-12">
<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>
</div>
<div class="col-md-12 text-center">
<p>{{ 'appstore.accountDialog.description' | tr }}</p>
</div>
<div class="col-md-12" style="margin-bottom: 10px;">
<small class="text-danger" ng-show="appstoreLogin.error.generic">{{ appstoreLogin.error.generic }}</small>
</div>
<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 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" 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>
</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 }">
<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>
</div>
<div class="form-group" ng-hide="appstoreLogin.register" 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>
</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>
<br/>
<br/>
<form name="appstoreLoginForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<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>
<div class="form-group" ng-class="{ 'has-error': (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">Email</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>
</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 }">
<label class="control-label">Password</label>
<input type="password" class="form-control" ng-model="appstoreLogin.password" id="inputAppstoreLoginPassword" name="password" required>
<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">Wrong password</small>
</div>
</div>
<div class="form-group" ng-hide="appstoreLogin.register" ng-class="{ 'has-error': appstoreLogin.error.totpToken }">
<label class="control-label">2FA Token (if enabled)</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>
</div>
</div>
<div class="form-group">
<label class="control-label">Intended Use</label>
<select class="purpose form-control" ng-model="appstoreLogin.purpose" required>
<option value="" disabled selected hidden>Please choose an option...</option>
<option value="personal_cloud">Personal use</option>
<option value="business_cloud">Business use</option>
<option value="website_hosting">Website hosting</option>
<option value="paas">PaaS - Develop &amp; deploy apps</option>
<option value="single_app">Host only one app</option>
<option value="exploring">Just exploring</option>
</select>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted">I accept the Cloudron <a href="https://cloudron.io/legal/license.html" target="_blank">license</a>
</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">Login</span><span ng-show="appstoreLogin.register">Create Account</span>
</button>
<br/>
<br/>
<a href="" ng-click="appstoreLogin.register = true" ng-hide="appstoreLogin.register">Don't have an account yet? Sign up</a>
<a href="" ng-click="appstoreLogin.register = false" ng-show="appstoreLogin.register">Already have an account? Log in</a>
</center>
</form>
</div>
</form>
</div>
</div>
<!-- give more vertical spacing so the login form does not appear clipped -->
<div ng-show="ready && !validSubscription">
<br/>
<br/>
<br/>
<br/>
</div>
<div ng-show="ready && validSubscription" class="ng-cloak" id="appstoreGrid">
<div class="col-md-2">
<br/>
<div>
<form ng-submit="search()">
<div class="input-group">
<input type="text" id="appstoreSearch" class="form-control" style="height: 40px" placeholder="Search" ng-model="searchString" ng-change="search()" autofocus>
</div>
</form>
</div>
<br/>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'featured' }" category="featured">Popular</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'new' }" category="new">New Apps</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'recent' }" category="recent">Recently Updated</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === '' }" category="">All</a>
<br/>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'analytics' }" category="analytics"><i class="fa fa-chart-line"></i> Analytics</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'blog' }" category="blog"><i class="fa fa-font"></i> Blog</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'chat' }" category="chat"><i class="fa fa-comments"></i> Chat</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'git' }" category="git"><i class="fa fa-code-branch"></i> Code Hosting</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'CRM' }" category="crm"><i class="fab fa-connectdevelop"></i> CRM</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'document' }" category="document"><i class="fa fa-file-word"></i> Documents</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'email' }" category="email"><i class="fa fa-envelope"></i> Email</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'sync' }" category="sync"><i class="fa fa-sync-alt"></i> File Sync</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'finance' }" category="finance"><i class="fa fa-hand-holding-usd"></i> Finance</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'forum' }" category="forum"><i class="fa fa-users"></i> Forum</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'gallery' }" category="gallery"><i class="fa fa-images"></i> Gallery</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'game' }" category="game"><i class="fa fa-gamepad"></i> Games</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'notes' }" category="notes"><i class="fa fa-sticky-note"></i> Notes</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'project' }" category="project"><i class="fas fa-project-diagram"></i> Project Management</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'vpn' }" category="vpn"><i class="fa fa-user-secret"></i> VPN</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'hosting' }" category="hosting"><i class="fa fa-server"></i> Web Hosting</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'wiki' }" category="wiki"><i class="fab fa-wikipedia-w"></i> Wiki</a>
<br/>
<br/>
<a href="https://forum.cloudron.io/category/5/app-requests" target="_blank">Missing an app? Let us know.</a>
</div>
<div class="col-md-10" ng-show="apps.length">
<div class="row-no-margin">
<div class="col-sm-1 appstore-item" ng-repeat="app in apps | orderBy:'installCount':true">
<div class="appstore-item-content highlight" ng-click="gotoApp(app)" ng-class="{ 'appstore-item-content-testing': app.releaseState === 'unstable' }">
<span class="badge badge-danger appstore-item-badge-testing" ng-show="app.releaseState === 'unstable'">Unstable</span>
<div class="appstore-item-content-icon col-same-height">
<img ng-src="{{app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-icon"/>
</div>
<div class="appstore-item-content-description col-same-height">
<h4 class="appstore-item-content-title">{{ app.manifest.title }}</h4>
<div class="appstore-item-content-tagline text-muted">{{ app.manifest.tagline }}</div>
<!-- <div class="appstore-item-rating"><i class="fa fa-star"></i><i class="fa fa-star"></i><i class="fa fa-star"></i><i class="fa fa-star-half-o"></i><i class="fa fa-star-o"></i></div> -->
</div>
</div>
</div>
</div>
</div>
<div class="col-md-10 animateMeOpacity loading-banner" ng-show="!apps.length">
<h3 class="text-muted">No apps found.</h3>
<a href="https://forum.cloudron.io/category/5/app-requests" target="_blank"><h3>Request an app or vote for one in our forum.</h3></a>
<div ng-show="ready && validSubscription" class="ng-cloak appstore-toolbar">
<div class="appstore-toolbar-content">
<button class="btn" type="button" ng-click="showCategory('');" ng-class="{ 'btn-primary': '' === category }">{{ 'appstore.category.all' | tr }}</button>
<button class="btn" type="button" ng-click="showCategory('new');" ng-class="{ 'btn-primary': 'new' === category }">{{ 'appstore.category.newApps' | tr }}</button>
<div class="dropdown">
<button class="btn dropdown-toggle" type="button" data-toggle="dropdown" ng-class="{ 'btn-primary': '' !== category && 'recent' !== category && 'new' !== category }">
{{ categoryButtonLabel(category) }}
<span class="caret"></span>
</button>
<ul class="dropdown-menu">
<li ng-repeat="category in categories | orderBy:'label'"><a href="" ng-click="showCategory(category.id);"><i class="{{ category.icon }} fa-fw"></i> {{ category.label }}</a></li>
</ul>
</div>
<input type="text" id="appstoreSearch" class="form-control" placeholder="{{ 'appstore.searchPlaceholder' | tr }}" ng-model="searchString" ng-change="search()" autofocus>
</div>
</div>
<div ng-show="ready && validSubscription" class="ng-cloak appstore-grid">
<div class="row">
<div class="col-md-12 text-center" ng-hide="apps.length">
<br/>
<br/>
<br/>
<h3 class="text-muted">{{ 'appstore.noAppsFound' | tr }}</h3>
<br/>
<a href="https://forum.cloudron.io/category/5/app-requests" target="_blank">{{ 'appstore.appMissing' | tr }}</a>
</div>
<div class="col-md-12" ng-show="category === '' && popularApps.length">
<div class="row-no-margin">
<div class="col-sm-12">
<h2>{{ 'appstore.category.popular' | tr }}</h2>
</div>
</div>
<div class="row-no-margin">
<div class="col-sm-1 appstore-item" ng-repeat="app in popularApps">
<div class="appstore-item-content highlight" ng-click="gotoApp(app)" ng-class="{ 'appstore-item-content-testing': app.releaseState === 'unstable' }">
<span class="badge badge-danger appstore-item-badge-testing" ng-show="app.releaseState === 'unstable'">{{ 'appstore.unstable' | tr }}</span>
<div class="appstore-item-content-icon col-same-height">
<img ng-src="{{app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-icon"/>
</div>
<div class="appstore-item-content-description col-same-height">
<h4 class="appstore-item-content-title">{{ app.manifest.title }}</h4>
<div class="appstore-item-content-tagline text-muted">{{ app.manifest.tagline }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-12" ng-show="apps.length">
<div class="row-no-margin" ng-show="!category && !searchString">
<div class="col-sm-12">
<h2>{{ 'appstore.category.all' | tr }}</h2>
</div>
</div>
<div class="row-no-margin">
<div class="col-sm-1 appstore-item" ng-repeat="app in apps">
<div class="appstore-item-content highlight" ng-click="gotoApp(app)" ng-class="{ 'appstore-item-content-testing': app.releaseState === 'unstable' }">
<span class="badge badge-danger appstore-item-badge-testing" ng-show="app.releaseState === 'unstable'">{{ 'appstore.unstable' | tr }}</span>
<div class="appstore-item-content-icon col-same-height">
<img ng-src="{{app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-icon"/>
</div>
<div class="appstore-item-content-description col-same-height">
<h4 class="appstore-item-content-title">{{ app.manifest.title }}</h4>
<div class="appstore-item-content-tagline text-muted">{{ app.manifest.tagline }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
+201 -108
View File
@@ -2,10 +2,12 @@
/* global angular:false */
/* global $:false */
/* global async */
/* global ERROR */
/* global RSTATES */
/* global moment */
angular.module('Application').controller('AppStoreController', ['$scope', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $location, $timeout, $routeParams, Client) {
angular.module('Application').controller('AppStoreController', ['$scope', '$translate', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $translate, $location, $timeout, $routeParams, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.HOST_PORT_MIN = 1024;
@@ -13,6 +15,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.ready = false;
$scope.apps = [];
$scope.popularApps = [];
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.users = [];
@@ -39,21 +42,68 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$('.modal').modal('hide');
};
// If new categories added make sure the translation below exists
$scope.categories = [
{ id: 'analytics', icon: 'fa fa-chart-line', label: 'Analytics'},
{ id: 'blog', icon: 'fa fa-font', label: 'Blog'},
{ id: 'chat', icon: 'fa fa-comments', label: 'Chat'},
{ id: 'crm', icon: 'fab fa-connectdevelop', label: 'CRM'},
{ id: 'document', icon: 'fa fa-file-word', label: 'Documents'},
{ id: 'email', icon: 'fa fa-envelope', label: 'Email'},
{ id: 'federated', icon: 'fa fa-project-diagram', label: 'Federated'},
{ id: 'finance', icon: 'fa fa-hand-holding-usd', label: 'Finance'},
{ id: 'forum', icon: 'fa fa-users', label: 'Forum'},
{ id: 'gallery', icon: 'fa fa-images', label: 'Gallery'},
{ id: 'game', icon: 'fa fa-gamepad', label: 'Games'},
{ id: 'git', icon: 'fa fa-code-branch', label: 'Code Hosting'},
{ id: 'hosting', icon: 'fa fa-server', label: 'Web Hosting'},
{ id: 'learning', icon: 'fas fa-graduation-cap', label: 'Learning'},
{ id: 'media', icon: 'fas fa-photo-video', label: 'Media'},
{ 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: 'vpn', icon: 'fa fa-user-secret', label: 'VPN'},
{ id: 'wiki', icon: 'fab fa-wikipedia-w', label: 'Wiki'},
];
// Translation IDs are generated as "appstore.category.<categoryId>"
$translate($scope.categories.map(function (c) { return 'appstore.category.' + c.id; })).then(function (tr) {
Object.keys(tr).forEach(function (key) {
if (key === tr[key]) return; // missing translation use default label
var category = $scope.categories.find(function (c) { return key.endsWith(c.id); });
if (category) category.label = tr[key];
});
});
$scope.categoryButtonLabel = function (category) {
var categoryLabel = $translate.instant('appstore.categoryLabel');
if (category === 'new') return categoryLabel;
if (category === 'recent') return categoryLabel;
var tmp = $scope.categories.find(function (c) { return c.id === category; });
if (tmp) return tmp.label;
return categoryLabel;
};
$scope.appInstall = {
busy: false,
state: 'appInfo',
error: {},
app: {},
needsOverwrite: false,
location: '',
domain: null,
subdomain: '',
domain: null, // object and not the string
secondaryDomains: {},
portBindings: {},
mediaLinks: [],
certificateFile: null,
certificateFileName: '',
keyFile: null,
keyFileName: '',
accessRestrictionOption: 'any',
accessRestrictionOption: '',
accessRestriction: { users: [], groups: [] },
customAuth: false,
optionalSso: false,
@@ -68,8 +118,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.appInstall.app = {};
$scope.appInstall.error = {};
$scope.appInstall.needsOverwrite = false;
$scope.appInstall.location = '';
$scope.appInstall.subdomain = '';
$scope.appInstall.domain = null;
$scope.appInstall.secondaryDomains = {};
$scope.appInstall.portBindings = {};
$scope.appInstall.state = 'appInfo';
$scope.appInstall.mediaLinks = [];
@@ -77,7 +128,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.appInstall.certificateFileName = '';
$scope.appInstall.keyFile = null;
$scope.appInstall.keyFileName = '';
$scope.appInstall.accessRestrictionOption = 'any';
$scope.appInstall.accessRestrictionOption = '';
$scope.appInstall.accessRestriction = { users: [], groups: [] };
$scope.appInstall.optionalSso = false;
$scope.appInstall.customAuth = false;
@@ -99,7 +150,11 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
var DEFAULT_MEMORY_LIMIT = 1024 * 1024 * 256;
var needed = app.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT; // RAM+Swap
var used = Client.getInstalledApps().reduce(function (prev, cur) { return prev + (cur.memoryLimit || cur.manifest.memoryLimit || DEFAULT_MEMORY_LIMIT); }, 0);
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 available = (totalMemory || 0) - used;
@@ -126,15 +181,25 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.appInstall.mediaLinks = $scope.appInstall.app.manifest.mediaLinks || [];
$scope.appInstall.domain = $scope.domains.find(function (d) { return $scope.config.adminDomain === d.domain; }); // pre-select the adminDomain
$scope.appInstall.secondaryDomains = {};
var httpPorts = $scope.appInstall.app.manifest.httpPorts || {};
for (var env2 in httpPorts) {
$scope.appInstall.secondaryDomains[env2] = {
subdomain: httpPorts[env2].defaultValue || '',
domain: $scope.appInstall.domain
};
}
$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
var manifest = app.manifest;
$scope.appInstall.optionalSso = !!manifest.optionalSso;
$scope.appInstall.customAuth = !(manifest.addons['ldap'] || manifest.addons['oauth']);
$scope.appInstall.customAuth = !(manifest.addons['ldap'] || manifest.addons['proxyAuth']);
$scope.appInstall.accessRestrictionOption = 'any';
$scope.appInstall.accessRestrictionOption = $scope.groups.length ? '' : 'any'; // make the user select an ACL conciously if groups are used
$scope.appInstall.accessRestriction = { users: [], groups: [] };
// set default ports
@@ -153,6 +218,14 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.appInstall.error.location = null;
$scope.appInstall.error.port = null;
var secondaryDomains = {};
for (var env2 in $scope.appInstall.secondaryDomains) {
secondaryDomains[env2] = {
subdomain: $scope.appInstall.secondaryDomains[env2].subdomain,
domain: $scope.appInstall.secondaryDomains[env2].domain.domain
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appInstall.portBindings) {
@@ -170,8 +243,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
var data = {
overwriteDns: $scope.appInstall.needsOverwrite,
location: $scope.appInstall.location || '',
subdomain: $scope.appInstall.subdomain || '',
domain: $scope.appInstall.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
accessRestriction: finalAccessRestriction,
cert: $scope.appInstall.certificateFile,
@@ -179,30 +253,63 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
sso: !$scope.appInstall.optionalSso ? undefined : ($scope.appInstall.accessRestrictionOption !== 'nosso')
};
Client.checkDNSRecords(data.domain, data.location, function (error, result) {
if (error) return Client.error(error);
var domains = [];
domains.push({ subdomain: data.subdomain, domain: data.domain, type: 'primary' });
var canInstall = true;
if (!data.overwriteDns) {
if (result.error || result.needsOverwrite) {
if (result.error) {
if (result.error.reason === ERROR.ACCESS_DENIED) {
$scope.appInstall.error.location = 'DNS credentials for ' + data.domain + ' are invalid. Update it in Domains & Certs view';
async.eachSeries(domains, function (domain, callback) {
if (data.overwriteDns) return callback();
Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) {
if (error) return callback(error);
var message;
if (result.error) {
if (result.error.reason === ERROR.ACCESS_DENIED) {
message = 'DNS credentials for ' + domain.domain + ' are invalid. Update it in Domains & Certs view';
if (domain.type === 'primary') {
$scope.appInstall.error.location = message;
} else {
$scope.appInstall.error.location = result.error.message;
$scope.appInstall.error.secondaryDomain = message;
}
} else {
$scope.appInstall.error.location = 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron';
$scope.appInstall.needsOverwrite = true;
if (domain.type === 'primary') {
$scope.appInstall.error.location = result.error.message;
} else {
$scope.appInstall.error.secondaryDomain = message;
}
}
$scope.appInstall.busy = false;
$scope.appInstallForm.location.$setPristine();
$('#appInstallLocationInput').focus();
return;
canInstall = false;
} else if (result.needsOverwrite) {
message = 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron';
if (data.type === 'primary') {
$scope.appInstall.error.location = message;
} else {
$scope.appInstall.error.secondaryDomain = message;
}
$scope.appInstall.needsOverwrite = true;
canInstall = false;
}
callback();
});
}, function (error) {
if (error) {
$scope.location.busy = false;
return Client.error(error);
}
if (!canInstall) {
$scope.appInstall.busy = false;
$scope.appInstallForm.location.$setPristine();
$('#appInstallLocationInput').focus();
return;
}
Client.installApp($scope.appInstall.app.id, $scope.appInstall.app.manifest, $scope.appInstall.app.title, data, function (error, newAppId) {
if (error) {
var errorMessage = error.message.toLowerCase();
if (error.statusCode === 402) {
$scope.appInstall.state = 'subscriptionRequired';
$scope.appInstall.subscriptionErrorMesssage = error.message;
@@ -211,17 +318,21 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$('#collapseInstallForm').collapse('hide');
$('#collapseSubscriptionRequired').collapse('show');
} else if (error.statusCode === 409) {
if (error.portName) {
if (errorMessage.indexOf('port') !== -1) {
$scope.appInstall.error.port = error.message;
} else if (error.domain) {
$scope.appInstall.error.location = error.message;
$scope.appInstallForm.location.$setPristine();
$('#appInstallLocationInput').focus();
} else if (errorMessage.indexOf('location') !== -1) {
if (errorMessage.indexOf('primary') !== -1) {
$scope.appInstall.error.location = error.message;
$scope.appInstallForm.location.$setPristine();
$('#appInstallLocationInput').focus();
} else {
$scope.appInstall.error.secondaryDomain = error.message;
}
} else {
$scope.appInstall.error.other = error.message;
}
} else if (error.statusCode === 400) {
if (error.field === 'cert') {
if (errorMessage.indexOf('cert') !== -1) {
$scope.appInstall.error.cert = error.message;
$scope.appInstall.certificateFileName = '';
$scope.appInstall.certificateFile = null;
@@ -275,13 +386,12 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
totpToken: '',
register: true,
termsAccepted: false,
purpose: '',
submit: function () {
$scope.appstoreLogin.error = {};
$scope.appstoreLogin.busy = true;
Client.registerCloudron($scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.register, $scope.appstoreLogin.purpose, function (error) {
Client.registerCloudron($scope.appstoreLogin.email, $scope.appstoreLogin.password, $scope.appstoreLogin.totpToken, $scope.appstoreLogin.register, function (error) {
if (error) {
$scope.appstoreLogin.busy = false;
@@ -325,46 +435,15 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
return;
}
getSubscription(function (error) {
if (error) return console.error(error);
onSubscribed(function (error) { if (error) console.error(error); });
});
// do a full re-init of the view now that we have a subscription
init();
});
}
};
function onSubscribed(callback) {
Client.getAppstoreApps(function (error) {
if (error) return callback(error);
// start with featured apps listing. this also sets $scope.apps accordingly
$scope.showCategory(null, 'featured');
// do this in background
fetchUsers();
fetchGroups();
// domains is required since we populate the dropdown with domains[0]
Client.getDomains(function (error, result) {
if (error) return callback(error);
$scope.domains = result;
// show install app dialog immediately if an app id was passed in the query
// hashChangeListener calls $apply, so make sure we don't double digest here
setTimeout(hashChangeListener, 1);
setTimeout(function () { $('#appstoreSearch').focus(); }, 1000);
callback();
});
});
}
// TODO does not support testing apps in search
$scope.search = function () {
if (!$scope.searchString) return $scope.showCategory(null, $scope.cachedCategory);
if (!$scope.searchString) return $scope.showCategory($scope.cachedCategory);
$scope.category = '';
@@ -373,6 +452,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
var token = $scope.searchString.toUpperCase();
$scope.popularApps = [];
$scope.apps = apps.filter(function (app) {
if (app.manifest.id.toUpperCase().indexOf(token) !== -1) return true;
if (app.manifest.title.toUpperCase().indexOf(token) !== -1) return true;
@@ -385,55 +465,54 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
};
function filterForNewApps(apps) {
var minApps = apps.length < 5 ? apps.length : 5; // prevent endless loop
var minApps = apps.length < 12 ? apps.length : 12; // prevent endless loop
var tmp = [];
var i = 0;
do {
var offset = moment().subtract(i++, 'days');
tmp = apps.filter(function (app) { return moment(app.ts).isAfter(offset); });
tmp = apps.filter(function (app) { return moment(app.ts).isAfter(offset); }); // ts here is from appstore's apps table
} while(tmp.length < minApps);
return tmp;
}
function filterForRecentlyUpdatedApps(apps) {
var minApps = apps.length < 5 ? apps.length : 5; // prevent endless loop
var minApps = apps.length < 12 ? apps.length : 12; // prevent endless loop
var tmp = [];
var i = 0;
do {
var offset = moment().subtract(i++, 'days');
tmp = apps.filter(function (app) { return moment(app.creationDate).isAfter(offset); });
tmp = apps.filter(function (app) { return moment(app.creationDate).isAfter(offset); }); // creationDate here is from appstore's appversions table
} while(tmp.length < minApps);
return tmp;
}
$scope.showCategory = function (event, category) {
if (!event) $scope.category = category;
else $scope.category = event.target.getAttribute('category');
$scope.showCategory = function (category) {
$scope.category = category;
$scope.cachedCategory = $scope.category;
Client.getAppstoreAppsFast(function (error, apps) {
if (error) return $timeout($scope.showCategory.bind(null, event), 1000);
if (error) return $timeout($scope.showCategory.bind(null, category), 1000);
if (!$scope.category) {
$scope.apps = apps;
} else if ($scope.category === 'featured') {
$scope.apps = apps.filter(function (app) { return app.featured; });
$scope.apps = apps.slice(0).filter(function (app) { return !app.featured; }).sort(function (a1, a2) { return a1.manifest.title.localeCompare(a2.manifest.title); });
$scope.popularApps = apps.slice(0).filter(function (app) { return app.featured; }).sort(function (a1, a2) { return a2.ranking - a1.ranking; });
} else if ($scope.category === 'new') {
$scope.apps = filterForNewApps(apps);
} else if ($scope.category === 'recent') {
$scope.apps = filterForRecentlyUpdatedApps(apps);
} else {
$scope.apps = apps.filter(function (app) {
return app.manifest.tags.some(function (tag) { return $scope.category === tag; });
});
return app.manifest.tags.some(function (tag) { return $scope.category === tag; }); // reverse sort;
}).sort(function (a1, a2) { return a2.ranking - a1.ranking; });
}
if (document.getElementById('appstoreGrid')) document.getElementById('appstoreGrid').scrollIntoView();
// ensure we scroll to top
document.getElementById('ng-view').scrollTop = 0;
});
};
@@ -475,7 +554,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.showAppNotFound = function (appId, version) {
$scope.appNotFound.appId = appId;
$scope.appNotFound.version = version;
$scope.appNotFound.version = version || 'latest';
$('#appNotFoundModal').modal('show');
};
@@ -507,7 +586,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
}
function fetchUsers() {
Client.getUsers(function (error, users) {
Client.getAllUsers(function (error, users) {
if (error) {
console.error(error);
return $timeout(fetchUsers, 5000);
@@ -528,6 +607,17 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
});
}
function fetchMemory() {
Client.memory(function (error, memory) {
if (error) {
console.error(error);
return $timeout(fetchMemory, 5000);
}
$scope.memory = memory;
});
}
function getSubscription(callback) {
Client.getSubscription(function (error, subscription) {
if (error) {
@@ -553,43 +643,46 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
});
}
function getMemory(callback) {
Client.memory(function (error, memory) {
if (error) console.error(error);
$scope.memory = memory;
callback();
});
}
function init() {
$scope.ready = false;
Client.getAppstoreAppsFast(function (error) {
$scope.ready = true;
getSubscription(function (error) {
if (error) {
console.error(error);
if (error && error.statusCode === 402) {
$scope.validSubscription = false;
return;
} else if (error) {
console.error('Failed to get apps. Will retry.', error);
return $timeout(init, 1000);
}
if (!$scope.validSubscription) { // show the login form
$scope.ready = true;
return;
}
$scope.validSubscription = true;
onSubscribed(function (error) {
if (error) console.error(error);
$scope.showCategory('');
$scope.ready = true;
// refresh everything in background
getSubscription(function (error) { if (error) console.error('Failed to get subscription.', error); });
Client.getAppstoreApps(function (error) { if (error) console.error('Failed to fetch apps.', error); });
Client.refreshConfig(); // refresh domain, user, group limit etc
fetchUsers();
fetchGroups();
fetchMemory();
// domains is required since we populate the dropdown with domains[0]
Client.getDomains(function (error, result) {
if (error) return console.error('Error getting domains.', error);
$scope.domains = result;
// show install app dialog immediately if an app id was passed in the query
// hashChangeListener calls $apply, so make sure we don't double digest here
setTimeout(hashChangeListener, 1);
setTimeout(function () { $('#appstoreSearch').focus(); }, 1);
});
});
}
Client.onReady(function () {
getMemory(function () {
init();
});
});
Client.onReady(init);
// note: do not use hide.bs.model since it is called immediately from switchToAppsView which is already in angular scope
$('#appInstallModal').on('hidden.bs.modal', function () {
+477 -178
View File
@@ -1,15 +1,108 @@
<!-- Modal details -->
<div class="modal fade" id="backupDetailsModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<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-3 text-muted">{{ 'backups.backupDetails.id' | tr }}:</div>
<div class="col-xs-9 text-right">{{ backupDetails.backup.id }}</div>
</div>
<div class="row">
<div class="col-xs-3 text-muted">{{ 'backups.backupDetails.date' | tr }}:</div>
<div class="col-xs-9 text-right">{{ backupDetails.backup.creationTime | prettyLongDate }}</div>
</div>
<div class="row">
<div class="col-xs-3 text-muted">{{ 'backups.backupDetails.version' | tr }}:</div>
<div class="col-xs-9 text-right">v{{ backupDetails.backup.packageVersion }}</div>
</div>
<div class="row">
<div class="col-xs-3 text-muted">{{ 'backups.backupDetails.format' | tr }}:</div>
<div class="col-xs-9 text-right">{{ backupDetails.backup.format }}</div>
</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>
</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 backup failed -->
<div class="modal fade" id="createBackupFailedModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Unable to create backup</h4>
<h4 class="modal-title">{{ 'backups.backupFailed.title' | tr }}</h4>
</div>
<div class="modal-body">
{{ createBackup.errorMessage }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" data-dismiss="modal">OK</button>
<button type="button" class="btn btn-primary" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Cleanup backups info -->
<div class="modal fade" id="cleanupBackupsModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'backups.cleanupBackups.title' | tr }}</h4>
</div>
<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>
</div>
</div>
</div>
</div>
<!-- modal backup config -->
<div class="modal fade" id="configureScheduleAndRetentionModal" 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>
<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">
{{ '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"></multiselect>
</div>
<div class="col-md-5">
{{ '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>
</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>
</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.$invalid || configureScheduleAndRetention.busy"><i class="fa fa-circle-notch fa-spin" ng-show="configureScheduleAndRetention.busy"></i><span> {{ 'main.dialog.save' | tr }}</span></button>
</div>
</div>
</div>
@@ -20,150 +113,272 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Configure Backup Storage</h4>
<h4 class="modal-title">{{ 'backups.configureBackupStorage.title' | tr }}</h4>
</div>
<div class="modal-body">
<p>Cloudron makes a complete backup of your system based on this configuration.</p>
<form name="configureBackupForm" role="form" novalidate ng-submit="configureBackup.submit()" autocomplete="off">
<fieldset>
<p class="has-error text-center" ng-show="configureBackup.error">{{ configureBackup.error.generic }}</p>
<p class="has-error text-center" ng-show="configureBackup.error">{{ configureBackup.error.generic }}</p>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">{{ 'backups.configureBackupStorage.provider' | tr }} <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>
<p class="small text-info" ng-show="backupConfig.provider !== configureBackup.provider">Backups in the old storage location have to be removed manually.</p>
<select class="form-control" id="storageProviderProvider" ng-model="configureBackup.provider" ng-options="a.value as a.name for a in storageProvider" ng-change=configureBackup.clearProviderFields()></select>
</div>
<!-- Noop -->
<div class="form-group" ng-show="configureBackup.provider === 'noop'">
<p class="has-error">{{ 'backups.configureBackupStorage.noopNote' | tr }}</p>
</div>
<!-- mountpoint -->
<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>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="configureBackup.provider === 'cifs' || configureBackup.provider === 'nfs' || configureBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ configureBackup.provider }})</label>
<input type="text" class="form-control" ng-model="configureBackup.mountOptions.host" id="configureBackupHost" name="host" ng-disabled="configureBackup.busy" placeholder="Server IP or hostname" ng-required="configureBackup.provider === 'cifs' || configureBackup.provider === 'nfs' || configureBackup.provider === 'sshfs'">
</div>
<!-- CIFS -->
<div class="checkbox" ng-show="configureBackup.provider === 'cifs'">
<label>
<input type="checkbox" ng-model="configureBackup.mountOptions.seal">{{ 'backups.configureBackupStorage.cifsSealSupport' | tr }}</input>
</label>
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="configureBackup.provider === 'cifs' || configureBackup.provider === 'nfs' || configureBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ configureBackup.provider }})</label>
<input type="text" class="form-control" ng-model="configureBackup.mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="configureBackup.busy" placeholder="/share" ng-required="configureBackup.provider === 'cifs' || configureBackup.provider === 'nfs' || configureBackup.provider === 'sshfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="configureBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ configureBackup.provider }})</label>
<input type="text" class="form-control" ng-model="configureBackup.mountOptions.username" id="configureBackupUsername" name="username" ng-disabled="configureBackup.busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="configureBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ configureBackup.provider }})</label>
<input type="password" class="form-control" ng-model="configureBackup.mountOptions.password" id="configureBackupPassword" name="password" 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'">
<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'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="configureBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="configureBackup.mountOptions.port" id="configureBackupPort" name="port" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="configureBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.mountOptions.user" id="configureBackupUser" name="user" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="configureBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="configureBackup.mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'sshfs'"></textarea>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.backupFolder }" ng-show="configureBackup.provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">{{ 'backups.configureBackupStorage.localDirectory' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="configureBackup.busy" placeholder="Directory for backups" ng-required="configureBackup.provider === 'filesystem'">
</div>
<!-- Filesystem/SSHFS/CIFS/NFS/EXT4/mountpoint -->
<div class="checkbox" ng-show="configureBackup.provider === 'filesystem' || mountlike(configureBackup.provider)">
<label>
<input type="checkbox" ng-model="configureBackup.useHardlinks">{{ 'backups.configureBackupStorage.hardlinksLabel' | tr }}</input>
</label>
</div>
<!-- mountpoint -->
<div class="checkbox" ng-show="configureBackup.provider === 'mountpoint'">
<label>
<input type="checkbox" ng-model="configureBackup.chown">{{ 'backups.configureBackupStorage.chown' | tr }}</input>
</label>
</div>
<!-- S3/Minio/SOS/GCS/UpCloud -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.endpoint }" ng-show="configureBackup.provider === 'minio' || configureBackup.provider === 'upcloud-objectstorage' || configureBackup.provider === 'backblaze-b2' || configureBackup.provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupEndpoint">{{ 'backups.configureBackupStorage.s3Endpoint' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="configureBackup.busy" placeholder="URL" ng-required="configureBackup.provider === 'minio' || configureBackup.provider === 'upcloud-objectstorage' || configureBackup.provider === 'backblaze-b2' || configureBackup.provider === 's3-v4-compat'">
</div>
<div class="checkbox" ng-show="configureBackup.provider === 'minio' || configureBackup.provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="configureBackup.acceptSelfSignedCerts">{{ 'backups.configureBackupStorage.acceptSelfSignedCerts' | tr }}</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.bucket }" ng-show="s3like(configureBackup.provider) || configureBackup.provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">{{ 'backups.configureBackupStorage.bucketName' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<!-- S3/Minio/SOS/GCS/SSHFS/CIFS/NFS/B2 -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.prefix }" ng-show="configureBackup.provider !== 'filesystem' && configureBackup.provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">{{ 'backups.configureBackupStorage.prefix' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="configureBackup.busy" placeholder="Prefix for backup file names">
</div>
<!-- S3/Minio/SOS/GCS -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="configureBackup.region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupS3V4CompatRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<input class="form-control" type="text" name="region" id="inputConfigureBackupS3V4CompatRegion" ng-model="configureBackup.region" ng-disabled="configureBackup.busy" placeholder="Leave empty to use us-east-1 as default"></input>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'linode-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'ovh-objectstorage'">
<label class="control-label" for="inputConfigureBackupOvhRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupOvhRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in ovhRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'ovh-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'ionos-objectstorage'">
<label class="control-label" for="inputConfigureBackupIonosRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<select class="form-control" name="region" id="inputConfigureBackupIonosRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in ionosRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'ionos-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'vultr-objectstorage'">
<label class="control-label" for="inputConfigureBackupVultrRegion">{{ 'backups.configureBackupStorage.region' | tr }}</label>
<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.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)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.secretAccessKey }" ng-show="s3like(configureBackup.provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">{{ 'backups.configureBackupStorage.s3SecretAccessKey' | tr }}</label>
<input type="text" class="form-control" ng-model="configureBackup.secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.gcsKeyInput }" ng-show="configureBackup.provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">{{ 'backups.configureBackupStorage.gcsServiceKey' | tr }}</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="configureBackup.gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="storageFormat">{{ 'backups.configureBackupStorage.format' | tr }} <sup><a ng-href="https://docs.cloudron.io/backups/#backup-formats" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<p class="small text-info" ng-show="backupConfig.format !== configureBackup.format">{{ 'backups.configureBackupStorage.formatChangeNote' | tr }}</p>
<p class="small text-info" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">{{ 'backups.configureBackupStorage.s3LikeNote' | tr }} <sup><a ng-href="https://docs.cloudron.io/backups/#amazon-s3" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<select class="form-control" id="storageFormat" ng-model="configureBackup.format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.password }" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPassword">{{ 'backups.configureBackupStorage.encryptionPassword' | tr }} <sup><a ng-href="https://docs.cloudron.io/backups/#encryption" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<p class="small">{{ 'backups.configureBackupStorage.encryptionDescription' | tr }}</p>
<input type="text" class="form-control" name="password" ng-model="configureBackup.password" id="inputConfigureBackupPassword" ng-disabled="configureBackup.busy" placeholder="{{ 'backups.configureBackupStorage.encryptionPasswordPlaceholder' | tr }}">
<div ng-show="configureBackup.password && configureBackup.password !== SECRET_PLACEHOLDER" ng-class="{ 'has-error': (configureBackupForm.password.$dirty && configureBackup.password !== configureBackup.passwordRepeat) }">
<p class="text-small" style="margin-top: 10px;" ng-class="{ 'text-danger': (configureBackupForm.password.$dirty && configureBackup.password !== configureBackup.passwordRepeat) }">{{ 'backups.configureBackupStorage.encryptionPasswordRepeat' | tr }}</p>
<input type="text" class="form-control" name="passwordRepeat" ng-model="configureBackup.passwordRepeat" ng-disabled="configureBackup.busy">
</div>
</div>
<a href="" ng-click="configureBackup.advancedVisible = true" ng-hide="configureBackup.advancedVisible">{{ 'backups.configureBackupStorage.advancedSettings' | tr }}</a>
<div uib-collapse="!configureBackup.advancedVisible">
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="{{ config.webServerOrigin }}/documentation/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="configureBackup.provider" ng-options="a.value as a.name for a in storageProvider" ng-change=configureBackup.clearForm()></select>
</div>
<!-- Noop -->
<div class="form-group" ng-show="configureBackup.provider === 'noop'">
<p class="has-error">
This option breaks the backup and restore functionality of Cloudron and should only be used for testing. Please make sure the server is completely backed up using alternate means.
</p>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.backupFolder || !configureBackup.backupFolder }" ng-show="configureBackup.provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="configureBackup.backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="configureBackup.busy" placeholder="Directory for backups" ng-required="configureBackup.provider === 'filesystem'">
</div>
<div class="checkbox" ng-show="configureBackup.provider === 'filesystem'">
<label>
<input type="checkbox" ng-model="configureBackup.externalDisk" id="inputConfigureExternalDisk">Backup directory is an external EXT4 Disk</input>
</label>
</div>
<div class="checkbox" ng-show="configureBackup.provider === 'filesystem'">
<label>
<input type="checkbox" ng-model="configureBackup.useHardlinks" id="inputConfigureUseHardlinks">Use hardlinks</input>
</label>
</div>
<!-- S3/Minio/SOS/GCS -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.endpoint }" ng-show="configureBackup.provider === 'minio' || configureBackup.provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="configureBackup.endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="configureBackup.busy" placeholder="URL of Minio/S3 Compatible" ng-required="configureBackup.provider === 'minio' || configureBackup.provider === 's3-v4-compat'">
</div>
<div class="checkbox" ng-show="configureBackup.provider === 'minio' || configureBackup.provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="configureBackup.acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">Accept Self-signed certificate</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.bucket }" ng-show="s3like(configureBackup.provider) || configureBackup.provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="configureBackup.bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.prefix }" ng-show="configureBackup.provider !== 'filesystem' && configureBackup.provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="configureBackup.prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="configureBackup.busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 's3'">
<label class="control-label" for="inputConfigureBackupS3Region">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupS3Region" ng-model="configureBackup.region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupDORegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupDORegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'exoscale-sos'">
<label class="control-label" for="inputConfigureBackupExoscaleRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupExoscaleRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in exoscaleSosRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'exoscale-sos'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'wasabi'">
<label class="control-label" for="inputConfigureBackupWasabiRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupWasabiRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in wasabiRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'wasabi'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'scaleway-objectstorage'">
<label class="control-label" for="inputConfigureBackupScalewayRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupScalewayRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in scalewayRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'scaleway-objectstorage'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'linode-objectstorage'">
<label class="control-label" for="inputConfigureBackupLinodeRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupLinodeRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in linodeRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'linode-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">Access key id</label>
<input type="text" class="form-control" ng-model="configureBackup.accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.secretAccessKey }" ng-show="s3like(configureBackup.provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="configureBackup.secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.gcsKeyInput }" ng-show="configureBackup.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="configureBackup.gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyByteSize:'800 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>
</div>
<div class="form-group" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="storageFormat">Storage Format <sup><a ng-href="{{ config.webServerOrigin }}/documentation/backups/#backup-formats" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageFormat" ng-change="configureBackup.key = ''" ng-model="configureBackup.format" ng-options="a.value as a.name for a in formats"></select>
<div class="form-group" ng-show="s3like(configureBackup.provider)">
<label class="control-label">{{ 'backups.configureBackupStorage.uploadPartSize' | tr }}: <b>{{ configureBackup.uploadPartSize | prettyByteSize:'Default (50 MB)' }}</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>
</div>
<div class="form-group" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="storageInterval">Backup Interval</label>
<select class="form-control" id="storageInterval" ng-model="configureBackup.intervalSecs" ng-options="a.value as a.name for a in intervalTimes"></select>
<div class="form-group" ng-show="configureBackup.format === 'rsync' && (s3like(configureBackup.provider) || configureBackup.provider === 'gcs')">
<label class="control-label">{{ '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>
</div>
<div class="form-group" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="storageRetention">Retention Time</label>
<select class="form-control" id="storageRetention" ng-model="configureBackup.retentionSecs" ng-options="a.value as a.name for a in retentionTimes"></select>
<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>
<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>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.key }" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="inputConfigureBackupKey">Encryption key (optional) <sup><a ng-href="{{ config.webServerOrigin }}/documentation/backups/#encryption" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<p class="small">Save this passphrase in a safe place. Backups cannot be decrypted without the passphrase</p>
<input type="text" class="form-control" ng-model="configureBackup.key" id="inputConfigureBackupKey" name="prefix" ng-disabled="configureBackup.busy" placeholder="Passphrase used to encrypt the backups">
<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>
<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>
</div>
<p class="text-info">
If you change the location of backups, Cloudron will not delete backups stored in the previous location. They have to be removed manually.
</p>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
</fieldset>
</div> <!-- advanced -->
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid || (configureBackup.password !== SECRET_PLACEHOLDER && configureBackup.password !== configureBackup.passwordRepeat)"/>
</form>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="configureBackup.submit()" ng-disabled="configureBackupForm.$invalid || configureBackup.busy"><i class="fa fa-circle-notch fa-spin" ng-show="configureBackup.busy"></i><span>Save</span></button>
<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="configureBackup.submit()" ng-disabled="configureBackupForm.$invalid || configureBackup.busy || (configureBackup.password !== SECRET_PLACEHOLDER && configureBackup.password !== configureBackup.passwordRepeat)"><i class="fa fa-circle-notch fa-spin" ng-show="configureBackup.busy"></i><span> {{ 'main.dialog.save' | tr }}</span></button>
</div>
</div>
</div>
@@ -172,13 +387,29 @@
<div class="content">
<div class="text-left">
<h1>Backups</h1>
<h1>{{ 'backups.title' | tr }}</h1>
</div>
<div class="text-left">
<h3>{{ 'backups.location.title' | tr }}</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
<p>{{ 'backups.location.description' | tr }}
<span ng-show="manualBackupApps.length">
{{ 'backups.location.disabledList' | tr }}
<span ng-repeat="app in manualBackupApps">
<a ng-href="/#/app/{{app.id}}/backups">{{app.label || app.fqdn}}</a><span ng-hide="$last">,</span>
</span>
</span>
</p>
<p ng-show="backupConfig.provider === 'noop'" class="text-danger" ng-bind-html="'backups.check.noop' | tr | markdown2html"></p>
<p ng-show="backupConfig.provider === 'filesystem'" class="text-danger" ng-bind-html="'backups.check.sameDisk' | tr | markdown2html"></p>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Provider</span>
<span class="text-muted">{{ 'backups.location.provider' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ prettyProviderName(backupConfig.provider) }}</span>
@@ -186,82 +417,150 @@
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Location</span>
<span class="text-muted">{{ 'backups.location.location' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<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 === '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>
<span ng-show="backupConfig.provider !== 's3' && backupConfig.provider !== 'minio' && (s3like(backupConfig.provider) || backupConfig.provider === 'gcs')">{{ backupConfig.bucket + (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
<span ng-show="backupConfig.provider === 's3'">{{ backupConfig.region + ' ' + backupConfig.bucket + (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
<span ng-show="backupConfig.provider === 'minio'">{{ backupConfig.endpoint + ' ' + backupConfig.bucket + (backupConfig.prefix ? '/' : '') + backupConfig.prefix }}</span>
</div>
</div>
<div class="row">
<div class="row" ng-show="backupConfig.endpoint && backupConfig.provider !== 'minio'">
<div class="col-xs-6">
<span class="text-muted">Storage Format</span>
<span class="text-muted">{{ 'backups.location.endpoint' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ backupConfig.format }}</span>
<span>{{ backupConfig.endpoint || backupConfig.region }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.location.format' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ backupConfig.format }} <i class="fas fa-lock" ng-show="backupConfig.password" ></i></span>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">Backup ID</span>
</div>
<div class="col-xs-8 text-right">
<span ng-click-select ng-show="lastBackup">{{ lastBackup.id }}</span>
<span ng-hide="lastBackup">No backups have been made yet</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Last backup</span>
</div>
<div class="col-xs-6 text-right">
<span uib-tooltip="{{ lastBackup.creationTime | prettyLongDate }}" ng-show="lastBackup">{{ lastBackup.creationTime | prettyDate }}</span>
<span ng-hide="lastBackup">-</span>
</div>
</div>
<div class="row">
<br/>
<div class="col-md-12" style="margin-bottom: 10px;">
<div ng-show="createBackup.busy" class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ createBackup.percent }}%"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-8">
<p ng-show="createBackup.busy">{{ createBackup.message }}</p>
<p ng-hide="createBackup.busy">
<div class="has-error" ng-show="!createBackup.active">{{ createBackup.errorMessage }}</div>
</p>
</div>
<div class="col-md-4 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="configureBackup.show()" ng-disabled="createBackup.busy">Configure</button>
<button class="btn btn-outline btn-primary" ng-click="createBackup.startBackup()" ng-show="!createBackup.busy" style="margin-right: 10px">Backup now</button>
<button class="btn btn-outline btn-danger" ng-click="createBackup.stopBackup()" ng-show="createBackup.busy" style="margin-right: 10px">Stop Backup</button>
<div class="col-md-12">
<button class="btn btn-outline btn-primary pull-right" ng-show="user.isAtLeastOwner" ng-click="configureBackup.show()">{{ 'backups.location.configure' | tr }}</button>
<button class="btn btn-outline btn-default pull-right" ng-show="user.isAtLeastOwner && mountlike(backupConfig.provider)" ng-disabled="remount.busy" ng-click="remount.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="remount.busy"></i> {{ 'backups.location.remount' | tr }}</button>
</div>
</div>
</div>
<div class="text-left">
<h3>Logs</h3>
<h3>{{ 'backups.schedule.title' | tr }}</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
<p>{{ 'backups.schedule.description' | tr }}</p>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.schedule.schedule' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ prettyBackupSchedule(backupConfig.schedulePattern) }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'backups.schedule.retentionPolicy' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ prettyBackupRetentionPolicy(backupConfig.retentionPolicy) }}</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>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'backups.listing.title' | tr }}</h3>
</div>
<div class="card card-large">
<div class="row">
<div class="col-md-12">
<p ng-show="!backups.length">{{ 'backups.listing.noBackups' | tr }}</p>
<table class="table table-hover" style="margin: 0;" ng-hide="!backups.length">
<thead>
<tr>
<th>{{ 'backups.listing.version' | tr }}</th>
<th>{{ 'main.table.date' | tr }}</th>
<th>{{ 'backups.listing.contents' | tr }}</th>
<th class="text-right" width="180px">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="backup in backups">
<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 }}</span></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>
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="downloadConfig(backup)" uib-tooltip="{{ 'backups.listing.tooltipDownloadBackupConfig' | tr }}"><i class="fas fa-file-alt"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<br/>
<div class="row" ng-show="createBackup.busy">
<div class="col-md-12" style="margin-bottom: 10px;">
<div class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ createBackup.percent }}%"></div>
</div>
<p>{{ createBackup.message }}</p>
</div>
</div>
<div class="row" ng-show="!createBackup.busy && !createBackup.active && createBackup.errorMessage">
<div class="col-md-12">
<p class="has-error">{{ createBackup.errorMessage }}</p>
</div>
</div>
<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>
Please be careful when uploading these logs to a public server since they may contain sensitive information.
</p>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{createBackup.taskId}}" ng-disabled="!createBackup.taskId" target="_blank">Show Logs</a>
<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>
</div>
</div>
</div>
+435 -53
View File
@@ -1,16 +1,20 @@
'use strict';
/* global angular:false */
/* global $:false */
/* global $, angular, TASK_TYPES, SECRET_PLACEHOLDER */
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.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.memory = null; // { memory, swap }
$scope.manualBackupApps = [];
$scope.backupConfig = {};
$scope.lastBackup = null;
$scope.backups = [];
// List is from http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
@@ -22,6 +26,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
{ name: 'Asia Pacific (Sydney)', value: 'ap-southeast-2' },
{ name: 'Asia Pacific (Tokyo)', value: 'ap-northeast-1' },
{ name: 'Canada (Central)', value: 'ca-central-1' },
{ name: 'China (Beijing)', value: 'cn-north-1' },
{ name: 'China (Ningxia)', value: 'cn-northwest-1' },
{ name: 'EU (Frankfurt)', value: 'eu-central-1' },
{ name: 'EU (Ireland)', value: 'eu-west-1' },
{ name: 'EU (London)', value: 'eu-west-2' },
@@ -36,7 +42,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.wasabiRegions = [
{ name: 'EU Central 1', value: 'https://s3.eu-central-1.wasabisys.com' },
{ name: 'US East 1', value: 'https://s3.wasabisys.com' },
{ name: 'US East 1', value: 'https://s3.us-east-1.wasabisys.com' },
{ name: 'US East 2', value: 'https://s3.us-east-2.wasabisys.com ' },
{ name: 'US West 1', value: 'https://s3.us-west-1.wasabisys.com' }
];
@@ -45,6 +52,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
{ name: 'FRA1', value: 'https://fra1.digitaloceanspaces.com' },
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
{ name: 'SFO2', value: 'https://sfo2.digitaloceanspaces.com' },
{ name: 'SFO3', value: 'https://sfo3.digitaloceanspaces.com' },
{ name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' }
];
@@ -64,37 +72,92 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.linodeRegions = [
{ name: 'Newark', value: 'us-east-1.linodeobjects.com', region: 'us-east-1' }, // default
{ name: 'Frankfurt', value: 'eu-central-1.linodeobjects.com', region: 'us-east-1' },
{ name: 'Singapore', value: 'ap-south-1.linodeobjects.com', region: 'us-east-1' }
];
// note: ovh also has a storage endpoint but that only supports path style access
$scope.ovhRegions = [
{ name: 'Beauharnois (BHS)', value: 'https://s3.bhs.cloud.ovh.net', region: 'bhs' }, // default
{ name: 'Frankfurt (DE)', value: 'https://s3.de.cloud.ovh.net', region: 'de' },
{ name: 'Gravelines (GRA)', value: 'https://s3.gra.cloud.ovh.net', region: 'gra' },
{ name: 'Strasbourg (SBG)', value: 'https://s3.sbg.cloud.ovh.net', region: 'sbg' },
{ name: 'London (UK)', value: 'https://s3.uk.cloud.ovh.net', region: 'uk' },
{ name: 'Sydney (SYD)', value: 'https://s3.syd.cloud.ovh.net', region: 'syd' },
{ name: 'Warsaw (WAW)', value: 'https://s3.waw.cloud.ovh.net', region: 'waw' },
];
// https://devops.ionos.com/api/s3/
$scope.ionosRegions = [
{ name: 'DE', value: 'https://s3-de-central.profitbricks.com', region: 's3-de-central' }, // default
];
// this is not used anywhere because upcloud needs endpoint URL. we detect region from the URL
$scope.upcloudRegions = [
{ name: 'AU-SYD1 (Australia)', value: 'https://au-syd1.upcloudobjects.com', region: 'au-syd1' }, // default
{ name: 'DE-FRA1 (Germany)', value: 'https://de-fra1.upcloudobjects.com', region: 'de-fra1' },
{ name: 'ES-MAD1 (Spain)', value: 'https://es-mad1.upcloudobjects.com', region: 'es-mad1' },
{ name: 'FI-HEL2 (Finland)', value: 'https://fi-hel2.upcloudobjects.com', region: 'fi-hel2' },
{ name: 'NL-AMS1 (Netherlands)', value: 'https://nl-ams1.upcloudobjects.com', region: 'nl-ams1' },
{ name: 'PL-WAW1 (Poland)', value: 'https://pl-waw1.upcloudobjects.com', region: 'pl-waw1' },
{ name: 'SG-SIN1 (Singapore)', value: 'https://sg-sin1.upcloudobjects.com', region: 'sg-sin1' },
{ name: 'UK-LON1 (United Kingdom)', value: 'https://uk-lon1.upcloudobjects.com', region: 'uk-lon1' },
{ name: 'US-CHI1 (USA)', value: 'https://us-chi1.upcloudobjects.com', region: 'us-chi1' },
{ name: 'US-NYC1 (USA)', value: 'https://us-nyc1.upcloudobjects.com', region: 'us-nyc1' },
{ name: 'US-SJO1 (USA)', value: 'https://us-sjo1.upcloudobjects.com', region: 'us-sjo1' },
];
$scope.vultrRegions = [
{ name: 'New Jersey', value: 'https://ewr1.vultrobjects.com', region: 'us-east-1' }, // default
];
$scope.storageProvider = [
{ name: 'Amazon S3', value: 's3' },
{ name: 'Backblaze B2 (S3 API)', value: 'backblaze-b2' },
{ name: 'CIFS Mount', value: 'cifs' },
{ name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' },
{ name: 'EXT4 Disk', value: 'ext4' },
{ name: 'Exoscale SOS', value: 'exoscale-sos' },
{ name: 'Filesystem', value: 'filesystem' },
{ name: 'Filesystem (Mountpoint)', value: 'mountpoint' }, // legacy
{ name: 'Google Cloud Storage', value: 'gcs' },
{ name: 'IONOS (Profitbricks)', value: 'ionos-objectstorage' },
{ name: 'Linode Object Storage', value: 'linode-objectstorage' },
{ name: 'Minio', value: 'minio' },
{ name: 'Scaleway Object Storage', value: 'scaleway-objectstorage' },
{ name: 'No-op (Only for testing)', value: 'noop' },
{ name: 'NFS Mount', value: 'nfs' },
{ name: 'OVH Object Storage', value: 'ovh-objectstorage' },
{ name: 'S3 API Compatible (v4)', value: 's3-v4-compat' },
{ name: 'Wasabi', value: 'wasabi' }
{ name: 'Scaleway Object Storage', value: 'scaleway-objectstorage' },
{ name: 'SSHFS Mount', value: 'sshfs' },
{ name: 'UpCloud Object Storage', value: 'upcloud-objectstorage' },
{ name: 'Vultr Object Storage', value: 'vultr-objectstorage' },
{ name: 'Wasabi', value: 'wasabi' },
{ name: 'No-op (Only for testing)', value: 'noop' }
];
$scope.retentionTimes = [
{ name: '2 days', value: 2 * 24 * 60 * 60 },
{ name: '1 week', value: 7 * 24 * 60 * 60},
{ name: '1 month', value: 30 * 24 * 60 * 60},
{ name: 'Forever', value: -1 }
$scope.retentionPolicies = [
{ 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 }},
{ name: '2 daily, 4 weekly', value: { keepDaily: 2, keepWeekly: 4 }},
{ name: '3 daily, 4 weekly, 6 monthly', value: { keepDaily: 3, keepWeekly: 4, keepMonthly: 6 }},
{ name: '7 daily, 4 weekly, 12 monthly', value: { keepDaily: 7, keepWeekly: 4, keepMonthly: 12 }},
{ name: 'Forever', value: { keepWithinSecs: -1 }}
];
$scope.intervalTimes = [
{ name: 'Every 6 hours', value: 6 * 60 * 60 },
{ name: 'Every 12 hours', value: 12 * 60 * 60 },
{ name: 'Every day', value: 24 * 60 * 60 },
{ name: 'Every 3 days', value: 3 * 24 * 60 * 60 },
{ name: 'Every week', value: 7 * 24 * 60 * 60 },
// values correspond to cron days
$scope.cronDays = [
{ name: 'Sunday', value: 0 },
{ name: 'Monday', value: 1 },
{ name: 'Tuesday', value: 2 },
{ name: 'Wednesday', value: 3 },
{ name: 'Thursday', value: 4 },
{ name: 'Friday', value: 5 },
{ name: 'Saturday', value: 6 },
];
// generates 24h time sets (instead of american 12h) to avoid having to translate everything to locales eg. 12:00
$scope.cronHours = Array.from({ length: 24 }).map(function (v, i) { return { name: (i < 10 ? '0' : '') + i + ':00', value: i }; });
$scope.formats = [
{ name: 'Tarball (zipped)', value: 'tgz' },
{ name: 'rsync', value: 'rsync' }
@@ -107,15 +170,63 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
};
$scope.prettyBackupSchedule = function (pattern) {
if (!pattern) return '';
var tmp = pattern.split(' ');
var hours = tmp[2].split(','), days = tmp[5].split(',');
var prettyDay;
if (days.length === 7 || days[0] === '*') {
prettyDay = 'Everyday';
} else {
prettyDay = days.map(function (day) { return $scope.cronDays[parseInt(day, 10)].name.substr(0, 3); }).join(',');
}
var prettyHour = hours.map(function (hour) { return $scope.cronHours[parseInt(hour, 10)].name; }).join(',');
return prettyDay + ' at ' + prettyHour;
};
$scope.prettyBackupRetentionPolicy = function (retentionPolicy) {
var tmp = $scope.retentionPolicies.find(function (p) { return angular.equals(p.value, retentionPolicy); });
return tmp ? tmp.name : '';
};
$scope.remount = {
busy: false,
error: null,
submit: function () {
if (!$scope.mountlike($scope.backupConfig.provider)) return;
$scope.remount.busy = true;
$scope.remount.error = null;
Client.remountBackupStorage(function (error) {
if (error) {
console.error('Failed to remount backup storage.', error);
$scope.remount.error = error.message;
}
// give the backend some time
$timeout(function () {
$scope.remount.busy = false;
getBackupConfig();
}, 2000);
});
}
};
$scope.createBackup = {
busy: false,
percent: 0,
message: '',
errorMessage: '',
taskId: '',
taskType: TASK_TYPES.TASK_BACKUP,
checkStatus: function () {
Client.getLatestTaskByType('backup', function (error, task) {
// TODO support both task types TASK_BACKUP and TASK_CLEAN_BACKUPS
Client.getLatestTaskByType($scope.createBackup.taskType, function (error, task) {
if (error) return console.error(error);
if (!task) return;
@@ -150,6 +261,7 @@ 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) {
@@ -173,7 +285,28 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
});
},
stopBackup: function () {
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);
$scope.createBackup.taskId = taskId;
$scope.createBackup.updateStatus();
});
},
stopTask: function () {
Client.stopTask($scope.createBackup.taskId, function (error) {
if (error) {
if (error.statusCode === 409) {
@@ -191,11 +324,126 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
};
$scope.listBackups = {
};
$scope.s3like = function (provider) {
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat'
|| provider === 'exoscale-sos' || provider === 'digitalocean-spaces'
|| provider === 'scaleway-objectstorage' || provider === 'wasabi'
|| provider === 'linode-objectstorage';
|| provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage'
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage';
};
$scope.mountlike = function (provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'mountpoint' || provider === 'ext4';
};
// https://stackoverflow.com/questions/3665115/how-to-create-a-file-in-memory-for-user-to-download-but-not-through-server#18197341
function download(filename, text) {
var element = document.createElement('a');
element.setAttribute('href', 'data:text/plain;charset=utf-8,' + encodeURIComponent(text));
element.setAttribute('download', filename);
element.style.display = 'none';
document.body.appendChild(element);
element.click();
document.body.removeChild(element);
}
$scope.downloadConfig = function (backup) {
// secrets and tokens already come with placeholder characters we remove them
var tmp = {
backupId: backup.id,
encrypted: !!$scope.backupConfig.password // we add this just to help the import UI
};
Object.keys($scope.backupConfig).forEach(function (k) {
if ($scope.backupConfig[k] !== SECRET_PLACEHOLDER) tmp[k] = $scope.backupConfig[k];
});
var filename = 'cloudron-backup-config-' + (new Date).toISOString().replace(/:|T/g,'-').replace(/\..*/,'') + '.json';
download(filename, JSON.stringify(tmp));
};
$scope.backupDetails = {
backup: null,
show: function (backup) {
$scope.backupDetails.backup = backup;
$('#backupDetailsModal').modal('show');
}
};
$scope.configureScheduleAndRetention = {
busy: false,
error: {},
retentionPolicy: $scope.retentionPolicies[0],
days: [],
hours: [],
show: function () {
$scope.configureScheduleAndRetention.error = {};
$scope.configureScheduleAndRetention.busy = false;
var selectedPolicy = $scope.retentionPolicies.find(function (x) { return angular.equals(x.value, $scope.backupConfig.retentionPolicy); });
if (!selectedPolicy) selectedPolicy = $scope.retentionPolicies[0];
$scope.configureScheduleAndRetention.retentionPolicy = selectedPolicy.value;
var tmp = $scope.backupConfig.schedulePattern.split(' ');
var hours = tmp[2].split(','), days = tmp[5].split(',');
if (days[0] === '*') {
$scope.configureScheduleAndRetention.days = angular.copy($scope.cronDays, []);
} else {
$scope.configureScheduleAndRetention.days = days.map(function (day) { return $scope.cronDays[parseInt(day, 10)]; });
}
$scope.configureScheduleAndRetention.hours = hours.map(function (hour) { return $scope.cronHours[parseInt(hour, 10)]; });
$('#configureScheduleAndRetentionModal').modal('show');
},
submit: function () {
$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;
var daysPattern;
if ($scope.configureScheduleAndRetention.days.length === 7) daysPattern = '*';
else daysPattern = $scope.configureScheduleAndRetention.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; });
backupConfig.schedulePattern ='00 00 ' + hoursPattern + ' * * ' + daysPattern;
Client.setBackupConfig(backupConfig, function (error) {
$scope.configureScheduleAndRetention.busy = false;
if (error) {
if (error.statusCode === 424) {
$scope.configureScheduleAndRetention.error.generic = error.message;
} else if (error.statusCode === 400) {
$scope.configureScheduleAndRetention.error.generic = error.message;
} else {
console.error('Unable to change schedule or retention.', error);
}
return;
}
$('#configureScheduleAndRetentionModal').modal('hide');
getBackupConfig();
});
}
};
$scope.configureBackup = {
@@ -211,15 +459,36 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
region: '',
endpoint: '',
backupFolder: '',
retentionSecs: 7 * 24 * 60 * 60,
intervalSecs: 24 * 60 * 60,
mountPoint: '',
acceptSelfSignedCerts: false,
useHardlinks: true,
externalDisk: false,
chown: true,
format: 'tgz',
key: '',
password: '',
passwordRepeat: '',
advancedVisible: false,
clearForm: function () {
memoryTicks: [],
memoryLimit: $scope.MIN_MEMORY_LIMIT,
uploadPartSizeTicks: [],
uploadPartSize: 50 * 1024 * 1024,
copyConcurrency: '',
downloadConcurrency: '',
syncConcurrency: '', // sort of similar to upload
mountOptions: {
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
seal: false,
user: '',
port: 22,
privateKey: ''
},
clearProviderFields: function () {
$scope.configureBackup.bucket = '';
$scope.configureBackup.prefix = '';
$scope.configureBackup.accessKeyId = '';
@@ -229,19 +498,27 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.endpoint = '';
$scope.configureBackup.region = '';
$scope.configureBackup.backupFolder = '';
$scope.configureBackup.retentionSecs = 7 * 24 * 60 * 60;
$scope.configureBackup.intervalSecs = 24 * 60 * 60;
$scope.configureBackup.format = 'tgz';
$scope.configureBackup.mountPoint = '';
$scope.configureBackup.acceptSelfSignedCerts = false;
$scope.configureBackup.useHardlinks = true;
$scope.configureBackup.externalDisk = false;
$scope.configureBackup.key = '';
$scope.configureBackup.chown = true;
$scope.configureBackup.memoryLimit = $scope.MIN_MEMORY_LIMIT;
// scaleway only supports 1000 parts per object (https://www.scaleway.com/en/docs/s3-multipart-upload/)
$scope.configureBackup.uploadPartSize = $scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024;
$scope.configureBackup.downloadConcurrency = $scope.configureBackup.provider === 's3' ? 30 : 10;
$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: '' };
},
show: function () {
$scope.configureBackup.error = {};
$scope.configureBackup.busy = false;
$scope.configureBackup.advancedVisible = false;
$scope.configureBackup.provider = $scope.backupConfig.provider;
$scope.configureBackup.bucket = $scope.backupConfig.bucket;
$scope.configureBackup.prefix = $scope.backupConfig.prefix;
@@ -257,14 +534,45 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
});
}
$scope.configureBackup.endpoint = $scope.backupConfig.endpoint;
$scope.configureBackup.key = $scope.backupConfig.key;
$scope.configureBackup.password = $scope.backupConfig.password || '';
$scope.configureBackup.passwordRepeat = '';
$scope.configureBackup.backupFolder = $scope.backupConfig.backupFolder;
$scope.configureBackup.retentionSecs = $scope.backupConfig.retentionSecs;
$scope.configureBackup.intervalSecs = $scope.backupConfig.intervalSecs;
$scope.configureBackup.mountPoint = $scope.backupConfig.mountPoint;
$scope.configureBackup.format = $scope.backupConfig.format;
$scope.configureBackup.acceptSelfSignedCerts = !!$scope.backupConfig.acceptSelfSignedCerts;
$scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks;
$scope.configureBackup.externalDisk = !!$scope.backupConfig.externalDisk;
$scope.configureBackup.chown = $scope.backupConfig.chown;
$scope.configureBackup.memoryLimit = $scope.backupConfig.memoryLimit;
$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);
}
var mountOptions = $scope.backupConfig.mountOptions || {};
$scope.configureBackup.mountOptions = {
host: mountOptions.host || '',
remoteDir: mountOptions.remoteDir || '',
username: mountOptions.username || '',
password: mountOptions.password || '',
diskPath: mountOptions.diskPath || '',
seal: mountOptions.seal,
user: mountOptions.user || '',
port: mountOptions.port || 22,
privateKey: mountOptions.privateKey || ''
};
$('#configureBackupModal').modal('show');
},
@@ -275,11 +583,13 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
var backupConfig = {
provider: $scope.configureBackup.provider,
key: $scope.configureBackup.key,
retentionSecs: $scope.configureBackup.retentionSecs,
intervalSecs: $scope.configureBackup.intervalSecs,
format: $scope.configureBackup.format
format: $scope.configureBackup.format,
memoryLimit: $scope.configureBackup.memoryLimit,
// required for api call to provide all fields
schedulePattern: $scope.backupConfig.schedulePattern,
retentionPolicy: $scope.backupConfig.retentionPolicy
};
if ($scope.configureBackup.password) backupConfig.password = $scope.configureBackup.password;
// only set provider specific fields, this will clear them in the db
if ($scope.s3like(backupConfig.provider)) {
@@ -294,13 +604,14 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if ($scope.configureBackup.region) backupConfig.region = $scope.configureBackup.region;
delete backupConfig.endpoint;
} else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') {
backupConfig.region = 'us-east-1';
backupConfig.region = $scope.configureBackup.region || 'us-east-1';
backupConfig.acceptSelfSignedCerts = $scope.configureBackup.acceptSelfSignedCerts;
backupConfig.s3ForcePathStyle = true; // might want to expose this in the UI
} else if (backupConfig.provider === 'exoscale-sos') {
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'wasabi') {
backupConfig.region = 'us-east-1';
backupConfig.region = $scope.wasabiRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'scaleway-objectstorage') {
backupConfig.region = $scope.scalewayRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
@@ -308,6 +619,19 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
} else if (backupConfig.provider === 'linode-objectstorage') {
backupConfig.region = $scope.linodeRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'ovh-objectstorage') {
backupConfig.region = $scope.ovhRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'ionos-objectstorage') {
backupConfig.region = $scope.ionosRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} 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 === '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
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
}
@@ -331,10 +655,42 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.busy = false;
return;
}
} else if ($scope.mountlike(backupConfig.provider)) {
backupConfig.prefix = $scope.configureBackup.prefix;
backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks;
backupConfig.mountOptions = {};
if (backupConfig.provider === 'cifs' || backupConfig.provider === 'sshfs' || backupConfig.provider === 'nfs') {
backupConfig.mountOptions.host = $scope.configureBackup.mountOptions.host;
backupConfig.mountOptions.remoteDir = $scope.configureBackup.mountOptions.remoteDir;
if (backupConfig.provider === 'cifs') {
backupConfig.mountOptions.username = $scope.configureBackup.mountOptions.username;
backupConfig.mountOptions.password = $scope.configureBackup.mountOptions.password;
backupConfig.mountOptions.seal = $scope.configureBackup.mountOptions.seal;
} else if (backupConfig.provider === 'sshfs') {
backupConfig.mountOptions.user = $scope.configureBackup.mountOptions.user;
backupConfig.mountOptions.port = $scope.configureBackup.mountOptions.port;
backupConfig.mountOptions.privateKey = $scope.configureBackup.mountOptions.privateKey;
}
} else if (backupConfig.provider === 'ext4') {
backupConfig.mountOptions.diskPath = $scope.configureBackup.mountOptions.diskPath;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountPoint = $scope.configureBackup.mountPoint;
backupConfig.chown = $scope.configureBackup.chown;
backupConfig.preserveAttributes = true;
}
} else if (backupConfig.provider === 'filesystem') {
backupConfig.backupFolder = $scope.configureBackup.backupFolder;
backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks;
backupConfig.externalDisk = $scope.configureBackup.externalDisk;
}
backupConfig.uploadPartSize = $scope.configureBackup.uploadPartSize;
if (backupConfig.format === 'rsync') {
backupConfig.downloadConcurrency = $scope.configureBackup.downloadConcurrency;
backupConfig.syncConcurrency = $scope.configureBackup.syncConcurrency;
backupConfig.copyConcurrency = $scope.configureBackup.copyConcurrency;
}
Client.setBackupConfig(backupConfig, function (error) {
@@ -375,7 +731,10 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
} else if (error.statusCode === 400) {
$scope.configureBackup.error.generic = error.message;
if ($scope.configureBackup.provider === 'filesystem') {
if (error.message.indexOf('password') !== -1) {
$scope.configureBackup.error.password = true;
$scope.configureBackupForm.password.$setPristine();
} else if ($scope.configureBackup.provider === 'filesystem') {
$scope.configureBackup.error.backupFolder = true;
}
} else {
@@ -398,12 +757,27 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
if (error) return console.error(error);
$scope.backups = backups;
$scope.backups = $scope.backups.slice(0, 20); // only show 20 since we don't have pagination
if ($scope.backups.length > 0) {
$scope.lastBackup = backups[0];
} else {
$scope.lastBackup = null;
}
// add contents property
var appsById = {}, appsByFqdn = {};
Client.getInstalledApps().forEach(function (app) {
appsById[app.id] = app;
appsByFqdn[app.fqdn] = app;
});
$scope.backups.forEach(function (backup) {
backup.contents = [];
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]]);
} else {
if (appsById[match[1]]) backup.contents.push(appsById[match[1]]);
}
});
});
});
}
@@ -416,11 +790,19 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
}
Client.onReady(function () {
fetchBackups();
getBackupConfig();
Client.memory(function (error, memory) {
if (error) console.error(error);
// show backup status
$scope.createBackup.checkStatus();
$scope.memory = memory;
fetchBackups();
getBackupConfig();
$scope.manualBackupApps = Client.getInstalledApps().filter(function (app) { return !app.enableBackup; });
// show backup status
$scope.createBackup.checkStatus();
});
});
function readFileLocally(obj, file, fileName) {
+98
View File
@@ -0,0 +1,98 @@
<!-- 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>
</div>
</div>
<div class="content">
<div class="text-left">
<h1>{{ 'branding.title' | tr }}</h1>
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<form role="form" name="aboutForm" ng-submit="about.submit()" autocomplete="off">
<fieldset>
<div class="form-group" ng-class="{ 'has-error': about.error.cloudronName }">
<label class="control-label">{{ 'branding.cloudronName' | tr }}</label>
<div class="control-label" ng-show="about.error.cloudronName">{{about.error.cloudronName}}</div>
<input type="text" class="form-control" id="inputCloudronName" name="name" ng-model="about.cloudronName" ng-minlength="1" maxlength="64" required>
</div>
<div class="form-group">
<div>
<label class="control-label">{{ 'branding.logo' | tr }}</label>
</div>
<div class="branding-avatar" ng-click="avatarChange.showChangeAvatar()">
<img ng-src="{{ about.avatarUrl() }}"/>
<div class="overlay"></div>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="(!about.avatar && !aboutForm.$dirty) || aboutForm.$invalid || about.busy"/>
</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="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>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'branding.footer.title' | tr }}</h3>
</div>
<div class="card" ng-show="config.features.branding">
<div class="row">
<div class="col-md-12">
<form role="form" name="footerForm" autocomplete="off">
<p>{{ 'branding.footer.description' | tr }} <sup><a ng-href="https://docs.cloudron.io/branding/#footer" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<textarea name="footer" class="form-control" ng-model="footer.content" ng-disabled="footer.busy"></textarea>
</form>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="footer.submit()" ng-disabled="!footerForm.$dirty || footerForm.$invalid || footer.busy"><i class="fa fa-circle-notch fa-spin" ng-show="footer.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
<div class="card" ng-hide="config.features.branding">
<div class="row">
<div class="col-md-12">
{{ 'branding.footer.subscriptionRequired' | tr }} <a href="" class="pull-right" ng-click="openSubscriptionSetup()">{{ 'branding.footer.setupSubscriptionNow' | tr }}</a>
</div>
</div>
</div>
</div>
+229
View File
@@ -0,0 +1,229 @@
'use strict';
/* global angular:false */
/* global $:false */
angular.module('Application').controller('BrandingController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (Client.getUserInfo().role !== 'owner') $location.path('/'); });
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.openSubscriptionSetup = function () {
Client.openSubscriptionSetup($scope.$parent.subscription);
};
$scope.avatarChange = {
avatar: null, // { file, data, url }
availableAvatars: [{
file: null,
data: null,
url: '/img/avatars/logo.png',
}, {
file: null,
data: null,
url: '/img/avatars/logo-green.png'
}, {
file: null,
data: null,
url: '/img/avatars/logo-orange.png'
}, {
file: null,
data: null,
url: '/img/avatars/logo-darkblue.png'
}, {
file: null,
data: null,
url: '/img/avatars/logo-red.png'
}, {
file: null,
data: null,
url: '/img/avatars/logo-yellow.png'
}, {
file: null,
data: null,
url: '/img/avatars/logo-black.png'
}],
avatarUrl: function () {
if ($scope.avatarChange.avatar) {
return $scope.avatarChange.avatar.data || $scope.avatarChange.avatar.url;
} else {
return Client.avatar;
}
},
getBlobFromImg: function (img, callback) {
var size = 512;
var canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
var imageDimensionRatio = img.width / img.height;
var canvasDimensionRatio = canvas.width / canvas.height;
var renderableHeight, renderableWidth, xStart, yStart;
if (imageDimensionRatio > canvasDimensionRatio) {
renderableHeight = canvas.height;
renderableWidth = img.width * (renderableHeight / img.height);
xStart = (canvas.width - renderableWidth) / 2;
yStart = 0;
} else if (imageDimensionRatio < canvasDimensionRatio) {
renderableWidth = canvas.width;
renderableHeight = img.height * (renderableWidth / img.width);
xStart = 0;
yStart = (canvas.height - renderableHeight) / 2;
} else {
renderableHeight = canvas.height;
renderableWidth = canvas.width;
xStart = 0;
yStart = 0;
}
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, xStart, yStart, renderableWidth, renderableHeight);
canvas.toBlob(callback);
},
setPreviewAvatar: function (avatar) {
$scope.avatarChange.avatar = avatar;
},
showChangeAvatar: function () {
$scope.avatarChange.avatar = $scope.about.avatar;
$('#avatarChangeModal').modal('show');
},
showCustomAvatarSelector: function () {
$('#avatarFileInput').click();
},
setAvatar: function () {
if (angular.equals($scope.about.avatar, $scope.avatarChange.avatar)) return $('#avatarChangeModal').modal('hide'); // nothing changed
$scope.about.avatar = $scope.avatarChange.avatar;
// get the blob now, we cannot get it if dialog is hidden
var img = document.getElementById('previewAvatar');
$scope.avatarChange.getBlobFromImg(img, function (blob) {
$scope.about.avatarBlob = blob;
$('#avatarChangeModal').modal('hide');
});
},
};
$('#avatarFileInput').get(0).onchange = function (event) {
var fr = new FileReader();
fr.onload = function () {
$scope.$apply(function () {
var tmp = {
file: event.target.files[0],
data: fr.result,
url: null
};
$scope.avatarChange.availableAvatars.push(tmp);
$scope.avatarChange.setPreviewAvatar(tmp);
});
};
fr.readAsDataURL(event.target.files[0]);
};
$scope.about = {
busy: false,
error: {},
cloudronName: '',
avatar: null,
avatarBlob: null,
avatarUrl: function () {
if ($scope.about.avatar) {
return $scope.about.avatar.data || $scope.about.avatar.url;
} else {
return Client.avatar;
}
},
refresh: function () {
$scope.about.cloudronName = $scope.config.cloudronName;
$scope.about.avatar = null;
},
submit: function () {
$scope.about.error.name = null;
$scope.about.busy = true;
var NOOP = function (next) { return next(); };
var changeCloudronName = $scope.about.cloudronName !== $scope.config.cloudronName ? Client.changeCloudronName.bind(null, $scope.about.cloudronName) : NOOP;
changeCloudronName(function (error) {
if (error) {
$scope.about.busy = false;
if (error.statusCode === 400) {
$scope.about.error.cloudronName = error.message || 'Invalid name';
$('#inputCloudronName').focus();
} else {
console.error('Unable to change name.', error);
return;
}
}
var changeAvatar = $scope.about.avatar ? Client.changeCloudronAvatar.bind(null, $scope.about.avatarBlob) : NOOP;
changeAvatar(function (error) {
if (error) {
$scope.about.busy = false;
console.error('Unable to change avatar.', error);
return;
}
Client.refreshConfig(function () {
if ($scope.about.avatar) Client.resetAvatar();
$scope.aboutForm.$setPristine();
$scope.about.avatar = null;
$scope.about.busy = false;
});
});
});
}
};
$scope.footer = {
content: '',
busy: false,
refresh: function () {
Client.getFooter(function (error, result) {
if (error) return console.error('Failed to get footer.', error);
$scope.footer.content = result;
});
},
submit: function () {
$scope.footer.busy = true;
Client.setFooter($scope.footer.content.trim(), function (error) {
if (error) return console.error('Failed to set footer.', error);
Client.refreshConfig(function () {
$scope.footer.busy = false;
$scope.footerForm.$setPristine();
});
});
}
};
Client.onReady(function () {
$scope.about.refresh();
$scope.footer.refresh();
});
$('.modal-backdrop').remove();
}]);
+275 -196
View File
@@ -1,188 +1,244 @@
<!-- Modal subscription -->
<div class="modal fade" id="subscriptionRequiredModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'domains.subscriptionRequired.title' | tr }}</h4>
</div>
<div class="modal-body">
<p ng-bind-html="'domains.subscriptionRequired.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-success" ng-click="openSubscriptionSetup()">{{ 'domains.subscriptionRequired.setupAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- modal domain add/configure -->
<div class="modal fade" id="domainConfigureModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" ng-show="domainConfigure.adding">Add Domain</h4>
<h4 class="modal-title" ng-hide="domainConfigure.adding">Configure {{ domainConfigure.domain.domain }}</h4>
<h4 class="modal-title" ng-show="domainConfigure.adding">{{ 'domains.domainDialog.addTitle' | tr }}</h4>
<h4 class="modal-title" ng-hide="domainConfigure.adding">{{ 'domains.domainDialog.editTitle' | tr:{ domain: domainConfigure.domain.domain } }}</h4>
</div>
<div class="modal-body">
<form name="domainConfigureForm" role="form" novalidate ng-submit="domainConfigure.submit()" autocomplete="off">
<fieldset>
<p class="has-error text-center" ng-show="domainConfigure.error">{{ domainConfigure.error }}</p>
<p ng-show="domainConfigure.adding" ng-bind-html="'domains.domainDialog.addDescription' | tr"></p>
<div class="form-group" ng-show="domainConfigure.adding">
<label class="control-label">Domain name</label>
<input type="text" class="form-control" ng-model="domainConfigure.newDomain" name="newDomain" ng-disabled="domainConfigure.busy" placeholder="example.com" ng-required="domainConfigure.adding" autofocus>
<form name="domainConfigureForm" role="form" novalidate ng-submit="domainConfigure.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="domainConfigure.error">{{ domainConfigure.error }}</p>
<div class="form-group" ng-show="domainConfigure.adding">
<label class="control-label">{{ 'domains.domainDialog.domain' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.newDomain" name="newDomain" ng-disabled="domainConfigure.busy" placeholder="example.com" ng-required="domainConfigure.adding" autofocus>
</div>
<div class="form-group">
<label class="control-label">{{ 'domains.domainDialog.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/domains/#dns-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="domainConfigure.provider" ng-options="a.value as a.name for a in dnsProvider" ng-change="domainConfigure.setDefaultTlsProvider()"></select>
</div>
<!-- Route53 -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'route53'">
<label class="control-label">{{ 'domains.domainDialog.route53AccessKeyId' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.accessKeyId" name="accessKeyId" ng-disabled="domainConfigure.busy" ng-minlength="16" ng-maxlength="32" ng-required="domainConfigure.provider === 'route53'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'route53'">
<label class="control-label">{{ 'domains.domainDialog.route53SecretAccessKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.secretAccessKey" name="secretAccessKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'route53'">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'gcdns'">
<label class="control-label">{{ 'domains.domainDialog.gcdnsServiceAccountKey' | tr }}</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="domainConfigure.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'gcdns'">
<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': false }" ng-show="domainConfigure.provider === 'digitalocean'">
<label class="control-label">{{ 'domains.domainDialog.digitalOceanToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.digitalOceanToken" name="digitalOceanToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'digitalocean'">
</div>
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'gandi'">
<label class="control-label">{{ 'domains.domainDialog.gandiApiKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.gandiApiKey" name="gandiApiKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'gandi'">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'godaddy'">
<label class="control-label">{{ 'domains.domainDialog.goDaddyApiKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.godaddyApiKey" name="apiKey" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'godaddy'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'godaddy'">
<label class="control-label">{{ 'domains.domainDialog.goDaddyApiSecret' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.godaddyApiSecret" name="apiSecret" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'godaddy'">
</div>
<!-- Netcup -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'netcup'">
<label class="control-label">{{ 'domains.domainDialog.netcupCustomerNumber' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.netcupCustomerNumber" name="netcupCustomerNumber" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'netcup'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'netcup'">
<label class="control-label">{{ 'domains.domainDialog.netcupApiKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.netcupApiKey" name="netcupApiKey" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'netcup'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'netcup'">
<label class="control-label">{{ 'domains.domainDialog.netcupApiPassword' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.netcupApiPassword" name="netcupApiPassword" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'netcup'">
</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>
<input type="text" class="form-control" ng-model="domainConfigure.cloudflareToken" name="cloudflareToken" ng-required="domainConfigure.provider === 'cloudflare'" ng-disabled="domainConfigure.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare' && domainConfigure.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">{{ 'domains.domainDialog.cloudflareEmail' | tr }}</label>
<input type="email" class="form-control" ng-model="domainConfigure.cloudflareEmail" name="cloudflareEmail" ng-required="domainConfigure.provider === 'cloudflare' && domainConfigure.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="domainConfigure.busy">
</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>
<input type="text" class="form-control" ng-model="domainConfigure.linodeToken" name="linodeToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'linode'">
</div>
<!-- Vultr -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'vultr'">
<label class="control-label">{{ 'domains.domainDialog.vultrToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.vultrToken" name="vultrToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'vultr'">
</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>
<input type="text" class="form-control" ng-model="domainConfigure.nameComUsername" name="nameComUsername" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'namecom'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecom'">
<label class="control-label">{{ 'domains.domainDialog.nameComApiToken' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.nameComToken" name="nameComToken" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'namecom'">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecheap'">
<label class="control-label">{{ 'domains.domainDialog.namecheapUsername' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.namecheapUsername" name="namecheapUsername" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'namecheap'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecheap'">
<label class="control-label">{{ 'domains.domainDialog.namecheapApiKey' | tr }}</label>
<input type="text" class="form-control" ng-model="domainConfigure.namecheapApiKey" name="namecheapApiKey" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'namecheap'">
</div>
<p class="small text-info text-bold" ng-show="domainConfigure.provider === 'namecheap'" ng-bind-html="'domains.domainDialog.namecheapInfo' | tr"></p>
<p class="small text-info text-bold" ng-show="domainConfigure.provider === 'wildcard'" ng-bind-html="'domains.domainDialog.wildcardInfo' | tr:{ domain: domainConfigure.adding ? domainConfigure.newDomain : domainConfigure.domain.domain }"></p>
<p class="small text-info text-bold" ng-show="domainConfigure.provider === 'manual'" ng-bind-html="'domains.domainDialog.manualInfo' | tr"></p>
<p class="small text-info text-bold" ng-show="needsPort80(domainConfigure.provider, domainConfigure.tlsConfig.provider)" ng-bind-html="'domains.domainDialog.letsEncryptInfo' | tr"></p>
<a href="" ng-click="domainConfigure.advancedVisible = true" ng-hide="domainConfigure.advancedVisible">{{ 'domains.domainDialog.advancedAction' | tr }}</a>
<div uib-collapse="!domainConfigure.advancedVisible">
<div class="form-group">
<label class="control-label">{{ 'domains.domainDialog.zoneName' | tr }} <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="domainConfigure.zoneName" name="zoneName" ng-disabled="domainConfigure.busy">
</div>
<div class="form-group">
<label class="control-label">DNS Provider <sup><a ng-href="{{ config.webServerOrigin }}/documentation/domains/#dns-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="domainConfigure.provider" ng-options="a.value as a.name for a in dnsProvider" ng-change="domainConfigure.setDefaultTlsProvider()"></select>
<label class="control-label">{{ 'domains.domainDialog.certProvider' | tr }} <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="domainConfigure.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider"></select>
</div>
<!-- Route53 -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'route53'">
<label class="control-label">Access Key Id</label>
<input type="text" class="form-control" ng-model="domainConfigure.accessKeyId" name="accessKeyId" ng-disabled="domainConfigure.busy" ng-minlength="16" ng-maxlength="32" ng-required="domainConfigure.provider === 'route53'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'route53'">
<label class="control-label">Secret Access Key</label>
<input type="text" class="form-control" ng-model="domainConfigure.secretAccessKey" name="secretAccessKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'route53'">
<!-- Fallback certificate -->
<div ng-show="domainConfigure.tlsConfig.provider !== 'fallback'">
<label class="control-label">{{ 'domains.domainDialog.fallbackCert' | tr }}</label>
<p ng-bind-html="'domains.domainDialog.fallbackCertInfo' | tr"></p>
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'gcdns'">
<label class="control-label">Service Account Key</label>
<div ng-show="domainConfigure.tlsConfig.provider === 'fallback'">
<label class="control-label">{{ 'domains.domainDialog.fallbackCertCustomCert' | tr }}</label>
<p ng-bind-html="'domains.domainDialog.fallbackCertCustomCertInfo' | tr:{ customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates' }"></p>
</div>
<div class="form-group" ng-class="{ 'has-error': (!fallbackCert.cert.$dirty && fallbackCert.error) }">
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="domainConfigure.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'gcdns'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
<input type="file" id="fallbackCertFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="{{ 'domains.domainDialog.fallbackCertCertificatePlaceholder' | tr }}" ng-model="domainConfigure.fallbackCert.certificateFileName" name="cert" onclick="getElementById('fallbackCertFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon"><i class="fa fa-upload" onclick="getElementById('fallbackCertFileInput').click();"></i></span>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!fallbackCert.key.$dirty && fallbackCert.error) }">
<div class="input-group">
<input type="file" id="fallbackKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="{{ 'domains.domainDialog.fallbackCertKeyPlaceholder' | tr }}" ng-model="domainConfigure.fallbackCert.keyFileName" id="fallbackKeyInput" name="key" onclick="getElementById('fallbackKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon"><i class="fa fa-upload" onclick="getElementById('fallbackKeyFileInput').click();"></i></span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'digitalocean'">
<label class="control-label">DigitalOcean Token</label>
<input type="text" class="form-control" ng-model="domainConfigure.digitalOceanToken" name="digitalOceanToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'digitalocean'">
</div>
</div> <!-- advanced -->
<!-- Gandi -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'gandi'">
<label class="control-label">Gandi API Key</label>
<input type="text" class="form-control" ng-model="domainConfigure.gandiApiKey" name="gandiApiKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'gandi'">
</div>
<!-- GoDaddy -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'godaddy'">
<label class="control-label">API Key</label>
<input type="text" class="form-control" ng-model="domainConfigure.godaddyApiKey" name="apiKey" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'godaddy'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'godaddy'">
<label class="control-label">API Secret</label>
<input type="text" class="form-control" ng-model="domainConfigure.godaddyApiSecret" name="apiSecret" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'godaddy'">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare'">
<label class="control-label">Token Type</label>
<select class="form-control" ng-model="domainConfigure.cloudflareTokenType">
<option value="GlobalApiKey">Global API Key</option>
<option value="ApiToken">API Token</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'">Global API Key</label>
<label class="control-label" ng-show="domainConfigure.cloudflareTokenType === 'ApiToken'">Api Token</label>
<input type="text" class="form-control" ng-model="domainConfigure.cloudflareToken" name="cloudflareToken" placeholder="API Key/Token" ng-required="domainConfigure.provider === 'cloudflare'" ng-disabled="domainConfigure.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare' && domainConfigure.cloudflareTokenType === 'GlobalApiKey'">
<label class="control-label">Cloudflare Email</label>
<input type="email" class="form-control" ng-model="domainConfigure.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="domainConfigure.provider === 'cloudflare' && domainConfigure.cloudflareTokenType === 'GlobalApiKey'" ng-disabled="domainConfigure.busy">
</div>
<!-- Name.com -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecom'">
<label class="control-label">Name.com Username</label>
<input type="text" class="form-control" ng-model="domainConfigure.nameComUsername" name="nameComUsername" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'namecom'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecom'">
<label class="control-label">API Token</label>
<input type="text" class="form-control" ng-model="domainConfigure.nameComToken" name="nameComToken" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'namecom'">
</div>
<!-- Namecheap -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecheap'">
<label class="control-label">Namecheap Username</label>
<input type="text" class="form-control" ng-model="domainConfigure.namecheapUsername" name="namecheapUsername" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'namecheap'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'namecheap'">
<label class="control-label">API Key</label>
<p class="small text-info" ng-show="domainConfigure.provider === 'namecheap'">
<b>The server IP needs to be whitelisted for this API Key.</b>
</p>
<input type="text" class="form-control" ng-model="domainConfigure.namecheapApiKey" name="namecheapApiKey" ng-disabled="domainConfigure.busy" ng-minlength="1" ng-required="domainConfigure.provider === 'namecheap'">
</div>
<p class="small text-info" ng-show="domainConfigure.provider === 'wildcard'">
Setup <i>A</i> records for <b>*.{{ domainConfigure.newDomain || domainConfigure.domain.domain }}</b> and <b>{{ domainConfigure.newDomain || domainConfigure.domain.domain }}</b> to this server's IP.
</p>
<p class="small text-info" ng-show="domainConfigure.provider === 'manual'">
<b>All DNS records have to be setup manually before each app installation.</b>
</p>
<p class="small text-info" ng-show="needsPort80(domainConfigure.provider, domainConfigure.tlsConfig.provider)">Let's Encrypt requires your server to be reachable on port 80</p>
<a href="" ng-click="domainConfigure.advancedVisible = true" ng-hide="domainConfigure.advancedVisible">Advanced settings...</a>
<div uib-collapse="!domainConfigure.advancedVisible">
<div ng-show="false">
<label class="control-label">
<input type="checkbox" ng-model="domainConfigure.hyphenatedSubdomains" name="hyphenatedSubdomains" ng-disabled="domainConfigure.busy"/>&nbsp; Hyphenate Subdomains
</label>
<p>When enabled, apps are installed into <code>&lt;location&gt;-&lt;domain&gt;</code></p>
</div>
<div class="form-group">
<label class="control-label">Zone Name (Optional) <sup><a ng-href="{{ config.webServerOrigin }}/documentation/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="domainConfigure.zoneName" name="zoneName" ng-disabled="domainConfigure.busy">
</div>
<div class="form-group">
<label class="control-label">Certificate Provider <sup><a ng-href="{{ config.webServerOrigin }}/documentation/certificates/#certificate-providers" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="domainConfigure.tlsConfig.provider" ng-options="a.value as a.name for a in tlsProvider"></select>
</div>
<!-- Fallback certificate -->
<div ng-show="domainConfigure.tlsConfig.provider !== 'fallback'">
<label class="control-label">Fallback Certificate (optional)</label>
<p>
Certificates are automatically obtained and renewed from <a href="https://letsencrypt.org/" target="_blank">Lets Encrypt</a>. See the current rate limit <a href="https://letsencrypt.org/docs/rate-limits/" target="_blank">here</a>.
This wildcard certificate will be used should getting a Lets Encrypt certificate fail. If not provided, an automatically generated self-signed certificate will be used as fallback.
</p>
</div>
<div ng-show="domainConfigure.tlsConfig.provider === 'fallback'">
<label class="control-label">Custom Certificate</label>
<p>
This <a ng-href="{{ config.webServerOrigin }}/documentation/certificates/#custom-certificates" target="_blank">wildcard certificate</a> will be used for all apps on this domain. If not provided, a self-signed certificate will be automatically generated.
</p>
</div>
<div class="form-group" ng-class="{ 'has-error': (!fallbackCert.cert.$dirty && fallbackCert.error) }">
<div class="input-group">
<input type="file" id="fallbackCertFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Certificate" ng-model="domainConfigure.fallbackCert.certificateFileName" name="cert" onclick="getElementById('fallbackCertFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('fallbackCertFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!fallbackCert.key.$dirty && fallbackCert.error) }">
<div class="input-group">
<input type="file" id="fallbackKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Key" ng-model="domainConfigure.fallbackCert.keyFileName" id="fallbackKeyInput" name="key" onclick="getElementById('fallbackKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('fallbackKeyFileInput').click();"></i>
</span>
</div>
</div>
</div> <!-- advanced -->
<input class="ng-hide" type="submit" ng-disabled="domainConfigureForm.$invalid || domainConfigure.busy"/>
</fieldset>
<input class="ng-hide" type="submit" ng-disabled="domainConfigureForm.$invalid || domainConfigure.busy"/>
</form>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="domainConfigure.submit()" ng-disabled="domainConfigureForm.$invalid || domainConfigure.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="domainConfigure.busy"></i> Save
</button>
<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="domainConfigure.submit()" ng-disabled="domainConfigureForm.$invalid || domainConfigure.busy"><i class="fa fa-circle-notch fa-spin" ng-show="domainConfigure.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- modal domain wellknown -->
<div class="modal fade" id="domainWellKnownModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'domains.domainWellKnown.title' | tr:{ domain: domainWellKnown.domain.domain } }}</h4>
</div>
<div class="modal-body">
<p ng-bind-html="'domains.domainDialog.wellKnownDescription' | tr:{ domain: domainWellKnown.domain.domain, docsLink: 'https://docs.cloudron.io/domains/#well-known-locations' }"></p>
<form name="domainWellKnownForm" role="form" novalidate ng-submit="domainWellKnown.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="domainWellKnown.error">{{ domainWellKnown.error }}</p>
<div class="form-group">
<label class="control-label">{{ 'domains.domainDialog.matrixHostname' | tr }}</label>
<input type="text" class="form-control" ng-model="domainWellKnown.matrixHostname" name="matrixHostname" ng-disabled="domainWellKnown.busy">
</div>
<div class="form-group">
<label class="control-label">{{ 'domains.domainDialog.mastodonHostname' | tr }}</label>
<input type="text" class="form-control" ng-model="domainWellKnown.mastodonHostname" name="mastodonHostname" ng-disabled="domainWellKnown.busy">
</div>
<div class="form-group">
<label class="control-label">{{ 'domains.domainDialog.jitsiHostname' | tr }}</label>
<input type="text" class="form-control" ng-model="domainWellKnown.jitsiHostname" name="jitsiHostname" ng-disabled="domainWellKnown.busy">
</div>
<input class="ng-hide" type="submit" ng-disabled="domainWellKnownForm.$invalid || domainWellKnown.busy"/>
</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="domainWellKnown.submit()" ng-disabled="domainWellKnownForm.$invalid || domainWellKnown.busy"><i class="fa fa-circle-notch fa-spin" ng-show="domainWellKnown.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -193,18 +249,16 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Really remove {{ domainRemove.domain.domain }} ?</h4>
<h4 class="modal-title">{{ 'domains.removeDialog.title' | tr:{ domain: domainRemove.domain.domain } }}</h4>
</div>
<div class="modal-body">
This will delete the domain <code>{{ domainRemove.domain.domain }}</code>.
<div>
<br/>
<span class="has-error" ng-show="domainRemove.error">{{ domainRemove.error }}</span>
</div>
<p ng-bind-html="'domains.removeDialog.description' | tr:{ domain: domainRemove.domain.domain }"></p>
<br/>
<span class="has-error" ng-show="domainRemove.error">{{ domainRemove.error }}</span>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="domainRemove.submit()" ng-disabled="domainRemove.busy"><i class="fa fa-circle-notch fa-spin" ng-show="domainRemove.busy"></i> Remove</button>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-danger" ng-click="domainRemove.submit()" ng-disabled="domainRemove.busy"><i class="fa fa-circle-notch fa-spin" ng-show="domainRemove.busy"></i> {{ 'domains.removeDialog.removeAction' | tr }}</button>
</div>
</div>
</div>
@@ -212,7 +266,7 @@
<div class="content">
<div class="text-left">
<h1>Domains <button class="btn btn-primary btn-outline pull-right" ng-click="domainConfigure.show()"><i class="fa fa-plus"></i> Add Domain</button></h1>
<h1>{{ 'domains.title' | tr }} <button class="btn btn-primary btn-outline pull-right" ng-click="domainAdd.show()"><i class="fa fa-plus"></i> {{ 'domains.addDomain' | tr }}</button></h1>
</div>
<div class="card card-large">
@@ -226,22 +280,23 @@
<table class="table table-hover" style="margin-top: 10px;">
<thead>
<tr>
<th>Domain</th>
<th class="text-left hidden-xs hidden-sm">Provider</th>
<th style="width: 100px" class="text-right">Actions</th>
<th>{{ 'domains.domain' | tr }}</th>
<th class="text-left hidden-xs hidden-sm">{{ 'domains.provider' | tr }}</th>
<th style="width: 100px" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="domain in domains">
<td class="elide-table-cell hand" ng-click="domain.provider !== 'caas' && domainConfigure.show(domain)">
<td class="elide-table-cell hand" ng-click="domainConfigure.show(domain)">
{{ domain.domain }}
</td>
<td class="text-left elide-table-cell hidden-xs hidden-sm hand" ng-click="domain.provider !== 'caas' && domainConfigure.show(domain)">
<td class="text-left elide-table-cell hidden-xs hidden-sm hand" ng-click="domainConfigure.show(domain)">
{{ prettyProviderName(domain) }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="domainConfigure.show(domain)" ng-show="domain.provider !== 'caas'" title="Edit Domain"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="domainRemove.show(domain)" ng-show="domain.provider !== 'caas'" title="Remove Domain"><i class="far fa-trash-alt"></i></button>
<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>
</td>
</tr>
</tbody>
@@ -251,15 +306,13 @@
</div>
<div class="text-left">
<h3>Renew certificates</h3>
<h3>{{ 'domains.renewCerts.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-md-12">
<p>
Cloudron renews Let's Encrypt certificates automatically. Use this option to trigger a renewal immediately.
</p>
<p ng-bind-html="'domains.renewCerts.description' | tr"></p>
</div>
</div>
@@ -272,30 +325,56 @@
</div>
<div class="row">
<div class="col-md-6">
<div class="col-md-12">
<p ng-show="renewCerts.busy">{{ renewCerts.message }}</p>
<p ng-hide="renewCerts.busy">
<div class="has-error" ng-show="!renewCerts.active">{{ renewCerts.errorMessage }}</div>
</p>
</div>
<div class="col-md-6 text-right">
<button class="btn btn-outline btn-primary" ng-click="renewCerts.renew()" ng-disabled="renewCerts.busy" style="margin-right: 10px">Renew All Certs</button>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{renewCerts.taskId}}" ng-disabled="!renewCerts.taskId" target="_blank">Show Logs</a>
<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>
</div>
</div>
</div>
<div class="text-left">
<h3>Change Dashboard Domain</h3>
<h3>{{ 'domains.syncDns.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-md-8">
<p>
This will move the dashboard to the <code>my</code>subdomain of the selected domain. Email server will be reconfigured to
send notifications from this domain.
</p>
<div class="col-md-12">
<p ng-bind-html="'domains.syncDns.description' | tr"></p>
</div>
</div>
<div class="row">
<div class="col-md-12" style="margin-bottom: 10px;">
<div ng-show="syncDns.busy" class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ syncDns.percent }}%"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<p ng-show="syncDns.busy">{{ syncDns.message }}</p>
<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>
</div>
</div>
</div>
<div class="text-left">
<h3>{{ 'domains.changeDashboardDomain.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-md-8">
<p ng-bind-html="'domains.changeDashboardDomain.description' | tr"></p>
</div>
<div class="col-md-4">
<select class="form-control pull-right" style="display: inline-block; width: 200px;" ng-model="changeDashboard.selectedDomain" ng-options="a.domain for a in domains"></select>
@@ -318,9 +397,9 @@
</p>
</div>
<div class="col-md-6 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">Change Domain</button>
<button class="btn btn-outline btn-danger" ng-click="changeDashboard.stop()" ng-show="changeDashboard.busy" style="margin-right: 10px">Cancel</button>
<a class="btn btn-primary pull-right" ng-href="/logs.html?taskId={{changeDashboard.taskId}}" ng-show="changeDashboard.busy" target="_blank">Show Logs</a>
<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>
+221 -31
View File
@@ -1,8 +1,8 @@
'use strict';
/* global asyncForEach:false */
/* global angular:false */
/* global $:false */
/* global async */
/* global angular */
/* global $, TASK_TYPES */
angular.module('Application').controller('DomainsController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
@@ -11,6 +11,15 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domains = [];
$scope.ready = false;
$scope.translationLinks = {
linodeDocsLink: 'https://docs.cloudron.io/domains/#linode-dns',
customCertLink: 'https://docs.cloudron.io/certificates/#custom-certificates'
};
$scope.openSubscriptionSetup = function () {
Client.openSubscriptionSetup($scope.$parent.subscription);
};
// currently, validation of wildcard with various provider is done server side
$scope.tlsProvider = [
{ name: 'Let\'s Encrypt Prod', value: 'letsencrypt-prod' },
@@ -28,8 +37,11 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
{ name: 'Gandi LiveDNS', value: 'gandi' },
{ name: 'GoDaddy', value: 'godaddy' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Linode', value: 'linode' },
{ name: 'Name.com', value: 'namecom' },
{ name: 'Namecheap', value: 'namecheap' },
{ name: 'Netcup', value: 'netcup' },
{ name: 'Vultr', value: 'vultr' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
@@ -37,19 +49,21 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.prettyProviderName = function (domain) {
switch (domain.provider) {
case 'caas': return 'Managed Cloudron';
case 'route53': return 'AWS Route53';
case 'cloudflare': return 'Cloudflare';
case 'digitalocean': return 'DigitalOcean';
case 'gandi': return 'Gandi LiveDNS';
case 'namecom': return 'Name.com';
case 'namecheap': return 'Namecheap';
case 'gcdns': return 'Google Cloud';
case 'godaddy': return 'GoDaddy';
case 'manual': return 'Manual';
case 'wildcard': return 'Wildcard';
case 'noop': return 'No-op';
default: return 'Unknown';
case 'route53': return 'AWS Route53';
case 'cloudflare': return 'Cloudflare';
case 'digitalocean': return 'DigitalOcean';
case 'gandi': return 'Gandi LiveDNS';
case 'linode': return 'Linode';
case 'namecom': return 'Name.com';
case 'namecheap': return 'Namecheap';
case 'netcup': return 'Netcup';
case 'gcdns': return 'Google Cloud';
case 'godaddy': return 'GoDaddy';
case 'vultr': return 'Vultr';
case 'manual': return 'Manual';
case 'wildcard': return 'Wildcard';
case 'noop': return 'No-op';
default: return 'Unknown';
}
};
@@ -80,7 +94,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
Client.getDomains(function (error, results) {
if (error) return console.error(error);
asyncForEach(results, function (result, iteratorDone) {
async.eachSeries(results, function (result, iteratorDone) {
Client.getDomain(result.domain, function (error, domain) {
if (error) return iteratorDone(error);
@@ -99,6 +113,106 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
});
}
$scope.domainAdd = {
show: function () {
if ($scope.config.features.domainMaxCount && $scope.config.features.domainMaxCount <= $scope.domains.length) {
$('#subscriptionRequiredModal').modal('show');
return;
}
$scope.domainConfigure.show();
}
};
$scope.domainWellKnown = {
busy: false,
error: null,
domain: null,
mastodonHostname: '',
matrixHostname: '',
jitsiHostname: '',
reset: function () {
$scope.domainWellKnown.busy = false;
$scope.domainWellKnown.error = null;
$scope.domainWellKnown.domain = null;
$scope.domainWellKnown.matrixHostname = '';
$scope.domainWellKnown.mastodonHostname = '';
$scope.domainWellKnown.jitsiHostname = '';
},
show: function (domain) {
$scope.domainWellKnown.reset();
$scope.domainWellKnown.domain = domain;
try {
if (domain.wellKnown && domain.wellKnown['matrix/server']) {
$scope.domainWellKnown.matrixHostname = JSON.parse(domain.wellKnown['matrix/server'])['m.server'];
}
if (domain.wellKnown && domain.wellKnown['host-meta']) {
$scope.domainWellKnown.mastodonHostname = domain.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
}
if (domain.wellKnown && domain.wellKnown['matrix/client']) {
let parsed = JSON.parse(domain.wellKnown['matrix/client']);
if (parsed['im.vector.riot.jitsi'] && parsed['im.vector.riot.jitsi']['preferredDomain']) {
$scope.domainWellKnown.jitsiHostname = parsed['im.vector.riot.jitsi']['preferredDomain'];
}
}
} catch (e) {
console.error(e);
}
$('#domainWellKnownModal').modal('show');
},
submit: function () {
$scope.domainWellKnown.busy = true;
$scope.domainWellKnown.error = null;
var wellKnown = {};
if ($scope.domainWellKnown.matrixHostname) {
wellKnown['matrix/server'] = JSON.stringify({ 'm.server': $scope.domainWellKnown.matrixHostname });
// https://matrix.org/docs/spec/client_server/latest#get-well-known-matrix-client
wellKnown['matrix/client'] = JSON.stringify({
'm.homeserver': {
'base_url': 'https://' + $scope.domainWellKnown.matrixHostname
},
'im.vector.riot.jitsi': {
'preferredDomain': $scope.domainWellKnown.jitsiHostname
}
});
} else if ($scope.domainWellKnown.jitsiHostname) { // only if matrixHostname is not set
wellKnown['matrix/client'] = JSON.stringify({
'im.vector.riot.jitsi': {
'preferredDomain': $scope.domainWellKnown.jitsiHostname
}
});
}
if ($scope.domainWellKnown.mastodonHostname) {
wellKnown['host-meta'] = '<?xml version="1.0" encoding="UTF-8"?>\n'
+ '<XRD xmlns="http://docs.oasis-open.org/ns/xri/xrd-1.0">\n'
+ '<Link rel="lrdd" type="application/xrd+xml" template="https://' + $scope.domainWellKnown.mastodonHostname + '/.well-known/webfinger?resource={uri}"/>\n'
+ '</XRD>';
}
Client.updateDomainWellKnown($scope.domainWellKnown.domain.domain, wellKnown, function (error) {
$scope.domainWellKnown.busy = false;
if (error) {
$scope.domainWellKnown.error = error.message;
return;
}
$('#domainWellKnownModal').modal('hide');
$scope.domainWellKnown.reset();
refreshDomains();
});
}
};
// We reused configure also for adding domains to avoid much code duplication
$scope.domainConfigure = {
adding: false,
@@ -119,13 +233,17 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
cloudflareToken: '',
cloudflareEmail: '',
cloudflareTokenType: 'GlobalApiKey',
linodeToken: '',
vultrToken: '',
nameComToken: '',
nameComUsername: '',
namecheapUsername: '',
namecheapApiKey: '',
netcupCustomerNumber: '',
netcupApiKey: '',
netcupApiPassword: '',
provider: 'route53',
zoneName: '',
hyphenatedSubdomains: false,
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
@@ -160,12 +278,16 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.gcdnsKey.content = '';
if (domain.provider === 'gcdns') {
$scope.domainConfigure.gcdnsKey.keyFileName = domain.config.credentials && domain.config.credentials.client_email;
$scope.domainConfigure.gcdnsKey.content = JSON.stringify({
project_id: domain.config.projectId,
credentials: domain.config.credentials
client_email: domain.config.credentials.client_email,
private_key: domain.config.credentials.private_key
});
}
$scope.domainConfigure.digitalOceanToken = domain.provider === 'digitalocean' ? domain.config.token : '';
$scope.domainConfigure.linodeToken = domain.provider === 'linode' ? domain.config.token : '';
$scope.domainConfigure.vultrToken = domain.provider === 'vultr' ? 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 : '';
@@ -180,6 +302,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.namecheapApiKey = domain.provider === 'namecheap' ? domain.config.token : '';
$scope.domainConfigure.namecheapUsername = domain.provider === 'namecheap' ? domain.config.username : '';
$scope.domainConfigure.netcupCustomerNumber = domain.provider === 'netcup' ? domain.config.customerNumber : '';
$scope.domainConfigure.netcupApiKey = domain.provider === 'netcup' ? domain.config.apiKey : '';
$scope.domainConfigure.netcupApiPassword = domain.provider === 'netcup' ? domain.config.apiPassword : '';
$scope.domainConfigure.provider = domain.provider;
$scope.domainConfigure.tlsConfig.provider = domain.tlsConfig.provider;
@@ -187,8 +313,6 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
if (domain.tlsConfig.wildcard) $scope.domainConfigure.tlsConfig.provider += '-wildcard';
}
$scope.domainConfigure.zoneName = domain.zoneName;
$scope.domainConfigure.hyphenatedSubdomains = !!domain.config.hyphenatedSubdomains;
} else {
$scope.domainConfigure.adding = true;
}
@@ -212,8 +336,8 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
var serviceAccountKey = JSON.parse($scope.domainConfigure.gcdnsKey.content);
data.projectId = serviceAccountKey.project_id;
data.credentials = {
client_email: serviceAccountKey.credentials.client_email,
private_key: serviceAccountKey.credentials.private_key
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!data.projectId || !data.credentials || !data.credentials.client_email || !data.credentials.private_key) {
@@ -226,6 +350,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
}
} else if (provider === 'digitalocean') {
data.token = $scope.domainConfigure.digitalOceanToken;
} else if (provider === 'linode') {
data.token = $scope.domainConfigure.linodeToken;
} else if (provider === 'vultr') {
data.token = $scope.domainConfigure.vultrToken;
} else if (provider === 'gandi') {
data.token = $scope.domainConfigure.gandiApiKey;
} else if (provider === 'godaddy') {
@@ -241,10 +369,12 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
} else if (provider === 'namecheap') {
data.token = $scope.domainConfigure.namecheapApiKey;
data.username = $scope.domainConfigure.namecheapUsername;
} else if (provider === 'netcup') {
data.customerNumber = $scope.domainConfigure.netcupCustomerNumber;
data.apiKey = $scope.domainConfigure.netcupApiKey;
data.apiPassword = $scope.domainConfigure.netcupApiPassword;
}
data.hyphenatedSubdomains = $scope.domainConfigure.hyphenatedSubdomains;
var fallbackCertificate = null;
if ($scope.domainConfigure.fallbackCert.certificateFile && $scope.domainConfigure.fallbackCert.keyFile) {
fallbackCertificate = {
@@ -265,7 +395,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
// choose the right api, since we reuse this for adding and configuring domains
var func;
if ($scope.domainConfigure.adding) func = Client.addDomain.bind(Client, $scope.domainConfigure.newDomain, $scope.domainConfigure.zoneName, provider, data, fallbackCertificate, tlsConfig);
else func = Client.updateDomain.bind(Client, $scope.domainConfigure.domain.domain, $scope.domainConfigure.zoneName, provider, data, fallbackCertificate, tlsConfig);
else func = Client.updateDomainConfig.bind(Client, $scope.domainConfigure.domain.domain, $scope.domainConfigure.zoneName, provider, data, fallbackCertificate, tlsConfig);
func(function (error) {
$scope.domainConfigure.busy = false;
@@ -305,12 +435,14 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.domainConfigure.nameComUsername = '';
$scope.domainConfigure.namecheapApiKey = '';
$scope.domainConfigure.namecheapUsername = '';
$scope.domainConfigure.netcupCustomerNumber = '';
$scope.domainConfigure.netcupApiKey = '';
$scope.domainConfigure.netcupApiPassword = '';
$scope.domainConfigure.vultrToken = '';
$scope.domainConfigure.tlsConfig.provider = 'letsencrypt-prod';
$scope.domainConfigure.zoneName = '';
$scope.domainConfigure.hyphenatedSubdomains = false;
$scope.domainConfigureForm.$setPristine();
$scope.domainConfigureForm.$setUntouched();
}
@@ -324,7 +456,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
taskId: '',
checkStatus: function () {
Client.getLatestTaskByType('renewcerts', function (error, task) {
Client.getLatestTaskByType(TASK_TYPES.TASK_RENEW_CERTS, function (error, task) {
if (error) return console.error(error);
if (!task) return;
@@ -360,7 +492,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
$scope.renewCerts.message = '';
$scope.renewCerts.errorMessage = '';
Client.renewCerts(null /* all domains */, function (error, taskId) {
Client.renewCerts(function (error, taskId) {
if (error) {
console.error(error);
$scope.renewCerts.errorMessage = error.message;
@@ -374,6 +506,64 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
}
};
$scope.syncDns = {
busy: false,
percent: 0,
message: '',
errorMessage: '',
taskId: '',
checkStatus: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_SYNC_DNS_RECORDS, function (error, task) {
if (error) return console.error(error);
if (!task) return;
$scope.syncDns.taskId = task.id;
$scope.syncDns.updateStatus();
});
},
updateStatus: function () {
Client.getTask($scope.syncDns.taskId, function (error, data) {
if (error) return window.setTimeout($scope.syncDns.updateStatus, 5000);
if (!data.active) {
$scope.syncDns.busy = false;
$scope.syncDns.message = '';
$scope.syncDns.percent = 100; // indicates that 'result' is valid
$scope.syncDns.errorMessage = data.success ? '' : data.error.message;
return;
}
$scope.syncDns.busy = true;
$scope.syncDns.percent = data.percent;
$scope.syncDns.message = data.message;
window.setTimeout($scope.syncDns.updateStatus, 500);
});
},
sync: function () {
$scope.syncDns.busy = true;
$scope.syncDns.percent = 0;
$scope.syncDns.message = '';
$scope.syncDns.errorMessage = '';
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.domainRemove = {
busy: false,
error: null,
@@ -517,7 +707,7 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
// setup all the dialog focus handling
['domainConfigureModal', 'domainRemoveModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
$(this).find('[autofocus]:first').focus();
});
});
+565 -404
View File
File diff suppressed because it is too large Load Diff
+402 -81
View File
@@ -1,23 +1,47 @@
'use strict';
/* global angular:false */
/* global $:false */
/* global angular */
/* global $ */
/* global async */
angular.module('Application').controller('EmailController', ['$scope', '$location', '$timeout', '$routeParams', 'Client', function ($scope, $location, $timeout, $routeParams, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
angular.module('Application').controller('EmailController', ['$scope', '$location', '$translate', '$timeout', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $route, $routeParams, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastMailManager) $location.path('/'); });
$scope.user = Client.getUserInfo();
// Avoid full reload on path change
// https://stackoverflow.com/a/22614334
// reloadOnUrl: false in $routeProvider did not work!
var lastRoute = $route.current;
$scope.$on('$locationChangeSuccess', function (/* event */) {
if (lastRoute.$$route.originalPath === $route.current.$$route.originalPath) {
$route.current = lastRoute;
}
});
var domainName = $routeParams.domain;
if (!domainName) return $location.path('/email');
$scope.setView = function (view, setAlways) {
if (!setAlways && !$scope.ready) return;
if ($scope.view === view) return;
$route.updateParams({ view: view });
$scope.view = view;
$scope.activeTab = view;
};
$scope.ready = false;
$scope.refreshBusy = true;
$scope.client = Client;
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.apps = Client.getInstalledApps();
$scope.users = [];
$scope.owners = []; // users + groups
$scope.incomingDomains = [];
$scope.domain = null;
$scope.adminDomain = null;
$scope.diskUsage = {};
$scope.expectedDnsRecords = {
mx: { },
@@ -26,6 +50,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
dmarc: { },
ptr: { }
};
$scope.expectedDnsRecordsTypes = [
{ name: 'MX', value: 'mx' },
{ name: 'DKIM', value: 'dkim' },
@@ -34,8 +59,13 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
{ name: 'PTR', value: 'ptr' }
];
$scope.openSubscriptionSetup = function () {
Client.openSubscriptionSetup($scope.$parent.subscription);
};
$scope.catchall = {
mailboxes: [],
availableMailboxes: [],
busy: false,
submit: function () {
@@ -51,9 +81,15 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
},
refresh: function () {
$scope.catchall.mailboxes = $scope.domain.mailConfig.catchAll.map(function (name) {
return $scope.mailboxes.mailboxes.find(function (m) { return m.name === name; });
}).filter(function (m) { return !!m; });
Client.listMailboxes($scope.domain.domain, '', 1, 1000, function (error, result) {
if (error) return console.error(error);
$scope.catchall.availableMailboxes = result;
$scope.catchall.mailboxes = $scope.domain.mailConfig.catchAll.map(function (name) {
return $scope.catchall.availableMailboxes.find(function (m) { return m.name === name; });
}).filter(function (m) { return !!m; });
});
}
};
@@ -61,12 +97,15 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
busy: false,
mailinglists: [],
search: '',
currentPage: 1,
perPage: 10,
add: {
busy: false,
error: {},
name: '',
membersTxt: '',
membersOnly: false,
reset: function () {
$scope.mailinglists.add.busy = false;
@@ -88,7 +127,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
.map(function (m) { return m.trim(); })
.filter(function (m) { return m.length !== 0; });
Client.addMailingList($scope.domain.domain, $scope.mailinglists.add.name, members, function (error) {
Client.addMailingList($scope.domain.domain, $scope.mailinglists.add.name, members, $scope.mailinglists.add.membersOnly, function (error) {
$scope.mailinglists.add.busy = false;
$scope.mailinglists.add.error = {};
@@ -114,10 +153,14 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
error: {},
name: '',
membersTxt: '',
membersOnly: false,
active: true,
show: function (list) {
$scope.mailinglists.edit.name = list.name;
$scope.mailinglists.edit.membersTxt = list.members.sort().join('\n');
$scope.mailinglists.edit.membersOnly = list.membersOnly;
$scope.mailinglists.edit.active = list.active;
$('#mailinglistEditModal').modal('show');
},
@@ -129,7 +172,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
.map(function (m) { return m.trim(); })
.filter(function (m) { return m.length !== 0; });
Client.updateMailingList($scope.domain.domain, $scope.mailinglists.edit.name, members, function (error) {
Client.updateMailingList($scope.domain.domain, $scope.mailinglists.edit.name, members, $scope.mailinglists.edit.membersOnly, $scope.mailinglists.edit.active, function (error) {
$scope.mailinglists.edit.busy = false;
$scope.mailinglists.edit.error = {};
@@ -174,13 +217,29 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
refresh: function (callback) {
callback = typeof callback === 'function' ? callback : function (error) { if (error) return console.error(error); };
Client.listMailingLists($scope.domain.domain, function (error, result) {
Client.listMailingLists($scope.domain.domain, $scope.mailinglists.search, $scope.mailinglists.currentPage, $scope.mailinglists.perPage, function (error, result) {
if (error) return callback(error);
$scope.mailinglists.mailinglists = result;
callback();
});
},
showNextPage: function () {
$scope.mailinglists.currentPage++;
$scope.mailinglists.refresh();
},
showPrevPage: function () {
if ($scope.mailinglists.currentPage > 1) $scope.mailinglists.currentPage--;
else $scope.mailinglists.currentPage = 1;
$scope.mailinglists.refresh();
},
updateFilter: function (fresh) {
if (fresh) $scope.mailinglists.currentPage = 1;
$scope.mailinglists.refresh();
}
};
@@ -205,6 +264,29 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
};
$scope.banner = {
busy: false,
text: '',
html: '',
submit: function () {
$scope.banner.busy = true;
Client.setMailBanner($scope.domain.domain, { text: $scope.banner.text, html: $scope.banner.html }, function (error) {
if (error) {
$scope.banner.busy = false;
return console.error(error);
}
// give sometime for the mail container to restart
$timeout(function () {
$scope.banner.busy = false;
$scope.refreshDomain();
}, 5000);
});
}
};
$scope.incomingEmail = {
busy: false,
setupDns: true,
@@ -221,7 +303,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
setDnsRecords: function (callback) {
$scope.incomingEmail.setupDnsBusy = true;
Client.setDnsRecords($scope.domain.domain, function (error) {
Client.setDnsRecords({ domain: $scope.domain.domain, type: 'mail' }, function (error) {
if (error) console.error(error);
$timeout(function () { $scope.incomingEmail.setupDnsBusy = false; }, 2000); // otherwise, it's too fast
@@ -245,9 +327,10 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
maybeSetupDns(function (error) {
if (error) return console.error(error);
$scope.refreshDomain();
$scope.incomingEmail.busy = false;
$timeout(function () {
$scope.refreshDomain();
$scope.incomingEmail.busy = false;
}, 5000); // wait for mail container to restart. it cannot get IP otherwise while refreshing
});
});
},
@@ -261,15 +344,186 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
if (error) return console.error(error);
$scope.reconfigureEmailApps();
$scope.refreshDomain();
$scope.incomingEmail.busy = false;
$timeout(function () {
$scope.refreshDomain();
$scope.incomingEmail.busy = false;
}, 5000); // wait for mail container to restart. it cannot get IP otherwise while refreshing
});
}
};
$scope.mailboxImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
mailboxes: [],
reset: function () {
$scope.mailboxImport.busy = false;
$scope.mailboxImport.error = null;
$scope.mailboxImport.mailboxes = [];
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
},
handleFileChanged: function () {
$scope.mailboxImport.reset();
var fileInput = document.getElementById('mailboxImportFileInput');
if (!fileInput.files || !fileInput.files[0]) return;
var file = fileInput.files[0];
if (file.type !== 'application/json' && file.type !== 'text/csv') return console.log('Unsupported file type.');
const reader = new FileReader();
reader.addEventListener('load', function () {
$scope.$apply(function () {
$scope.mailboxImport.mailboxes = [];
var mailboxes = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.mailboxImport.error = { file: 'Imported file has no lines' };
for (var i = 0; i < lines.length; i++) {
var line = lines[i].trim();
if (!line) continue;
var items = line.split(',');
if (items.length !== 4) {
$scope.mailboxImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 4' };
return;
}
mailboxes.push({
name: items[0].trim(),
domain: items[1].trim(),
owner: items[2].trim(),
ownerType: items[3].trim(),
});
}
} else {
try {
mailboxes = JSON.parse(reader.result).map(function (mailbox) {
return {
name: mailbox.name,
domain: mailbox.domain,
owner: mailbox.owner,
ownerType: mailbox.ownerType
};
});
} catch (e) {
console.error('Failed to parse mailboxes.', e);
$scope.mailboxImport.error = { file: 'Imported file is not valid JSON' };
}
}
$scope.mailboxImport.mailboxes = mailboxes;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.mailboxImport.reset();
// named so no duplactes
document.getElementById('mailboxImportFileInput').addEventListener('change', $scope.mailboxImport.handleFileChanged);
$('#mailboxImportModal').modal('show');
},
openFileInput: function () {
$('#mailboxImportFileInput').click();
},
import: function () {
$scope.mailboxImport.percent = 0;
$scope.mailboxImport.success = 0;
$scope.mailboxImport.done = false;
$scope.mailboxImport.error = { import: [] };
$scope.mailboxImport.busy = true;
var processed = 0;
async.eachSeries($scope.mailboxImport.mailboxes, function (mailbox, callback) {
var owner = $scope.owners.find(function (o) { return o.display === mailbox.owner && o.type === mailbox.ownerType; }); // owner may not exist
if (!owner) {
$scope.mailboxImport.error.import.push({ error: new Error('Could not detect owner'), mailbox: mailbox });
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
return callback();
}
Client.addMailbox(mailbox.domain, mailbox.name, owner.id, mailbox.ownerType, function (error) {
if (error) $scope.mailboxImport.error.import.push({ error: error, mailbox: mailbox });
else ++$scope.mailboxImport.success;
++processed;
$scope.mailboxImport.percent = 100 * processed / $scope.mailboxImport.mailboxes.length;
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.mailboxImport.busy = false;
$scope.mailboxImport.done = true;
if ($scope.mailboxImport.success) $scope.mailboxes.refresh();
});
}
};
$scope.mailboxExport = function (type) {
// FIXME only does first 10k mailboxes
Client.listMailboxes($scope.domain.domain, '', 1, 10000, function (error, result) {
if (error) {
Client.error('Failed to list mailboxes. Full error in the webinspector.');
return console.error('Failed to list mailboxes.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
return {
name: mailbox.name,
domain: mailbox.domain,
owner: owner ? owner.display : '', // this meta property is set when we get the user list
ownerType: owner ? owner.type : '',
active: mailbox.active,
aliases: mailbox.aliases
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (mailbox) {
var owner = $scope.owners.find(function (o) { return o.id === mailbox.ownerId; }); // owner may not exist
var aliases = mailbox.aliases.map(function (a) { return a.name + '@' + a.domain; }).join(' ');
return [ mailbox.name, mailbox.domain, owner ? owner.display : '', owner ? owner.type : '', aliases, mailbox.active ].join(',');
}).join('\n');
} else {
return;
}
var file = new Blob([ content ], { type: type === 'json' ? 'application/json' : 'text/csv' });
var a = document.createElement('a');
a.href = URL.createObjectURL(file);
a.download = $scope.domain.domain.replaceAll('.','_') + '-mailboxes.' + type;
document.body.appendChild(a);
a.click();
});
};
$scope.mailboxes = {
mailboxes: [],
search: '',
currentPage: 1,
perPage: 10,
add: {
error: null,
@@ -285,6 +539,11 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
},
show: function () {
if ($scope.config.features.mailboxMaxCount && $scope.config.features.mailboxMaxCount <= $scope.mailboxes.mailboxes.length) {
$('#subscriptionRequiredModal').modal('show');
return;
}
$scope.mailboxes.add.reset();
$('#mailboxAddModal').modal('show');
},
@@ -292,21 +551,17 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
submit: function () {
$scope.mailboxes.add.busy = true;
Client.addMailbox($scope.domain.domain, $scope.mailboxes.add.name, $scope.mailboxes.add.owner.id, function (error) {
Client.addMailbox($scope.domain.domain, $scope.mailboxes.add.name, $scope.mailboxes.add.owner.id, $scope.mailboxes.add.owner.type, function (error) {
if (error) {
$scope.mailboxes.add.busy = false;
$scope.mailboxes.add.error = error;
return;
}
$scope.mailboxes.add.reset();
$scope.mailboxes.refresh(function (error) {
if (error) return console.error(error);
$scope.mailboxes.refresh();
$scope.catchall.refresh();
$scope.catchall.refresh();
$('#mailboxAddModal').modal('hide');
});
$('#mailboxAddModal').modal('hide');
});
}
},
@@ -316,12 +571,32 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
error: null,
name: '',
owner: null,
aliases: '',
incomingDomains: [],
aliases: [],
active: true,
enablePop3: false,
addAlias: function (event) {
event.preventDefault();
$scope.mailboxes.edit.aliases.push({
name: '',
domain: domainName,
reversedSortingNotation: 'z'.repeat(100) // quick and dirty to ensure newly added are on bottom
});
},
delAlias: function (event, index) {
event.preventDefault();
$scope.mailboxes.edit.aliases.splice(index, 1);
},
show: function (mailbox) {
$scope.mailboxes.edit.name = mailbox.name;
$scope.mailboxes.edit.owner = mailbox.owner; // this can be null if mailbox had no owner
$scope.mailboxes.edit.aliases = mailbox.aliases;
$scope.mailboxes.edit.aliases = angular.copy(mailbox.aliases, []).map(function (a) { a.reversedSortingNotation = a.domain + '@' + a.name; return a; });
$scope.mailboxes.edit.active = mailbox.active;
$scope.mailboxes.edit.enablePop3 = mailbox.enablePop3;
$('#mailboxEditModal').modal('show');
},
@@ -329,17 +604,22 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
submit: function () {
$scope.mailboxes.edit.busy = true;
var data = {
ownerId: $scope.mailboxes.edit.owner.id,
ownerType: $scope.mailboxes.edit.owner.type,
active: $scope.mailboxes.edit.active,
enablePop3: $scope.mailboxes.edit.enablePop3
};
// $scope.mailboxes.edit.owner is expected to be validated by the UI
Client.updateMailbox($scope.domain.domain, $scope.mailboxes.edit.name, $scope.mailboxes.edit.owner.id, function (error) {
Client.updateMailbox($scope.domain.domain, $scope.mailboxes.edit.name, data, function (error) {
if (error) {
$scope.mailboxes.edit.error = error;
$scope.mailboxes.edit.busy = false;
return;
}
var aliases = $scope.mailboxes.edit.aliases.split(',').map(function (a) { return a.trim(); }).filter(function (a) { return !!a; });
Client.setAliases($scope.domain.domain, $scope.mailboxes.edit.name, aliases, function (error) {
Client.setAliases($scope.mailboxes.edit.name, $scope.domain.domain, $scope.mailboxes.edit.aliases, function (error) {
if (error) {
$scope.mailboxes.edit.error = error;
$scope.mailboxes.edit.busy = false;
@@ -350,7 +630,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$scope.mailboxes.edit.error = null;
$scope.mailboxes.edit.name = '';
$scope.mailboxes.edit.owner = null;
$scope.mailboxes.edit.aliases = '';
$scope.mailboxes.edit.aliases = [];
$scope.mailboxes.refresh();
$('#mailboxEditModal').modal('hide');
@@ -362,6 +642,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
remove: {
busy: false,
mailbox: null,
deleteMails: true,
show: function (mailbox) {
$scope.mailboxes.remove.mailbox = mailbox;
@@ -372,7 +653,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
submit: function () {
$scope.mailboxes.remove.busy = true;
Client.removeMailbox($scope.domain.domain, $scope.mailboxes.remove.mailbox.name, function (error) {
Client.removeMailbox($scope.domain.domain, $scope.mailboxes.remove.mailbox.name, $scope.mailboxes.remove.deleteMails, function (error) {
$scope.mailboxes.remove.busy = false;
if (error) return console.error(error);
@@ -391,32 +672,37 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
refresh: function (callback) {
callback = typeof callback === 'function' ? callback : function (error) { if (error) return console.error(error); };
Client.getMailboxes($scope.domain.domain, function (error, mailboxes) {
Client.listMailboxes($scope.domain.domain, $scope.mailboxes.search, $scope.mailboxes.currentPage, $scope.mailboxes.perPage, function (error, mailboxes) {
if (error) return callback(error);
Client.listAliases($scope.domain.domain, function (error, aliases) {
if (error) return callback(error);
mailboxes.forEach(function (m) {
m.owner = $scope.owners.find(function (o) { return o.id === m.ownerId; }); // owner may not exist
m.ownerDisplayName = m.owner ? m.owner.display : ''; // this meta property is set when we get the user list
$scope.mailboxes.mailboxes = mailboxes.map(function (m) {
m.aliases = aliases.filter(function (a) { return a.aliasTarget === m.name; }).map(function (a) { return a.name; }).join(',');
m.owner = $scope.users.find(function (u) { return u.id === m.ownerId; }); // owner may not exist
m.ownerDisplayName = m.owner ? m.owner.display : ''; // this meta property is set when we get the user list
return m;
});
Client.getMailUsage($scope.domain.domain, function (error, usage) {
if (error) return callback(error);
$scope.mailboxes.mailboxes.forEach(function (m) {
var u = usage[m.name + '@' + m.domain]; // this is unset when no emails have been received yet
m.usage = (u && u.size) || 0;
});
callback();
});
var u = $scope.diskUsage[m.name + '@' + m.domain]; // this is unset when no emails have been received yet
m.usage = (u && u.size) || 0;
});
$scope.mailboxes.mailboxes = mailboxes;
callback();
});
},
showNextPage: function () {
$scope.mailboxes.currentPage++;
$scope.mailboxes.refresh();
},
showPrevPage: function () {
if ($scope.mailboxes.currentPage > 1) $scope.mailboxes.currentPage--;
else $scope.mailboxes.currentPage = 1;
$scope.mailboxes.refresh();
},
updateFilter: function (fresh) {
if (fresh) $scope.mailboxes.currentPage = 1;
$scope.mailboxes.refresh();
}
};
@@ -425,6 +711,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
{ provider: 'external-smtp', name: 'External SMTP server', host: '', port: 587 },
{ provider: 'external-smtp-noauth', name: 'External SMTP server (No Authentication)', host: '', port: 587 },
{ provider: 'ses-smtp', name: 'Amazon SES', host: 'email-smtp.us-east-1.amazonaws.com', port: 587, spfDoc: 'https://docs.aws.amazon.com/ses/latest/DeveloperGuide/spf.html' },
{ provider: 'elasticemail-smtp', name: 'Elastic Email', host: 'smtp.elasticemail.com', port: 587, spfDoc: 'https://elasticemail.com/blog/marketing_tips/common-spf-errors' },
{ provider: 'google-smtp', name: 'Google', host: 'smtp.gmail.com', port: 587, spfDoc: 'https://support.google.com/a/answer/33786?hl=en' },
{ provider: 'mailgun-smtp', name: 'Mailgun', host: 'smtp.mailgun.org', port: 587, spfDoc: 'https://www.mailgun.com/blog/white-labeling-dns-records-your-customers-tips-tricks' },
{ provider: 'mailjet-smtp', name: 'Mailjet', host: '', port: 587, spfDoc: 'https://app.mailjet.com/docs/spf-dkim-guide' },
@@ -447,6 +734,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
|| provider === 'ses-smtp'
|| provider === 'google-smtp'
|| provider === 'mailgun-smtp'
|| provider === 'elasticemail-smtp'
|| provider === 'mailjet-smtp';
};
@@ -456,6 +744,17 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
busy: false,
preset: $scope.mailRelayPresets[0],
// form data to be set on load
relay: {
provider: 'cloudron-smtp',
host: '',
port: 25,
username: '',
password: '',
serverApiToken: '',
acceptSelfSignedCerts: false
},
presetChanged: function () {
$scope.mailRelay.error = null;
@@ -468,17 +767,6 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$scope.mailRelay.relay.acceptSelfSignedCerts = false;
},
// form data to be set on load
relay: {
provider: 'cloudron-smtp',
host: '',
port: 25,
username: '',
password: '',
serverApiToken: '',
acceptSelfSignedCerts: false
},
submit: function () {
$scope.mailRelay.error = null;
$scope.mailRelay.busy = true;
@@ -488,13 +776,15 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
provider: $scope.mailRelay.relay.provider,
host: $scope.mailRelay.relay.host,
port: $scope.mailRelay.relay.port,
acceptSelfSignedCerts: $scope.mailRelay.relay.acceptSelfSignedCerts
acceptSelfSignedCerts: $scope.mailRelay.relay.acceptSelfSignedCerts,
forceFromAddress: false
};
// fill in provider specific username/password usage
if (data.provider === 'postmark-smtp') {
data.username = $scope.mailRelay.relay.serverApiToken;
data.password = $scope.mailRelay.relay.serverApiToken;
data.forceFromAddress = true; // postmark requires the "From:" in mail to be a Sender Signature
} else if (data.provider === 'sendgrid-smtp') {
data.username = 'apikey';
data.password = $scope.mailRelay.relay.serverApiToken;
@@ -566,7 +856,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$location.path('/email/' + $scope.domain.domain, false);
};
// this is required because we need to rewrite the MAIL_DOMAINS env var
// this is required because we need to rewrite the CLOUDRON_MAIL_SERVER_HOST env var
$scope.reconfigureEmailApps = function () {
var installedApps = Client.getInstalledApps();
for (var i = 0; i < installedApps.length; i++) {
@@ -584,6 +874,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
resetDnsRecords();
Client.getMailConfigForDomain(domainName, function (error, mailConfig) {
if (error && error.statusCode === 404) return $location.path('/email');
if (error) {
$scope.refreshBusy = false;
return console.error(error);
@@ -616,13 +907,19 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
}
$scope.banner.text = mailConfig.banner.text || '';
$scope.banner.html = mailConfig.banner.html || '';
// amend to selected domain to be available for the UI
$scope.domain.mailConfig = mailConfig;
$scope.domain.mailStatus = {};
$scope.mailboxes.refresh(function (error) {
Client.getMailUsage($scope.domain.domain, function (error, usage) {
if (error) console.error(error);
$scope.diskUsage = usage || {}; // if mail server is down, don't stop the listing
$scope.mailboxes.refresh(); // relies on disk usage
$scope.mailinglists.refresh();
$scope.catchall.refresh();
});
@@ -658,33 +955,57 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
});
};
$scope.howToConnectInfo = {
show: function () {
$('#howToConnectInfoModal').modal('show');
}
};
Client.onReady(function () {
$scope.isAdminDomain = $scope.config.adminDomain === domainName;
Client.getUsers(function (error, users) {
Client.getAllUsers(function (error, users) {
if (error) return console.error('Unable to get user listing.', error);
// ensure we have a display value available
$scope.users = users.map(function (u) {
u.display = u.username || u.email;
return u;
$scope.owners.push({ header: true, display: $translate.instant('email.mailboxboxDialog.usersHeader') });
users.forEach(function (u) {
$scope.owners.push({ display: u.username || u.email, id: u.id, type: 'user' });
});
$scope.users = users;
Client.getGroups(function (error, groups) {
if (error) return console.error('Unable to get group listing.', error);
Client.getDomain(domainName, function (error, result) {
if (error) return console.error('Unable to get view domain.', error);
$scope.owners.push({ header: true, display: $translate.instant('email.mailboxboxDialog.groupsHeader') });
groups.forEach(function (g) {
$scope.owners.push({ display: g.name, id: g.id, type: 'group' });
});
$scope.domain = result;
$scope.owners.push({ header: true, display: $translate.instant('email.mailboxboxDialog.appsHeader') });
Client.getInstalledApps().forEach(function (a) {
if (a.manifest.addons && a.manifest.addons.recvmail) $scope.owners.push({ display: a.label || a.fqdn, id: a.id, type: 'app' });
});
Client.getDomain($scope.config.adminDomain, function (error, result) {
if (error) return console.error('Unable to get admin domain.', error);
$scope.adminDomain = result;
Client.getDomains(function (error, result) {
if (error) return console.error('Unable to list domains.', error);
$scope.domain = result.filter(function (d) { return d.domain === domainName; })[0];
$scope.adminDomain = result.filter(function (d) { return d.domain === $scope.config.adminDomain; })[0];
$scope.refreshDomain();
$scope.ready = true;
async.eachSeries(result, function (domain, iteratorDone) {
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
if (error) return console.error('Failed to fetch mail config for domain', domain.domain, error);
if (mailConfig.enabled) $scope.incomingDomains.push(domain);
iteratorDone();
});
}, function iteratorDone(error) {
if (error) return console.error(error);
$scope.setView($routeParams.view || 'mailboxes', true /* always set */);
$scope.ready = true;
});
});
});
});
+94
View File
@@ -0,0 +1,94 @@
<div>
<a href="/#/email" class="back-to-view-link"><i class="fas fa-arrow-left"></i> {{ 'email.backAction' | tr }}</a>
<br/>
<div class="col-md-10 col-md-offset-1">
<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>
</h1>
</div>
</div>
<div>
<div class="col-md-10 col-md-offset-1">
<div class="maillog-filter">
<input class="form-control" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="activity.search" ng-model-options="{ debounce: 1000 }" ng-change="activity.updateFilter(true)" />
<multiselect ng-model="activity.selectedTypes" ms-header="{{ 'emails.typeFilterHeader' | tr }}" options="a.name for a in activityTypes" data-multiple="true" ng-change="activity.updateFilter(true)" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<select class="form-control" ng-model="activity.pageItems" ng-options="a.name for a in pageItemCount" ng-change="activity.updateFilter(true)"></select>
</div>
<div class="pagination pull-right">
<button class="btn btn-default btn-outline" ng-click="activity.refresh()"><i class="fas fa-sync-alt" ng-class="{ 'fa-spin': busyRefresh }"></i></button>
<button class="btn btn-default btn-outline" ng-click="activity.showPrevPage()" ng-disabled="activity.busy || activity.currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="activity.showNextPage()" ng-disabled="activity.busy || activity.perPage > activity.eventLogs.length">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
</div>
<div>
<div class="col-md-10 col-md-offset-1">
<div class="card card-block" style="max-width: 100%">
<div>
<center ng-show="activity.busy"><h2><i class="fa fa-circle-notch fa-spin"></i></h2></center>
<table ng-hide="activity.busy" class="table table-hover" style="margin: 0; table-layout:fixed">
<thead>
<tr>
<th style="width: 5%"><!-- Icon --></th>
<th style="width: 15%">{{ 'emails.eventlog.time' | tr }}</th>
<th style="width: 25%">{{ 'emails.eventlog.mailFrom' | tr }}</th>
<th style="width: 25%">{{ 'emails.eventlog.rcptTo' | tr }}</th>
<th style="width: 30%">{{ 'emails.eventlog.details' | tr }}</th>
</tr>
</thead>
<tbody ng-hide="activity.eventLogs.length">
<tr>
<td colspan="4" class="text-center">
<br>
<br>
{{ 'emails.eventlog.empty' | tr }}
<br>
<br>
</td>
</tr>
</tbody>
<tbody ng-show="activity.eventLogs.length" ng-repeat="eventlog in activity.eventLogs">
<tr ng-click="activity.showEventLogDetails(eventlog)" class="hand">
<td class="no-wrap">
<i class="fas fa-arrow-circle-left" ng-show="eventlog.type === 'delivered'" uib-tooltip="{{ 'emails.eventlog.type.outgoing' | tr }}"></i>
<i class="fas fa-history" ng-show="eventlog.type === 'deferred'" uib-tooltip="{{ 'emails.eventlog.type.deferred' | tr }}"></i>
<i class="fas fa-arrow-circle-right" ng-show="eventlog.type === 'received'" uib-tooltip="{{ 'emails.eventlog.type.incoming' | tr }}"></i>
<i class="fas fa-align-justify" ng-show="eventlog.type === 'queued' && eventlog.spamStatus.indexOf('Yes,') !== 0" uib-tooltip="{{ 'emails.eventlog.type.queued' | tr }}"></i>
<i class="fas fa-trash" ng-show="eventlog.type === 'queued' && eventlog.spamStatus.indexOf('Yes,') === 0" uib-tooltip="{{ 'emails.eventlog.type.queued' | tr }}"></i>
<i class="fas fa-minus-circle" ng-show="eventlog.type === 'denied'" uib-tooltip="{{ 'emails.eventlog.type.denied' | tr }}"></i>
<i class="fas fa-hand-paper" ng-show="eventlog.type === 'bounce'" uib-tooltip="{{ 'emails.eventlog.type.bounce' | tr }}"></i>
<i class="fas fa-filter" ng-show="eventlog.type === 'spam-learn'" uib-tooltip="{{ 'emails.eventlog.type.spamFilterTrained' | tr }}"></i>
</td>
<td class="no-wrap"><span uib-tooltip="{{ eventlog.ts | prettyLongDate }}" class="arrow">{{ eventlog.ts | prettyDate }}</span></td>
<td class="elide-table-cell">{{ (eventlog.mailFrom | prettyEmailAddresses) || '-' }}</td>
<td class="elide-table-cell">{{ (eventlog.rcptTo | prettyEmailAddresses) || '-' }}</td>
<td>
<span ng-show="eventlog.type === 'bounce'">{{ 'emails.eventlog.type.bounceInfo' | tr }}. {{ eventlog.message || eventlog.reason }}</span>
<span ng-show="eventlog.type === 'deferred'">{{ 'emails.eventlog.type.deferredInfo' | tr: { delay:eventlog.delay } }}. {{ eventlog.message || eventlog.reason }} </span>
<span ng-show="eventlog.type === 'queued'">
<span ng-show="eventlog.direction === 'inbound'">{{ 'emails.eventlog.type.inboundInfo' | tr }}</span>
<span ng-show="eventlog.direction === 'outbound'">{{ 'emails.eventlog.type.outboundInfo' | tr }}</span>
</span>
<span ng-show="eventlog.type === 'received'">{{ 'emails.eventlog.type.receivedInfo' | tr }}</span>
<span ng-show="eventlog.type === 'delivered'">{{ 'emails.eventlog.type.deliveredInfo' | tr }}</span>
<span ng-show="eventlog.type === 'denied'">{{ 'emails.eventlog.type.deniedInfo' | tr }}. {{ eventlog.message || eventlog.reason }} </span>
<span ng-show="eventlog.type === 'spam-learn'">{{ 'emails.eventlog.type.spamFilterTrainedInfo' | tr }}</span>
</td>
</tr>
<tr ng-show="activity.activeEventLog === eventlog">
<td colspan="6">
<pre class="eventlog-details">{{ eventlog | json }}</pre>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
+82
View File
@@ -0,0 +1,82 @@
'use strict';
/* global $ */
/* 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('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.pageItemCount = [
{ name: $translate.instant('main.pagination.perPageSelector', { n: 20 }), value: 20 },
{ name: $translate.instant('main.pagination.perPageSelector', { n: 50 }), value: 50 },
{ name: $translate.instant('main.pagination.perPageSelector', { n: 100 }), value: 100 }
];
$scope.activityTypes = [
{ name: 'Bounce', value: 'bounce' },
{ name: 'Deferred', value: 'deferred' },
{ name: 'Delivered', value: 'delivered' },
{ name: 'Denied', value: 'denied' },
{ name: 'Queued', value: 'queued' },
{ name: 'Received', value: 'received' },
{ name: 'Spam', value: 'spam' },
];
$scope.activity = {
busy: true,
eventLogs: [],
activeEventLog: null,
currentPage: 1,
perPage: 20,
pageItems: $scope.pageItemCount[0],
selectedTypes: [],
search: '',
refresh: function () {
$scope.activity.busy = true;
var types = $scope.activity.selectedTypes.map(function (a) { return a.value; }).join(',');
Client.getMailEventLogs($scope.activity.search, types, $scope.activity.currentPage, $scope.activity.pageItems.value, function (error, result) {
if (error) return console.error('Failed to fetch mail eventlogs.', error);
$scope.activity.busy = false;
$scope.activity.eventLogs = result;
});
},
showNextPage: function () {
$scope.activity.currentPage++;
$scope.activity.refresh();
},
showPrevPage: function () {
if ($scope.activity.currentPage > 1) $scope.activity.currentPage--;
else $scope.activity.currentPage = 1;
$scope.activity.refresh();
},
showEventLogDetails: function (eventLog) {
if ($scope.activity.activeEventLog === eventLog) $scope.activity.activeEventLog = null;
else $scope.activity.activeEventLog = eventLog;
},
updateFilter: function (fresh) {
if (fresh) $scope.activity.currentPage = 1;
$scope.activity.refresh();
}
};
Client.onReady(function () {
$scope.ready = true;
$scope.activity.refresh();
});
$('.modal-backdrop').remove();
}]);
+277 -93
View File
@@ -1,21 +1,199 @@
<!-- 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">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'emails.changeMailSizeDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-bind-html=" 'emails.changeMailSizeDialog.description' | tr "></div>
<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 | prettyDiskSize }}</b></label>
<slider ng-model="maxEmailSize.size" tooltip="hide" min="1048576" max="1073741824" step="1048576"></slider>
</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-click="maxEmailSize.submit()" ng-disabled="maxEmailSize.size === maxEmailSize.currentSize"><i class="fa fa-circle-notch fa-spin" ng-show="maxEmailSize.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal change mailbox sharing -->
<div class="modal fade" id="mailboxSharingChangeModal" 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>
</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>
<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>
</div>
</div>
</div>
</div>
<!-- Modal solr config -->
<div class="modal fade" id="solrConfigModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'emails.solrConfig.title' | tr }}</h4>
</div>
<div class="modal-body">
<p ng-bind-html=" 'emails.solrConfig.description' | tr "></p>
<!-- only show this when user is trying to enable -->
<p class="has-error" ng-show="!solrConfig.currentConfig.enabled && !solrConfig.enoughMemory">{{ 'emails.solrConfig.notEnoughMemory' | tr }}</p>
</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-hide="solrConfig.currentConfig.enabled" ng-click="solrConfig.submit(true)" ng-disabled="(!solrConfig.currentConfig.enabled && !solrConfig.enoughMemory) || solrConfig.busy"><i class="fa fa-circle-notch fa-spin" ng-show="solrConfig.busy"></i> {{ 'main.enableAction' | tr }}</button>
<button type="button" class="btn btn-danger" ng-show="solrConfig.currentConfig.enabled" ng-click="solrConfig.submit(false)" ng-disabled="solrConfig.busy"><i class="fa fa-circle-notch fa-spin" ng-show="solrConfig.busy"></i> {{ 'main.disableAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal change acl -->
<div class="modal fade" id="aclChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'emails.aclDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="aclChangeForm" role="form" novalidate ng-submit="acl.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label">{{ 'emails.aclDialog.dnsblZones' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#dnsbl" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<p class="small">{{ 'emails.aclDialog.dnsblZonesInfo' | tr }}</p>
<div class="has-error" ng-show="acl.error.dnsblZones">{{ acl.error.dnsblZones }}</div>
<textarea ng-model="acl.dnsblZones" placeholder="{{ 'emails.aclDialog.dnsblZonesPlaceholder' | tr }}" name="dnsblZones" class="form-control" ng-class="{ 'has-error': !aclChangeForm.dnsblZones.$dirty && acl.error.dnsblZones }" 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-click="acl.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="acl.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal change spam config -->
<div class="modal fade" id="spamConfigChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'emails.spamFilterDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="spamConfigChangeForm" role="form" novalidate ng-submit="spamConfig.submit()" autocomplete="off">
<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>
<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>
<div class="has-error" ng-show="spamConfig.error.config">{{ spamConfig.error.config }}</div>
<textarea ng-model="spamConfig.config" placeholder="{{ 'emails.spamFilterDialog.customRulesPlaceholder' | tr }}" class="form-control" name="config" ng-class="{ 'has-error': !spamConfigChangeForm.config.$dirty && spamConfig.error.config }" 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-click="spamConfig.submit()"><i class="fa fa-circle-notch fa-spin" ng-show="spamConfig.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Test email -->
<div class="modal fade" id="testEmailModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Send test email for <b>{{ testEmail.domain.domain }}</b></h4>
<h4 class="modal-title">{{ 'emails.testMailDialog.title' | tr:{ domain: testEmail.domain.domain } }}</h4>
</div>
<div class="modal-body">
<form name="testEmailForm" role="form" novalidate ng-submit="testEmail.submit()" autocomplete="off">
<fieldset>
<p class="has-error text-center" ng-show="testEmail.error">{{ testEmail.error.generic }}</p>
<p>This will send a test email from <b>no-reply@{{testEmail.domain.domain}}</b> to the address below.</p>
<p ng-bind-html="'emails.testMailDialog.description' | tr:{ domain: testEmail.domain.domain }"></p>
<br/>
<div class="form-group" ng-class="{ 'has-error': testEmail.error.key }">
<label class="control-label" for="inputTestEmailKey">Email to</label>
<input type="text" class="form-control" ng-model="testEmail.mailTo" id="inputTestMailTo" name="mailTo" ng-disabled="testEmail.busy" placeholder="Email address" autofocus>
<label class="control-label" for="inputTestEmailKey">{{ 'emails.testMailDialog.mailTo' | tr }}</label>
<input type="text" class="form-control" ng-model="testEmail.mailTo" id="inputTestMailTo" name="mailTo" ng-disabled="testEmail.busy" placeholder="{{ 'emails.testMailDialog.mailToPlaceholder' | tr }}" autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="testEmailForm.$invalid"/>
@@ -23,10 +201,8 @@
</form>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="testEmail.submit()" ng-disabled="testEmail.$invalid || testEmail.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="testEmail.busy"></i><span>Send</span>
</button>
<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="testEmail.submit()" ng-disabled="testEmail.$invalid || testEmail.busy"><i class="fa fa-circle-notch fa-spin" ng-show="testEmail.busy"></i> {{ 'emails.testMailDialog.sendAction' | tr }}</button>
</div>
</div>
</div>
@@ -35,15 +211,21 @@
<div class="content">
<div class="text-left">
<h1>
Mail Server
{{ 'emails.title' | tr }}
<div class="btn-group pull-right" role="group">
<a class="btn btn-default" href="#/emails-eventlog">{{ 'main.action.logs' | tr }}</a>
<!-- hidden for now, until we see a purpose -->
<!-- <a class="btn btn-sm btn-default" ng-disabled="user.role !== 'owner'" 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> -->
</div>
</h1>
</div>
<div class="text-left">
<h3>Domains</h3>
<h3>{{ 'emails.domains.title' | tr }}</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="card" style="margin-bottom: 15px;">
<div class="row ng-hide" ng-hide="ready">
<div class="col-lg-12 text-center">
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
@@ -55,15 +237,15 @@
<thead>
<tr>
<th style="width: 5%"></th>
<th style="width: 30%">Domain</th>
<th style="width: 60%">Config</th>
<th style="width: 10%">Actions</th>
<th style="width: 30%">{{ 'emails.domains.domain' | tr }}</th>
<th style="width: 60%">{{ 'emails.domains.config' | tr }}</th>
<th style="width: 10%">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="domain in domains">
<td>
<i class="fa fa-circle" ng-style="{ color: domain.statusOk ? '#27CE65' : '#d9534f' }" ng-show="domain.status"></i>
<i class="fa fa-circle" ng-class="{ 'status-active': domain.statusOk, 'status-error': !domain.statusOk }" ng-show="domain.status"></i>
<i class="fa fa-circle-notch fa-spin" ng-hide="domain.status"></i>
</td>
<td class="elide-table-cell no-padding">
@@ -71,15 +253,13 @@
</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.mailboxCount }} Mailbox(es) / Usage: {{ domain.usage | prettyMailSize }}
</span>
<span ng-show="!domain.inbound && domain.outbound">Outbound only</span>
<span ng-show="!domain.inbound && !domain.outbound">Disabled</span>
<span ng-show="domain.inbound && domain.outbound">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount, usage: (domain.usage | prettyByteSize) } }}</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>
</a>
</td>
<td class="text-right no-wrap">
<button class="btn btn-xs btn-default" ng-click="testEmail.show(domain)" uib-tooltip="Send Test Email"><i class="fa fa-paper-plane"></i></button>
<button class="btn btn-xs btn-default" ng-click="testEmail.show(domain)" uib-tooltip="{{ 'emails.domains.testEmailTooltip' | tr }}"><i class="fa fa-paper-plane"></i></button>
<a href="/#/email/{{ domain.domain }}" class="btn btn-xs btn-default"><i class="fa fa-pencil-alt"></i></a>
</td>
</tr>
@@ -89,83 +269,87 @@
</div>
</div>
<br/>
<div class="text-left">
<h3>Event Log
<button class="btn btn-sm btn-default btn-outline pull-right" ng-click="activity.showNextPage()" ng-disabled="activity.busy || activity.perPage > activity.eventLogs.length">next <i class="fa fa-angle-double-right"></i></button>
<button class="btn btn-sm btn-default btn-outline pull-right" ng-click="activity.showPrevPage()" ng-disabled="activity.busy || activity.currentPage <= 1"><i class="fa fa-angle-double-left"></i> prev</button>
<button class="btn btn-sm btn-primary btn-outline pull-right" ng-click="activity.refresh()" ng-disabled="activity.busy"><i class="fa fa-sync"></i></button>
<input class="form-control pull-right" style="width: 200px;" placeholder="Search" type="text" ng-model="activity.search" ng-model-options="{ debounce: 1000 }" ng-change="activity.updateFilter()" />
</h3>
<div class="text-left" ng-show="user.isAtLeastOwner">
<h3>{{ 'emails.mailboxSharing.title' | tr }}</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row ng-hide" ng-hide="ready">
<div class="col-lg-12 text-center">
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>{{ 'emails.mailboxSharing.description' | tr }}</p>
</div>
</div>
<div class="row animateMeOpacity ng-hide" ng-show="ready">
<div class="col-xs-12">
<table class="table table-hover" style="margin: 0;">
<thead>
<tr>
<th style="width: 5%"><!-- Icon --></th>
<th style="width: 20%">Time</th>
<th style="width: 75%">Details</th>
</tr>
</thead>
<tbody ng-show="activity.busy">
<tr>
<td colspan="4" class="text-center">
<i class="fa fa-circle-notch fa-spin"></i>
</td>
</tr>
</tbody>
<tbody ng-hide="activity.eventLogs.length || activity.busy">
<tr>
<td colspan="4" class="text-center">
<br>
<br>
Event Log is empty.
<br>
<br>
</td>
</tr>
</tbody>
<tbody ng-repeat="eventlog in activity.eventLogs" ng-hide="activity.busy">
<tr ng-click="activity.showEventLogDetails(eventlog)" class="hand">
<td class="no-wrap">
<i class="fas fa-arrow-circle-left" ng-show="eventlog.type === 'delivered'" uib-tooltip="Outgoing"></i>
<i class="fas fa-history" ng-show="eventlog.type === 'deferred'" uib-tooltip="Deferred"></i>
<i class="fas fa-arrow-circle-right" ng-show="eventlog.type === 'received'" uib-tooltip="Incoming"></i>
<i class="fas fa-align-justify" ng-show="eventlog.type === 'queued'" uib-tooltip="Queued"></i>
<!-- <i class="fas fa-ban" ng-show="eventlog.details.spamStatus.indexOf('Yes,') === 0" uib-tooltip="Spam"></i> -->
<i class="fas fa-minus-circle" ng-show="eventlog.type === 'denied'" uib-tooltip="Denied"></i>
<i class="fas fa-hand-paper" ng-show="eventlog.type === 'bounce'" uib-tooltip="Bounce"></i>
<i class="fas fa-filter" ng-show="eventlog.type === 'spam-learn'" uib-tooltip="Spam filter trained"></i>
</td>
<td class="no-wrap"><span uib-tooltip="{{ eventlog.ts | prettyLongDate }}" class="arrow">{{ eventlog.ts | prettyDate }}</span></td>
<td class="elide-table-cell">
<span ng-show="eventlog.type === 'delivered' || eventlog.type === 'queued' || eventlog.type === 'received' || eventlog.type === 'bounce' || eventlog.type === 'deferred'">{{ eventlog.mailFrom | prettyEmailAddresses }} <i class="fas fa-long-arrow-alt-right"></i> {{ eventlog.rcptTo | prettyEmailAddresses }}</span>
<span ng-show="eventlog.type === 'denied'">Incoming connection from {{ eventlog.remote.ip }} denied</span>
<span ng-show="eventlog.type === 'spam-learn'">Spam filter trained using mailbox content</span>
</td>
</tr>
<tr ng-show="activity.activeEventLog === eventlog">
<td colspan="6">
<pre class="eventlog-details">{{ eventlog | json }}</pre>
</td>
</tr>
</tbody>
</table>
<div class="row">
<div class="col-md-2" style="padding-top: 12px;">
<i class="fa fa-circle" ng-class="{ 'status-active': mailboxSharing.enabled, 'status-inactive': !mailboxSharing.enabled }"></i> {{ mailboxSharing.enabled ? 'main.statusEnabled' : 'main.statusDisabled' | tr }}
</div>
<div class="col-md-10 text-right">
<button class="btn" ng-class="{ 'btn-danger': mailboxSharing.enabled, 'btn-primary': !mailboxSharing.enabled }" ng-click="mailboxSharing.submit()" ng-disabled="mailboxSharing.enable === mailboxSharing.enabled"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxSharing.busy"></i> {{ mailboxSharing.enabled ? ('main.disableAction' | tr) : ('main.enableAction' | tr) }} </button>
</div>
</div>
</div>
<br/>
<div class="text-left" ng-show="user.isAtLeastOwner">
<h3>{{ 'emails.settings.title' | tr }}</h3>
</div>
<a class="btn btn-primary pull-right" href="/logs.html?id=mail" target="_blank">Show Raw Logs</a>
<div class="card" ng-show="user.isAtLeastOwner" 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 | prettyDiskSize }} <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.acl' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ 'emails.settings.aclOverview' | tr:{ dnsblZonesCount: acl.dnsblZonesCount } }} <a href="" ng-click="acl.show()"><i class="fa fa-edit text-small"></i></a></span>
</div>
<div class="col-xs-6">
<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>
</div>
<div class="col-xs-6">
<span class="text-muted">{{ 'emails.settings.solrFts' | tr }}</span>
</div>
<div class="col-xs-6 text-right" ng-hide="solrConfig.currentConfig">
<i class="fa fa-circle-notch fa-spin"></i>
</div>
<div class="col-xs-6 text-right" ng-show="solrConfig.currentConfig">
<span ng-show="solrConfig.currentConfig.enabled">
{{ 'emails.settings.solrEnabled' | tr }}
<span ng-show="solrConfig.running">/ {{ 'emails.settings.solrRunning' | tr }}</span>
<span ng-hide="solrConfig.running">/ {{ 'emails.settings.solrNotRunning' | tr }}</span>
</span>
<span ng-hide="solrConfig.currentConfig.enabled">{{ 'emails.settings.solrDisabled' | tr }}</span>
<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>
+351 -34
View File
@@ -1,58 +1,368 @@
'use strict';
/* global angular:false */
/* global $:false */
/* global $, angular, TASK_TYPES */
angular.module('Application').controller('EmailsController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
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('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.domains = [];
$scope.activity = {
busy: true,
eventLogs: [],
activeEventLog: null,
currentPage: 1,
perPage: 20,
search: '',
// 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;
refresh: function () {
$scope.activity.busy = true;
Client.repairApp(installedApps[i].id, { }, function (error) {
if (error) console.error(error);
});
}
};
Client.getMailEventLogs($scope.activity.search, $scope.activity.currentPage, $scope.activity.perPage, function (error, result) {
if (error) return console.error('Failed to fetch mail eventlogs.', error);
$scope.mailLocation = {
busy: false,
error: null,
currentLocation: { domain: null, subdomain: '' },
domain: null,
subdomain: '',
taskId: null,
percent: 0,
taskMinutesActive: 0,
message: '',
errorMessage: '',
reconfigure: false,
$scope.activity.busy = false;
stopTask: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_CHANGE_MAIL_LOCATION, function (error, task) {
if (error) return console.error(error);
if (!task.id) return;
$scope.activity.eventLogs = result;
Client.stopTask(task.id, function (error) {
if (error) console.error(error);
});
});
},
showNextPage: function () {
$scope.activity.currentPage++;
$scope.activity.refresh();
refresh: function () {
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; });
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();
});
});
},
showPrevPage: function () {
if ($scope.activity.currentPage > 1) $scope.activity.currentPage--;
else $scope.activity.currentPage = 1;
$scope.activity.refresh();
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');
},
showEventLogDetails: function (eventLog) {
if ($scope.activity.activeEventLog === eventLog) $scope.activity.activeEventLog = null;
else $scope.activity.activeEventLog = eventLog;
updateStatus: function () {
Client.getTask($scope.mailLocation.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.errorMessage = data.success ? '' : data.error.message;
if ($scope.mailLocation.reconfigure) $scope.reconfigureEmailApps();
return;
}
$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);
});
},
updateFilter: function () {
$scope.activity.currentPage = 1;
$scope.activity.refresh();
submit: function () {
$scope.mailLocation.busy = true;
Client.setMailLocation($scope.mailLocation.subdomain, $scope.mailLocation.domain.domain, function (error, result) {
if (error) {
$scope.mailLocation.busy = false;
$scope.mailLocation.error = error;
return;
}
// 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');
});
}
};
$scope.maxEmailSize = {
busy: false,
error: null,
size: 0,
currentSize: 0,
refresh: function () {
Client.getMaxEmailSize(function (error, size) {
if (error) return console.error('Failed to get max email size', error);
$scope.maxEmailSize.currentSize = size;
});
},
show: function() {
$scope.maxEmailSize.busy = false;
$scope.maxEmailSize.error = null;
$scope.maxEmailSize.size = $scope.maxEmailSize.currentSize;
$scope.maxEmailSizeChangeForm.$setUntouched();
$scope.maxEmailSizeChangeForm.$setPristine();
$('#maxEmailSizeChangeModal').modal('show');
},
submit: function () {
$scope.maxEmailSize.busy = true;
Client.setMaxEmailSize($scope.maxEmailSize.size, function (error) {
$scope.maxEmailSize.busy = false;
if (error) return console.error(error);
$scope.maxEmailSize.currentSize = $scope.maxEmailSize.size;
$('#maxEmailSizeChangeModal').modal('hide');
});
}
};
$scope.mailboxSharing = {
busy: false,
error: null,
enabled: null, // null means we have not refreshed yet
refresh: function () {
Client.getMailboxSharing(function (error, enabled) {
if (error) return console.error('Failed to get mailbox sharing', error);
$scope.mailboxSharing.enabled = enabled;
});
},
submit: function () {
$scope.mailboxSharing.busy = true;
Client.setMailboxSharing(!$scope.mailboxSharing.enabled, function (error) {
// give sometime for mail server to restart
$timeout(function () {
$scope.mailboxSharing.busy = false;
if (error) return console.error(error);
$scope.mailboxSharing.enabled = !$scope.mailboxSharing.enabled;
}, 3000);
});
}
};
$scope.solrConfig = {
busy: false,
error: {},
currentConfig: null, // null means not loaded yet
enabled: false,
running: false,
enoughMemory: false,
refresh: function () {
Client.getService('mail', function (error, result) {
if (error) return console.log('Error getting status of mail conatiner', error);
$scope.solrConfig.enoughMemory = result.config.memoryLimit > (1024*1024*1024*2);
$scope.solrConfig.running = result.healthcheck && result.healthcheck.solr.status;
Client.getSolrConfig(function (error, config) {
if (error) return console.error('Failed to get solr config', error);
$scope.solrConfig.currentConfig = config;
});
});
},
show: function() {
$scope.solrConfig.busy = false;
$scope.solrConfig.error = null;
$scope.solrConfig.enabled = $scope.solrConfig.currentConfig.enabled;
$('#solrConfigModal').modal('show');
},
submit: function (newState) {
$scope.solrConfig.busy = true;
Client.setSolrConfig(newState, function (error) {
if (error) return console.error(error);
$timeout(function () {
$scope.solrConfig.busy = false;
// FIXME: these values are fake. but cannot get current status from mail server since it might be restarting
$scope.solrConfig.currentConfig.enabled = newState;
$scope.solrConfig.running = newState;
$timeout(function () { $scope.solrConfig.refresh(); }, 20000); // get real values after 20 seconds
$('#solrConfigModal').modal('hide');
}, 5000);
});
}
};
$scope.spamConfig = {
busy: false,
error: {},
acl: { whitelist: [], blacklist: [] },
customConfig: '',
config: '',
blacklist: '', // currently, we don't support whitelist because it requires user to understand a bit more of what he is doing
refresh: function () {
Client.getSpamCustomConfig(function (error, config) {
if (error) return console.error('Failed to get custom spam config', error);
$scope.spamConfig.customConfig = config;
});
Client.getSpamAcl(function (error, acl) {
if (error) return console.error('Failed to get spam acl', error);
$scope.spamConfig.acl = acl;
});
},
show: function() {
$scope.spamConfig.busy = false;
$scope.spamConfig.error = {};
$scope.spamConfig.blacklist = $scope.spamConfig.acl.blacklist.join('\n');
$scope.spamConfig.config = $scope.spamConfig.customConfig;
$scope.spamConfigChangeForm.$setUntouched();
$scope.spamConfigChangeForm.$setPristine();
$('#spamConfigChangeModal').modal('show');
},
submit: function () {
$scope.spamConfig.busy = true;
$scope.spamConfig.error = {};
var blacklist = $scope.spamConfig.blacklist.split('\n').filter(function (l) { return l !== ''; });
Client.setSpamAcl({ blacklist: blacklist, whitelist: [] }, function (error) {
if (error) {
$scope.spamConfig.busy = false;
$scope.spamConfig.error.blacklist = error.message;
$scope.spamConfigChangeForm.blacklist.$setPristine();
return;
}
Client.setSpamCustomConfig($scope.spamConfig.config, function (error) {
if (error) {
$scope.spamConfig.busy = false;
$scope.spamConfig.error.config = error.message;
$scope.spamConfigChangeForm.config.$setPristine();
return;
}
$scope.spamConfig.busy = false;
$scope.spamConfig.refresh();
$('#spamConfigChangeModal').modal('hide');
});
});
}
},
$scope.acl = {
busy: false,
error: {},
dnsblZones: '',
dnsblZonesCount: 0,
refresh: function () {
Client.getDnsblConfig(function (error, result) {
if (error) return console.error('Failed to get email acl', error);
$scope.acl.dnsblZones = result.zones.join('\n');
$scope.acl.dnsblZonesCount = result.zones.length;
});
},
show: function() {
$scope.acl.busy = false;
$scope.acl.error = {};
$scope.aclChangeForm.$setUntouched();
$scope.aclChangeForm.$setPristine();
$('#aclChangeModal').modal('show');
},
submit: function () {
$scope.acl.busy = true;
$scope.acl.error = {};
var zones = $scope.acl.dnsblZones.split('\n').filter(function (l) { return l !== ''; });
Client.setDnsblConfig(zones, function (error) {
if (error) {
$scope.acl.busy = false;
$scope.acl.error.dnsblZones = error.message;
$scope.aclChangeForm.dnsblZones.$setPristine();
return;
}
$scope.acl.busy = false;
$scope.acl.refresh();
$('#aclChangeModal').modal('hide');
});
}
},
$scope.testEmail = {
busy: false,
error: {},
@@ -117,10 +427,10 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
domain.outbound = mailConfig.relay.provider !== 'noop';
// do this even if no outbound since people forget to remove mailboxes
Client.getMailboxes(domain.domain, function (error, mailboxes) {
Client.getMailboxCount(domain.domain, function (error, count) {
if (error) return console.error('Failed to fetch mailboxes for domain', domain.domain, error);
domain.mailboxCount = mailboxes.length;
domain.mailboxCount = count;
Client.getMailUsage(domain.domain, function (error, usage) {
if (error) return console.error('Failed to fetch usage for domain', domain.domain, error);
@@ -130,7 +440,6 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
});
});
});
});
}
@@ -141,7 +450,15 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.domains = domains;
$scope.ready = true;
$scope.activity.refresh();
if ($scope.user.isAtLeastOwner) {
$scope.mailLocation.refresh();
$scope.maxEmailSize.refresh();
$scope.mailboxSharing.refresh();
$scope.spamConfig.refresh();
$scope.solrConfig.refresh();
$scope.acl.refresh();
}
refreshDomainStatuses();
});
});
@@ -1,23 +1,24 @@
<div>
<div class="col-md-10 col-md-offset-1">
<h1>Event Log</h1>
<h1>{{ 'eventlog.title' | tr }}</h1>
</div>
</div>
<div>
<div class="col-md-10 col-md-offset-1">
<div class="filter">
<input type="text" class="form-control" style="min-width: 350px;" ng-model="search" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="Search"/>
<multiselect ng-model="selectedActions" ms-header="All Events" options="a.name for a in actions" data-multiple="true" ng-change="updateFilter(true)" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<div class="eventlog-filter">
<input type="text" class="form-control" style="min-width: 350px;" ng-model="search" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
<multiselect ng-model="selectedActions" ms-header="{{ 'eventlog.filterAllEvents' | tr }}" options="a.name for a in actions" data-multiple="true" ng-change="updateFilter(true)" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<select class="form-control" ng-model="pageItems" ng-options="a.name for a in pageItemCount" ng-change="updateFilter(true)"></select>
<!-- <select class="form-control" ng-model="action" ng-options="a.name for a in actions" ng-change="updateFilter()">
<option value="">-- All actions --</option>
</select> -->
</div>
<div class="pagination pull-right">
<button class="btn btn-default btn-outline" ng-click="showPrevPage()" ng-disabled="busy || currentPage <= 1"><i class="fa fa-angle-double-left"></i> prev</button>
<button class="btn btn-default btn-outline" ng-click="showNextPage()" ng-disabled="busy || pageItems.value > eventLogs.length">next <i class="fa fa-angle-double-right"></i></button>
<button class="btn btn-default btn-outline" ng-click="refresh()"><i class="fas fa-sync-alt" ng-class="{ 'fa-spin': busyRefresh }"></i></button>
<button class="btn btn-default btn-outline" ng-click="showPrevPage()" ng-disabled="busy || currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="showNextPage()" ng-disabled="busy || pageItems.value > eventLogs.length">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
</div>
@@ -30,9 +31,9 @@
<table ng-hide="busy" class="table table-condensed table-hover">
<thead>
<tr>
<th class="col-md-2">Time</th>
<th class="col-md-3">Source</th>
<th class="col-md-7">Details</th>
<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>
</tr>
</thead>
<tbody ng-repeat="eventLog in eventLogs">
+147
View File
@@ -0,0 +1,147 @@
'use strict';
/* global angular */
/* global $ */
angular.module('Application').controller('EventLogController', ['$scope', '$location', '$translate', 'Client', function ($scope, $location, $translate, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.config = Client.getConfig();
$scope.busy = false;
$scope.busyRefresh = false;
$scope.eventLogs = [];
$scope.activeEventLog = null;
// TODO sync this with the eventlog filter
$scope.actions = [
{ name: '-- All app events --', value: 'app.' },
{ name: '-- All user events --', value: 'user.' },
{ name: 'app.backup', value: 'app.backup' },
{ name: 'app.backup.finish', value: 'app.backup.finish' },
{ name: 'app.configure', value: 'app.configure' },
{ name: 'app.install', value: 'app.install' },
{ name: 'app.restore', value: 'app.restore' },
{ name: 'app.uninstall', value: 'app.uninstall' },
{ name: 'app.update', value: 'app.update' },
{ name: 'app.update.finish', value: 'app.update.finish' },
{ name: 'app.login', value: 'app.login' },
{ name: 'app.oom', value: 'app.oom' },
{ name: 'app.down', value: 'app.down' },
{ name: 'app.up', value: 'app.up' },
{ 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' },
{ name: 'backup.start', value: 'backup.start' },
{ name: 'certificate.new', value: 'certificate.new' },
{ name: 'certificate.renew', value: 'certificate.renew' },
{ name: 'cloudron.activate', value: 'cloudron.activate' },
{ name: 'cloudron.provision', value: 'cloudron.provision' },
{ name: 'cloudron.restore', value: 'cloudron.restore' },
{ name: 'cloudron.start', value: 'cloudron.start' },
{ name: 'cloudron.update', value: 'cloudron.update' },
{ name: 'cloudron.update.finish', value: 'cloudron.update.finish' },
{ name: 'dashboard.domain.update', value: 'dashboard.domain.update' },
{ 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: 'mail.location', value: 'mail.location' },
{ name: 'mail.enabled', value: 'mail.enabled' },
{ name: 'mail.box.add', value: 'mail.box.add' },
{ name: 'mail.box.update', value: 'mail.box.update' },
{ name: 'mail.box.remove', value: 'mail.box.remove' },
{ name: 'mail.list.add', value: 'mail.list.add' },
{ name: 'mail.list.update', value: 'mail.list.update' },
{ name: 'mail.list.remove', value: 'mail.list.remove' },
{ name: 'service.configure', value: 'service.configure' },
{ name: 'service.rebuild', value: 'service.rebuild' },
{ name: 'service.restart', value: 'service.restart' },
{ name: 'support.ticket', value: 'support.ticket' },
{ name: 'support.ssh', value: 'support.ssh' },
{ name: 'user.add', value: 'user.add' },
{ name: 'user.login', value: 'user.login' },
{ name: 'user.logout', value: 'user.logout' },
{ name: 'user.remove', value: 'user.remove' },
{ name: 'user.transfer', value: 'user.transfer' },
{ name: 'user.update', value: 'user.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 = [
{ name: $translate.instant('main.pagination.perPageSelector', { n: 20 }), value: 20 },
{ name: $translate.instant('main.pagination.perPageSelector', { n: 50 }), value: 50 },
{ name: $translate.instant('main.pagination.perPageSelector', { n: 100 }), value: 100 }
];
$scope.currentPage = 1;
$scope.pageItems = $scope.pageItemCount[0];
$scope.action = '';
$scope.selectedActions = [];
$scope.search = '';
function fetchEventLogs(background, callback) {
callback = callback || function (error) { if (error) console.error(error); };
background = background || false;
if (!background) $scope.busy = true;
var actions = $scope.selectedActions.map(function (a) { return a.value; }).join(', ');
Client.getEventLogs(actions, $scope.search || null, $scope.currentPage, $scope.pageItems.value, function (error, result) {
$scope.busy = false;
if (error) return callback(error);
$scope.eventLogs = [];
result.forEach(function (e) {
$scope.eventLogs.push({ raw: e, details: Client.eventLogDetails(e), source: Client.eventLogSource(e) });
});
callback();
});
}
$scope.refresh = function () {
$scope.busyRefresh = true;
fetchEventLogs(true, function () {
$scope.busyRefresh = false;
});
};
$scope.showNextPage = function () {
$scope.currentPage++;
fetchEventLogs();
};
$scope.showPrevPage = function () {
if ($scope.currentPage > 1) $scope.currentPage--;
else $scope.currentPage = 1;
fetchEventLogs();
};
$scope.updateFilter = function (fresh) {
if (fresh) $scope.currentPage = 1;
fetchEventLogs();
};
$scope.showEventLogDetails = function (eventLog) {
if ($scope.activeEventLog === eventLog) $scope.activeEventLog = null;
else $scope.activeEventLog = eventLog;
};
Client.onReady(function () {
fetchEventLogs();
});
$('.modal-backdrop').remove();
}]);
-42
View File
@@ -1,42 +0,0 @@
<div class="content content-large">
<div class="row" ng-if="errorMessage">
<br>
<div class="alert alert-danger text-center">
{{ errorMessage }}
</div>
</div>
<div class="text-left">
<h2>Memory</h2>
</div>
<div class="card card-large text-center">
<div class="row">
<div class="col-md-6">
<h3>Apps</h3>
<div style="width: 200px; height: 200px; margin: auto;">
<canvas id="memoryUsageAppsChart" style="width: 200px; height: 200px;"></canvas>
</div>
</div>
<div class="col-md-6">
<h3>System</h3>
<div style="width: 200px; height: 200px; margin: auto;">
<canvas id="memoryUsageSystemChart" style="width: 200px; height: 200px;"></canvas>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4 ng-show="activeApp === 'system'">System</h4>
<h4 ng-show="activeApp !== 'system'">{{ activeApp.fqdn }}</h4>
<br/>
<canvas id="memoryAppChart" width="900" height="300"></canvas>
<p>Memory consumption in MB.</p>
</div>
</div>
</div>
</div>
-248
View File
@@ -1,248 +0,0 @@
'use strict';
/* global Chart:false */
/* global asyncForEach:false */
/* global angular:false */
/* global $:false */
angular.module('Application').controller('GraphsController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.memoryUsageSystem = [];
$scope.memoryUsageApps = [];
$scope.activeApp = null;
$scope.memory = null;
$scope.errorMessage = '';
$scope.installedApps = Client.getInstalledApps();
function bytesToMegaBytes(value) {
return (value/1024/1024).toFixed(2);
}
// http://stackoverflow.com/questions/1484506/random-color-generator-in-javascript
function getRandomColor() {
var letters = '0123456789ABCDEF'.split('');
var color = '#';
for (var i = 0; i < 6; i++ ) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
var colorIndex = 0;
var colors = [ '#2196F3', '#3995b1', '#f0ad4e', '#ff4c4c' ];
function getNextColor() {
if (colors[colorIndex+1]) return colors[colorIndex++];
return getRandomColor();
}
$scope.setError = function (context, error) {
$scope.errorMessage = 'Error loading ' + context + ' stats : ' + error.message + '. Try restarting the graphite service.';
};
$scope.setMemoryApp = function (app, color) {
$scope.activeApp = app;
var timePeriod = 12 * 60; // in minutes
var timeBucketSize = 60; // in minutes
var target;
if (app === 'system') target = 'summarize(sum(collectd.localhost.memory.memory-used, collectd.localhost.swap.swap-used), "' + timeBucketSize + 'min", "avg")';
else target = 'summarize(collectd.localhost.table-' + app.id + '-memory.gauge-rss, "' + timeBucketSize + 'min", "avg")';
Client.graphs([target], '-' + timePeriod + 'min', {}, function (error, result) {
if (error) return $scope.setError('memory', error);
// translate the data from bytes to MB
var datapoints = result[0].datapoints.map(function (d) { return parseInt((d[0] / 1024 / 1024).toFixed(2)); });
var labels = datapoints.map(function (d, index) {
var dateTime = new Date(Date.now() - ((timePeriod - (index * timeBucketSize)) * 60 *1000));
return ('0' + dateTime.getHours()).slice(-2) + ':00';
});
var data = {
labels: labels,
datasets: [{
label: 'Memory',
backgroundColor: color || '#82C4F8',
borderColor: color || '#2196F3',
borderWidth: 2,
pointBackgroundColor: color || 'rgba(151,187,205,1)',
pointBorderColor: color || '#2196F3',
pointHoverBackgroundColor: color || '#82C4F8',
pointHoverBorderColor: color || '#82C4F8',
data: datapoints
}]
};
var scaleMax = 0;
if ($scope.activeApp === 'system') {
scaleMax = $scope.memory.memory + $scope.memory.swap;
} else {
scaleMax = $scope.activeApp.memoryLimit || $scope.activeApp.manifest.memoryLimit || (256 * 1024 * 1024);
}
var stepSize;
if (scaleMax >= (8 * 1024 * 1024 * 1024)) stepSize = 1024;
else if (scaleMax >= (4 * 1024 * 1024 * 1024)) stepSize = 512;
else if (scaleMax >= (2 * 1024 * 1024 * 1024)) stepSize = 256;
else stepSize = 128;
var options = {
legend: {
display: false
},
scales: {
yAxes: [{
ticks: {
min: 0,
max: Math.round(scaleMax / (1024 * 1024)),
stepSize: stepSize,
beginAtZero: true
}
}]
}
};
var ctx = $('#memoryAppChart').get(0).getContext('2d');
new Chart(ctx, { type: 'line', data: data, options: options });
});
};
$scope.updateMemorySystemChart = function () {
var targets = [];
var targetsInfo = [];
targets.push('summarize(collectd.localhost.memory.memory-used, "1min", "avg")');
targetsInfo.push({ label: 'Memory (RAM)', color: '#2196F3' });
targets.push('summarize(collectd.localhost.swap.swap-used, "1min", "avg")');
targetsInfo.push({ label: 'Swap', color: '#2196A9' });
targets.push('summarize(sum(collectd.localhost.memory.memory-buffered, collectd.localhost.memory.memory-cached), "1min", "avg")');
targetsInfo.push({ label: 'Memory (Cached)', color: '#f0ad4e' });
targets.push('summarize(collectd.localhost.swap.swap-cached, "1min", "avg")');
targetsInfo.push({ label: 'Swap (Cached)', color: '#f0cd4e' });
targets.push('summarize(collectd.localhost.memory.memory-free, "1min", "avg")');
targetsInfo.push({ label: 'Memory (Free)', color: '#27DD65' });
targets.push('summarize(collectd.localhost.swap.swap-free, "1min", "avg")');
targetsInfo.push({ label: 'Swap (Free)', color: '#27CE65' });
Client.graphs(targets, '-1min', {}, function (error, result) {
if (error) return $scope.setError('memory', error);
$scope.memoryUsageSystem = result.map(function (data, index) {
return {
value: bytesToMegaBytes(data.datapoints[0][0]),
color: targetsInfo[index].color,
highlight: targetsInfo[index].color,
label: targetsInfo[index].label
};
});
var tmp = {
datasets: [{
data: result.map(function (data) { return bytesToMegaBytes(data.datapoints[0][0]); }),
backgroundColor: result.map(function (data, index) { return targetsInfo[index].color; })
}],
labels: result.map(function (data, index) { return targetsInfo[index].label; })
};
var options = {
onClick: function (/*event, dataset*/) {
$scope.setMemoryApp('system');
},
legend: { display: false }
};
var ctx = $('#memoryUsageSystemChart').get(0).getContext('2d');
new Chart(ctx, { type: 'doughnut', data: tmp, options: options });
});
};
$scope.updateMemoryAppsChart = function () {
var targets = [];
var targetsInfo = [];
colorIndex = 0;
$scope.installedApps.forEach(function (app) {
targets.push('summarize(collectd.localhost.table-' + app.id + '-memory.gauge-rss, "1min", "avg")');
targetsInfo.push({
label: app.fqdn,
color: getNextColor(),
app: app
});
});
// we split up the request, to avoid too large query strings into graphite
var tmp = [];
var aggregatedResult= [];
while (targets.length > 0) tmp.push(targets.splice(0, 10));
asyncForEach(tmp, function (targets, callback) {
Client.graphs(targets, '-1min', {}, function (error, result) {
if (error) return callback(error);
aggregatedResult = aggregatedResult.concat(result);
callback(null);
});
}, function (error) {
if (error) return $scope.setError('memory', error);
$scope.memoryUsageApps = aggregatedResult.map(function (data, index) {
return {
value: bytesToMegaBytes(data.datapoints[0][0]),
color: targetsInfo[index].color,
highlight: targetsInfo[index].color,
label: targetsInfo[index].label
};
});
var tmp = {
datasets: [{
data: aggregatedResult.map(function (data) { return bytesToMegaBytes(data.datapoints[0][0]); }),
backgroundColor: aggregatedResult.map(function (data, index) { return targetsInfo[index].color; })
}],
labels: aggregatedResult.map(function (data, index) { return targetsInfo[index].label; })
};
var options = {
onClick: function (event, dataset) {
var selectedDataInfo = targetsInfo.find(function (info) { return info.label === dataset[0]._model.label; });
if (selectedDataInfo) $scope.setMemoryApp(selectedDataInfo.app, selectedDataInfo.color);
},
legend: { display: false }
};
var ctx = $('#memoryUsageAppsChart').get(0).getContext('2d');
new Chart(ctx, { type: 'doughnut', data: tmp, options: options });
});
};
function getMemory(callback) {
Client.memory(function (error, memory) {
if (error) console.error(error);
$scope.memory = memory;
callback();
});
}
Client.onReady(function () {
getMemory(function () {
$scope.updateMemorySystemChart();
$scope.updateMemoryAppsChart();
$scope.setMemoryApp('system');
});
});
$('.modal-backdrop').remove();
}]);
+168 -36
View File
@@ -3,31 +3,31 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Configure IP Provider</h4>
<h4 class="modal-title">{{ 'network.configureIp.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="sysinfoForm" role="form" novalidate ng-submit="sysinfo.submit()" autocomplete="off">
<fieldset>
<div class="form-group">
<label class="control-label">Provider <sup><a ng-href="{{ config.webServerOrigin }}/documentation/networking/#ip-configuration" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label class="control-label">{{ 'network.ip.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/networking/#ipv4" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="sysinfo.newProvider" ng-options="a.value as a.name for a in sysinfoProvider"></select>
</div>
<div ng-show="sysinfo.newProvider === 'generic'">
The Public IP address of the server will be <a href="https://api.cloudron.io/api/v1/helper/public_ip" target="_blank">automatically detected</a>.
{{ 'network.configureIp.providerGenericDescription' | tr }} <sup><a ng-href="https://ipv4.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="sysinfo.newProvider === 'fixed'" ng-class="{ 'has-error': (!sysinfoForm.ip.$dirty && sysinfo.error.ip) }">
<label class="control-label">IP Address</label>
<input type="text" class="form-control" ng-model="sysinfo.newIp" name="ip" ng-disabled="sysinfo.busy" ng-required="sysinfo.newProvider === 'fixed'">
<p class="has-error" ng-show="sysinfo.error.ip">{{ sysinfo.error.ip }}</p>
<div class="form-group" ng-show="sysinfo.newProvider === 'fixed'" ng-class="{ 'has-error': (!sysinfoForm.ipv4.$dirty && sysinfo.error.ipv4) }">
<label class="control-label">{{ 'network.ipv4.address' | tr }}</label>
<input type="text" class="form-control" ng-model="sysinfo.newIPv4" name="ipv4" ng-disabled="sysinfo.busy" ng-required="sysinfo.newProvider === 'fixed'">
<p class="has-error" ng-show="sysinfo.error.ipv4">{{ sysinfo.error.ipv4 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="sysinfo.newProvider === 'network-interface'" ng-class="{ 'has-error': (!sysinfoForm.ifname.$dirty && sysinfo.error.ifname) }">
<label class="control-label">Interface Name</label>
<p>List available devices on the server with <code>ip -f inet -br addr</code></p>
<label class="control-label">{{ 'network.ip.interface' | tr }}</label>
<p>{{ 'network.ip.interfaceDescription' | tr }} <code>ip -f inet -br addr</code></p>
<input type="text" class="form-control" ng-model="sysinfo.newIfname" name="ifname" ng-disabled="sysinfo.busy" ng-required="sysinfo.newProvider === 'network-interface'">
<p class="has-error" ng-show="sysinfo.error.ifname">{{ sysinfo.error.ifname }}</p>
</div>
@@ -37,8 +37,80 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="sysinfo.submit()" ng-disabled="sysinfoForm.$invalid || sysinfo.busy"><i class="fa fa-circle-notch fa-spin" ng-show="sysinfo.busy"></i> Save</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="sysinfo.submit()" ng-disabled="sysinfoForm.$invalid || sysinfo.busy"><i class="fa fa-circle-notch fa-spin" ng-show="sysinfo.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal block list -->
<div class="modal fade" id="blocklistModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'network.firewall.configure.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="blocklistChangeForm" role="form" novalidate ng-submit="blocklist.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label">{{ 'network.firewall.blockedIpRanges' | tr }}</label>
<p class="small">{{ 'network.firewall.configure.description' | tr }}</p>
<div class="has-error" ng-show="blocklist.error.blocklist">{{ blocklist.error.blocklist }}</div>
<textarea ng-model="blocklist.blocklist" placeholder="{{ 'network.firewall.configure.blocklistPlaceholder' | tr }}" name="blocklist" class="form-control" ng-class="{ 'has-error': !blocklistChangeForm.blocklist.$dirty && blocklist.error.blocklist }" 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-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 IPv6 -->
<div class="modal fade" id="ipv6ConfigureModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'network.configureIpv6.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="ipv6ConfigureForm" role="form" novalidate ng-submit="ipv6Configure.submit()" autocomplete="off">
<fieldset>
<div class="form-group">
<label class="control-label">{{ 'network.ip.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/networking/#ipv6" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" ng-model="ipv6Configure.newProvider" ng-options="a.value as a.name for a in ipv6ConfigureProvider"></select>
</div>
<div ng-show="ipv6Configure.newProvider === 'generic'">
{{ 'network.configureIp.providerGenericDescription' | tr }} <sup><a ng-href="https://ipv6.api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</div>
<!-- Fixed -->
<div class="form-group" ng-show="ipv6Configure.newProvider === 'fixed'" ng-class="{ 'has-error': (!ipv6ConfigureForm.ipv4.$dirty && ipv6Configure.error.ipv6) }">
<label class="control-label">{{ 'network.ipv6.address' | tr }}</label>
<input type="text" class="form-control" ng-model="ipv6Configure.newIPv6" name="ipv6" ng-disabled="ipv6Configure.busy" ng-required="ipv6Configure.newProvider === 'fixed'">
<p class="has-error" ng-show="ipv6Configure.error.ipv6">{{ ipv6Configure.error.ipv6 }}</p>
</div>
<!-- Network Interface -->
<div class="form-group" ng-show="ipv6Configure.newProvider === 'network-interface'" ng-class="{ 'has-error': (!ipv6ConfigureForm.ifname.$dirty && ipv6Configure.error.ifname) }">
<label class="control-label">{{ 'network.ip.interface' | tr }}</label>
<p>{{ 'network.ip.interfaceDescription' | tr }} <code>ip -f inet6 -br addr</code></p>
<input type="text" class="form-control" ng-model="ipv6Configure.newIfname" name="ifname" ng-disabled="ipv6Configure.busy" ng-required="ipv6Configure.newProvider === 'network-interface'">
<p class="has-error" ng-show="ipv6Configure.error.ifname">{{ ipv6Configure.error.ifname }}</p>
</div>
<input class="ng-hide" type="submit" ng-disabled="ipv6ConfigureForm.$invalid || ipv6Configure.busy"/>
</fieldset>
</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="ipv6Configure.submit()" ng-disabled="ipv6ConfigureForm.$invalid || ipv6Configure.busy"><i class="fa fa-circle-notch fa-spin" ng-show="ipv6Configure.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
@@ -46,43 +118,44 @@
<div class="content">
<div class="text-left">
<h1>Network</h1>
<h1>{{ 'network.title' | tr }}</h1>
</div>
<!-- IPv4 -->
<div class="text-left">
<h3>IP Address</h3>
<h3>{{ 'network.ip.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-xs-12">
Cloudron uses this IP address when setting up DNS records.
{{ 'network.ip.description' | tr }}
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Provider</span>
<span class="text-muted">{{ 'network.ip.provider' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ prettySysinfoProviderName(sysinfo.provider) }}</span>
<span>{{ prettyIpProviderName(sysinfo.provider) }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">IP Address</span>
<span class="text-muted">{{ 'network.ip.address' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="sysinfo.ip">{{ sysinfo.ip }}</span>
<span ng-show="!sysinfo.ip">{{ sysinfo.serverIp }} (detected)</span>
<span ng-show="sysinfo.ipv4">{{ sysinfo.ipv4 }}</span>
<span ng-show="!sysinfo.ipv4">{{ sysinfo.serverIPv4 }} ({{ 'network.ip.detected' | tr }})</span>
</div>
</div>
<div class="row" ng-show="sysinfo.ifname">
<div class="col-xs-6">
<span class="text-muted">Network Interface Name</span>
<span class="text-muted">{{ 'network.ip.interface' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ sysinfo.ifname }}</span>
@@ -93,38 +166,97 @@
<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="sysinfo.show()">Configure</button>
<button class="btn btn-outline btn-primary pull-right" ng-click="sysinfo.show()">{{ 'network.ip.configure' | tr }}</button>
</div>
</div>
</div>
<!-- Firewall -->
<div class="text-left" ng-show="user.isAtLeastOwner">
<h3>{{ 'network.firewall.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.isAtLeastOwner">
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.firewall.blockedIpRanges' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<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>
</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>
<div class="text-left">
<h3>Dynamic DNS</h3>
<h3>{{ 'network.dyndns.title' | tr }}</h3>
</div>
<div class="card">
<div class="row">
<div class="col-md-12">
<p>
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.
</p>
<p>{{ 'network.dyndns.description' | tr }}</p>
<p class="text-danger" ng-show="dyndnsConfigure.error"><br/>{{ dyndnsConfigure.error }}</p>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="dyndnsConfigure.enabled" name="dynamicDns" ng-disabled="dyndnsConfigure.busy"/>&nbsp; Use Dynamic DNS
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<span class="text-success text-bold" ng-show="dyndnsConfigure.success">Saved</span>
<div class="col-md-2" style="padding-top: 12px;">
<i class="fa fa-circle" ng-class="{ 'status-active': dyndnsConfigure.isEnabled, 'status-inactive': !dyndnsConfigure.isEnabled }"></i> {{ dyndnsConfigure.isEnabled ? 'main.statusEnabled' : 'main.statusDisabled' | tr }}
</div>
<div class="col-md-6 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="dyndnsConfigure.submit()" ng-disabled="dyndnsConfigure.currentState === dyndnsConfigure.enabled"><i class="fa fa-circle-notch fa-spin" ng-show="dyndnsConfigure.busy"></i> Save</button>
<div class="col-md-10 text-right">
<button class="btn btn-outline btn-primary" ng-hide="dyndnsConfigure.isEnabled" ng-click="dyndnsConfigure.setEnabled(true)" ng-disabled="dyndnsConfigure.busy"><i class="fa fa-circle-notch fa-spin" ng-show="dyndnsConfigure.busy"></i> {{ 'main.enableAction' | tr }}</button>
<button class="btn btn-outline btn-danger" ng-show="dyndnsConfigure.isEnabled" ng-click="dyndnsConfigure.setEnabled(false)" ng-disabled="dyndnsConfigure.busy"><i class="fa fa-circle-notch fa-spin" ng-show="dyndnsConfigure.busy"></i> {{ 'main.disableAction' | tr }}</button>
</div>
</div>
</div>
+159 -23
View File
@@ -6,6 +6,7 @@
angular.module('Application').controller('NetworkController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
// keep in sync with sysinfo.js
@@ -15,8 +16,16 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettySysinfoProviderName = function (provider) {
$scope.ipv6ConfigureProvider = [
{ name: 'Disabled', value: 'noop' },
{ name: 'Public IP', value: 'generic' },
{ name: 'Static IP Address', value: 'fixed' },
{ name: 'Network Interface', value: 'network-interface' }
];
$scope.prettyIpProviderName = function (provider) {
switch (provider) {
case 'noop': return 'Disabled';
case 'generic': return 'Public IP';
case 'fixed': return 'Static IP Address';
case 'network-interface': return 'Network Interface';
@@ -26,31 +35,156 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
$scope.dyndnsConfigure = {
busy: false,
success: false,
error: '',
currentState: false,
enabled: false,
isEnabled: false,
refresh: function () {
Client.getDynamicDnsConfig(function (error, enabled) {
if (error) return console.error(error);
$scope.dyndnsConfigure.currentState = enabled;
$scope.dyndnsConfigure.enabled = enabled;
$scope.dyndnsConfigure.isEnabled = enabled;
});
},
submit: function () {
setEnabled: function (enabled) {
$scope.dyndnsConfigure.busy = true;
$scope.dyndnsConfigure.success = false;
$scope.dyndnsConfigure.error = '';
Client.setDynamicDnsConfig($scope.dyndnsConfigure.enabled, function (error) {
if (error) $scope.dyndnsConfigure.error = error.message;
else $scope.dyndnsConfigure.currentState = $scope.dyndnsConfigure.enabled;
Client.setDynamicDnsConfig(enabled, function (error) {
$scope.dyndnsConfigure.busy = false;
$scope.dyndnsConfigure.success = true;
if (error) $scope.dyndnsConfigure.error = error.message;
else $scope.dyndnsConfigure.isEnabled = enabled;
});
}
};
$scope.ipv6Configure = {
busy: false,
error: {},
displayError: null,
serverIPv6: '',
provider: '',
ipv6: '',
ifname: '',
// configure dialog
newProvider: '',
newIPv6: '',
newIfname: '',
refresh: function () {
Client.getIPv6Config(function (error, result) {
if (error) {
$scope.ipv6Configure.displayError = error.message;
return console.error(error);
}
$scope.ipv6Configure.provider = result.provider;
$scope.ipv6Configure.ipv6 = result.ipv6 || '';
$scope.ipv6Configure.ifname = result.ifname || '';
if (result.provider === 'noop') return;
Client.getServerIpv6(function (error, result) {
if (error) {
$scope.ipv6Configure.displayError = error.message;
return console.error(error);
}
$scope.ipv6Configure.serverIPv6 = result.ipv6;
});
});
},
show: function () {
$scope.ipv6Configure.error = {};
$scope.ipv6Configure.newProvider = $scope.ipv6Configure.provider;
$scope.ipv6Configure.newIPv6 = $scope.ipv6Configure.ipv6;
$scope.ipv6Configure.newIfname = $scope.ipv6Configure.ifname;
$('#ipv6ConfigureModal').modal('show');
},
submit: function () {
$scope.ipv6Configure.error = {};
$scope.ipv6Configure.busy = true;
var config = {
provider: $scope.ipv6Configure.newProvider
};
if (config.provider === 'fixed') {
config.ipv4 = $scope.ipv6Configure.newIPv4;
} else if (config.provider === 'network-interface') {
config.ifname = $scope.ipv6Configure.newIfname;
}
Client.setIPv6Config(config, function (error) {
$scope.ipv6Configure.busy = false;
if (error && error.message.indexOf('ipv') !== -1) {
$scope.ipv6Configure.error.ipv6 = error.message;
$scope.ipv6ConfigureForm.$setPristine();
$scope.ipv6ConfigureForm.$setUntouched();
return;
} else if (error && (error.message.indexOf('interface') !== -1 || error.message.indexOf('IPv6') !== -1)) {
$scope.ipv6Configure.error.ifname = error.message;
$scope.ipv6ConfigureForm.$setPristine();
$scope.ipv6ConfigureForm.$setUntouched();
return;
} else if (error) {
console.error(error);
return;
}
$scope.ipv6Configure.refresh();
$('#ipv6ConfigureModal').modal('hide');
});
}
};
$scope.blocklist = {
busy: false,
error: {},
blocklist: '',
currentBlocklist: '',
currentBlocklistLength: 0,
refresh: function () {
Client.getBlocklist(function (error, result) {
if (error) return console.error(error);
$scope.blocklist.currentBlocklist = result;
$scope.blocklist.currentBlocklistLength = result.split('\n').filter(function (l) { return l.length !== 0 && l[0] !== '#'; }).length;
});
},
show: function () {
$scope.blocklist.error = {};
$scope.blocklist.blocklist = $scope.blocklist.currentBlocklist;
$('#blocklistModal').modal('show');
},
submit: function () {
$scope.blocklist.error = {};
$scope.blocklist.busy = true;
Client.setBlocklist($scope.blocklist.blocklist, function (error) {
$scope.blocklist.busy = false;
if (error) {
$scope.blocklist.error.blocklist = error.message;
$scope.blocklist.error.ip = error.message;
$scope.blocklistChangeForm.$setPristine();
$scope.blocklistChangeForm.$setUntouched();
return;
}
$scope.blocklist.refresh();
$('#blocklistModal').modal('hide');
});
}
};
@@ -59,15 +193,15 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
busy: false,
error: {},
serverIp: '',
serverIPv4: '',
provider: '',
ip: '',
ipv4: '',
ifname: '',
// configure dialog
newProvider: '',
newIp: '',
newIPv4: '',
newIfname: '',
refresh: function () {
@@ -75,13 +209,13 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
if (error) return console.error(error);
$scope.sysinfo.provider = result.provider;
$scope.sysinfo.ip = result.ip || '';
$scope.sysinfo.ipv4 = result.ipv4 || '';
$scope.sysinfo.ifname = result.ifname || '';
Client.getServerIp(function (error, ip) {
Client.getServerIpv4(function (error, result) {
if (error) return console.error(error);
$scope.sysinfo.serverIp = ip;
$scope.sysinfo.serverIPv4 = result.ipv4;
});
});
},
@@ -89,7 +223,7 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
show: function () {
$scope.sysinfo.error = {};
$scope.sysinfo.newProvider = $scope.sysinfo.provider;
$scope.sysinfo.newIp = $scope.sysinfo.ip;
$scope.sysinfo.newIPv4 = $scope.sysinfo.ipv4;
$scope.sysinfo.newIfname = $scope.sysinfo.ifname;
$('#sysinfoModal').modal('show');
@@ -104,15 +238,15 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
};
if (config.provider === 'fixed') {
config.ip = $scope.sysinfo.newIp;
config.ipv4 = $scope.sysinfo.newIPv4;
} else if (config.provider === 'network-interface') {
config.ifname = $scope.sysinfo.newIfname;
}
Client.setSysinfoConfig(config, function (error) {
$scope.sysinfo.busy = false;
if (error && error.message.indexOf('ip') !== -1) {
$scope.sysinfo.error.ip = error.message;
if (error && error.message.indexOf('ipv') !== -1) {
$scope.sysinfo.error.ipv4 = error.message;
$scope.sysinfoForm.$setPristine();
$scope.sysinfoForm.$setUntouched();
return;
@@ -137,6 +271,8 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
$scope.sysinfo.refresh();
$scope.dyndnsConfigure.refresh();
$scope.ipv6Configure.refresh();
if ($scope.user.isAtLeastOwner) $scope.blocklist.refresh();
});
$('.modal-backdrop').remove();
+16 -29
View File
@@ -1,50 +1,37 @@
<!-- Modal reboot server -->
<div class="modal fade" id="rebootModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Really reboot server?</h4>
</div>
<div class="modal-body">
<p class="text-bold">Rebooting the server will cause temporary downtime for all apps installed on this Cloudron!</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="reboot.submit()" ng-disabled="reboot.busy"><i class="fa fa-circle-notch fa-spin" ng-show="reboot.busy"></i> Reboot now</button>
</div>
</div>
</div>
</div>
<div class="content">
<div class="text-left">
<h1>Notifications <button class="btn btn-primary btn-outline pull-right" ng-click="notifications.clearAll()" ng-disabled="clearAllBusy"><i class="fa fa-circle-notch fa-spin" ng-show="clearAllBusy"></i><i class="fa fa-check" ng-hide="clearAllBusy"></i> Clear All</button></h1>
<h1>{{ 'notifications.title' | tr }}
<div class="title-toolbar">
<button class="btn btn-default btn-outline" ng-click="showPrevPage()" ng-disabled="busy || currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="showNextPage()" ng-disabled="busy || perPage > notifications.length">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
<button class="btn btn-primary btn-outline" ng-click="clearAll()" ng-disabled="!hasUnread || clearAllBusy"><i class="fa fa-circle-notch fa-spin" ng-show="clearAllBusy"></i><i class="fa fa-check" ng-hide="clearAllBusy"></i> {{ 'notifications.markAllAsRead' | tr }}</button>
</div>
</h1>
</div>
<div class="col-lg-12 text-center" ng-show="notifications.busy">
<div class="col-lg-12 text-center" ng-show="busy">
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="card" ng-hide="notifications.busy || notifications.notifications.length">
<div class="card" ng-hide="busy || notifications.length">
<div class="row">
<div class="col-xs-12">
<h3 class="text-center" style="margin: 20px;">All Caught Up!</h3>
<h3 class="text-center" style="margin: 20px;">{{ 'notifications.nonePending' | tr }}</h3>
</div>
</div>
</div>
<div class="card notification-item" ng-repeat="notification in notifications.notifications">
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed">
<div class="row">
<div class="col-xs-12" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-class="{ 'notification-details': notification.detailsShown }">
{{ notification.title }} <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
<button class="btn btn-xs btn-default pull-right" ng-hide="notification.acknowledged" ng-click="notifications.ack(notification, $event)" uib-tooltip="Dismiss"><i class="fa fa-times"></i></button>
<div uib-collapse="notification.isCollapsed" expanding="notificationExpanding(notification)">
<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>
<div uib-collapse="notification.isCollapsed" expanding="ack(notification)">
<br/>
<p ng-hide="notification.messageJson" ng-click="$event.stopPropagation();" style="cursor: auto; overflow: auto;" ng-bind-html="notification.message | markdown2html"></p>
<pre ng-show="notification.messageJson" ng-click="$event.stopPropagation();" style="cursor: auto">{{ notification.messageJson | json }}</pre>
<button type="button" class="btn btn-danger pull-right" ng-click="$event.stopPropagation(); reboot.show(event)" ng-show="notification.title === 'Reboot Required'" ng-disabled="reboot.busy"><i class="fa fa-circle-notch fa-spin" ng-show="reboot.busy"></i> Reboot</button>
<button type="button" class="btn btn-danger pull-right" ng-click="$event.stopPropagation(); $parent.reboot.show()" ng-show="notification.title === 'Reboot Required'" ng-disabled="reboot.busy"><i class="fa fa-circle-notch fa-spin" ng-show="reboot.busy"></i> {{ 'main.action.reboot' | tr }}</button>
</div>
</div>
</div>
+63 -85
View File
@@ -1,115 +1,93 @@
'use strict';
/* global asyncForEach, angular, $ */
/* global async */
/* global angular */
angular.module('Application').controller('NotificationsController', ['$scope', '$timeout', 'Client', function ($scope, $timeout, Client) {
angular.module('Application').controller('NotificationsController', ['$scope', '$location', '$timeout', '$translate', '$interval', 'Client', function ($scope, $location, $timeout, $translate, $interval, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.clearAllBusy = false;
$scope.reboot = {
busy: false,
$scope.notifications = [];
$scope.activeNotification = null;
$scope.busy = true;
$scope.hasUnread = false;
$scope.currentPage = 1;
$scope.perPage = 20;
show: function () {
$scope.reboot.busy = false;
$('#rebootModal').modal('show');
},
$scope.refresh = function () {
Client.getNotifications({}, $scope.currentPage, $scope.perPage, function (error, result) {
if (error) return console.error(error);
submit: function () {
$scope.reboot.busy = true;
// collapse by default
result.forEach(function (r) { r.isCollapsed = 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);
// attempt to translate or parse the message as json
result.forEach(function (r) {
try {
r.messageJson = JSON.parse(r.message);
} catch (e) {}
});
}
$scope.notifications = result;
$scope.hasUnread = !!result.find(function (n) { return !n.acknowledged; });
$scope.busy = false;
});
};
$scope.notifications = {
notifications: [],
activeNotification: null,
busy: true,
$scope.showNextPage = function () {
$scope.currentPage++;
$scope.refresh();
};
refresh: function () {
Client.getNotifications(false, 1, 100, function (error, result) {
if (error) return console.error(error);
$scope.showPrevPage = function () {
if ($scope.currentPage > 1) $scope.currentPage--;
else $scope.currentPage = 1;
// collapse by default
result.forEach(function (r) { r.isCollapsed = true; });
$scope.refresh();
};
// attempt to parse the message as json
result.forEach(function (r) {
try {
r.messageJson = JSON.parse(r.message);
} catch (e) {}
});
$scope.ack = function (notification) {
if (notification.acknowledged) return;
$scope.notifications.notifications = result;
Client.ackNotification(notification.id, true, function (error) {
if (error) console.error(error);
$scope.notifications.busy = false;
});
},
notification.acknowledged = true;
$scope.$parent.notificationAcknowledged();
});
};
clicked: function (notification) {
if ($scope.notifications.activeNotification === notification) return $scope.notifications.activeNotification = null;
$scope.notifications.activeNotification = notification;
},
$scope.clearAll = function () {
$scope.clearAllBusy = true;
ack: function (notification, event, callback) {
callback = callback || function (error) { if (error) console.error(error); };
async.eachLimit($scope.notifications, 20, function (notification, callback) {
if (notification.acknowledged) return callback();
if (event) event.stopPropagation();
Client.ackNotification(notification.id, function (error) {
if (error) return callback(error);
$scope.$parent.notificationAcknowledged(notification.id);
$scope.notifications.refresh();
Client.ackNotification(notification.id, true, function (error) {
if (error) {
console.error(error);
} else {
notification.acknowledged = true;
$scope.$parent.notificationAcknowledged();
}
callback();
});
},
}, function (error) {
if (error) console.error(error);
action: function (notification) {
if (notification.action) window.location = notification.action;
},
clearAll: function () {
$scope.clearAllBusy = true;
asyncForEach($scope.notifications.notifications, function (notification, callback) {
if (notification.acknowledged) return callback();
$scope.notifications.ack(notification, null /* no click event */, callback);
}, function (error) {
if (error) console.error(error);
$scope.clearAllBusy = false;
});
}
};
$scope.notificationExpanding = function (notification) {
if (!notification.eventId) return;
notification.busyLoadEvent = true;
Client.getEvent(notification.eventId, function (error, result) {
notification.busyLoadEvent = false;
if (error) return console.error(error);
notification.event = result;
$scope.hasUnread = false;
$scope.clearAllBusy = false;
});
};
Client.onReady(function () {
$scope.notifications.refresh();
var refreshTimer = $interval($scope.refresh, 60 * 1000); // keep this interval in sync with the notification count indicator in main.js
$scope.$on('$destroy', function () {
$interval.cancel(refreshTimer);
});
$scope.refresh();
});
Client.onReconnect(function () {
$scope.notifications.refresh();
})
}]);
+240 -208
View File
@@ -4,163 +4,179 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change your Avatar</h4>
<h4 class="modal-title">{{ 'profile.changeAvatar.title' | tr }}</h4>
</div>
<div class="modal-body settings-avatar-selector">
<div class="radio">
<label>
<input type="radio" name="useGravatar" ng-model="avatarChange.useGravatar" value="true_string">
Use <a target="_blank" href="https://gravatar.com/">Gravatar</a>
</label>
<div style="margin: auto; text-align: left">
<div class="radio">
<label>
<input type="radio" name="avatarType" ng-model="avatarChange.type" value="">
{{ 'profile.changeAvatar.noAvatar' | tr }}
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="avatarType" ng-model="avatarChange.type" value="gravatar">
<span ng-bind-html="'profile.changeAvatar.useGravatar' | tr:{ gravatarLink: 'https://gravatar.com/' }"></span>
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="avatarType" ng-model="avatarChange.type" value="custom">
{{ 'profile.changeAvatar.useCustomPicture' | tr }}
</label>
</div>
</div>
<div class="radio">
<label>
<input type="radio" name="useGravatar" ng-model="avatarChange.useGravatar" value="">
Use Custom Picture
</label>
</div>
<div ng-hide="avatarChange.useGravatar" class="preview-avatar">
<div ng-show="avatarChange.type === 'custom'" class="preview-avatar">
<img id="previewAvatar" width="128" height="128" class="copy" ng-click="avatarChange.showCustomAvatarSelector()"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/png"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/*"/>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.doChangeAvatar()" ng-disabled="avatarChange.busy || (avatarChange.useGravatarOrig === avatarChange.useGravatar && avatarChange.useGravatar) || (!avatarChange.useGravatar && !avatarChange.pictureChanged)"><i class="fa fa-circle-notch fa-spin" ng-show="avatarChange.busy"></i> Save</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="avatarChange.doChangeAvatar()" ng-disabled="avatarChange.busy || (avatarChange.typeOrig === avatarChange.type && !avatarChange.pictureChanged) || (avatarChange.type === 'custom' && !avatarChange.pictureChanged)"><i class="fa fa-circle-notch fa-spin" ng-show="avatarChange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal change password -->
<div class="modal fade" id="passwordChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change your password</h4>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'profile.changePassword.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="passwordChangeForm" role="form" novalidate ng-submit="passwordchange.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid) }">
<label class="control-label" for="inputPasswordChangePassword">{{ 'profile.changePassword.currentPassword' | tr }}</label>
<div class="control-label" ng-show="(!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid)">
<small ng-show="!passwordChangeForm.password.$dirty && passwordchange.error.password">Wrong password</small>
<small ng-show="passwordChangeForm.password.$dirty && passwordChangeForm.password.$error.required">A password is required</small>
</div>
<div class="modal-body">
<form name="passwordChangeForm" role="form" novalidate ng-submit="passwordchange.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid) }">
<label class="control-label" for="inputPasswordChangePassword">Current password</label>
<div class="control-label" ng-show="(!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid)">
<small ng-show="!passwordChangeForm.password.$dirty && passwordchange.error.password">Wrong password</small>
<small ng-show="passwordChangeForm.password.$dirty && passwordChangeForm.password.$error.required">A password is required</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.password" id="inputPasswordChangePassword" name="password" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid) }">
<label class="control-label" for="inputPasswordChangeNewPassword">New password</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid)">
<small ng-show="!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword">{{ passwordchange.error.newPassword }}<br/><br/></small>
<small ng-show=" passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid">Password must be atleast 8 and at most 265 characters</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.newPassword" id="inputPasswordChangeNewPassword" name="newPassword" ng-minlength="8" ng-maxlength="256" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat) }">
<label class="control-label" for="inputPasswordChangeNewPasswordRepeat">Repeat new password</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat)">
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required">A password is required</small>
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat && passwordchange.newPasswordRepeat">Passwords don't match</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.newPasswordRepeat" id="inputPasswordChangeNewPasswordRepeat" name="newPasswordRepeat" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="passwordChangeForm.$invalid"/>
</form>
<input type="password" class="form-control" ng-model="passwordchange.password" id="inputPasswordChangePassword" name="password" required autofocus password-reveal>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid) }">
<label class="control-label" for="inputPasswordChangeNewPassword">{{ 'profile.changePassword.newPassword' | tr }}</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid)">
<small ng-show="!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword">{{ passwordchange.error.newPassword }}<br/><br/></small>
<small ng-show=" passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid">{{ 'profile.changePassword.errorPasswordInvalid' | tr }}</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="passwordchange.submit()" ng-disabled="passwordChangeForm.$invalid || passwordchange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="passwordchange.busy"></i> Change</button>
<input type="password" class="form-control" ng-model="passwordchange.newPassword" id="inputPasswordChangeNewPassword" name="newPassword" ng-minlength="8" ng-maxlength="256" required autofocus password-reveal>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat) }">
<label class="control-label" for="inputPasswordChangeNewPasswordRepeat">{{ 'profile.changePassword.newPasswordRepeat' | tr }}</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat)">
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required">{{ 'profile.changePassword.errorPasswordRequired' | tr }}</small>
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat && passwordchange.newPasswordRepeat">{{ 'profile.changePassword.errorPasswordsDontMatch' | tr }}</small>
</div>
</div>
<input type="password" class="form-control" ng-model="passwordchange.newPasswordRepeat" id="inputPasswordChangeNewPasswordRepeat" name="newPasswordRepeat" required autofocus password-reveal>
</div>
<input class="ng-hide" type="submit" ng-disabled="passwordChangeForm.$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="passwordchange.submit()" ng-disabled="passwordChangeForm.$invalid || passwordchange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="passwordchange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal change email -->
<div class="modal fade" id="emailChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change primary email address</h4>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<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)">
<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>
</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)}">
<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">A valid email address is required</small>
<small ng-show="(emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) && !emailChangeForm.email.$error.required">The Email address is not valid</small>
<small ng-show="!emailChangeForm.email.$dirty && emailchange.error.email">{{ emailchange.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="emailchange.email" id="inputEmailChangeEmail" name="email" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="emailChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</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> Change</button>
</div>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="emailChangeForm.$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="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>
</div>
<!-- Modal change fallback email -->
<div class="modal fade" id="fallbackEmailChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change password recovery email address</h4>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'profile.changeFallbackEmail.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="fallbackEmailChangeForm" role="form" novalidate ng-submit="fallbackEmailChange.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid) || (!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email)}">
<label class="control-label" for="inputFallbackEmailChangeEmail">{{ 'profile.changeFallbackEmail.email' | tr }}</label>
<input type="email" class="form-control" ng-model="fallbackEmailChange.email" id="inputFallbackEmailChangeEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email) || (fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid)">
<small ng-show="fallbackEmailChangeForm.email.$error.required">{{ 'profile.changeFallbackEmail.errorEmailRequired' | tr }}</small>
<small ng-show="(fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid) && !fallbackEmailChangeForm.email.$error.required">{{ 'profile.changeFallbackEmail.errorEmailInvalid' | tr }}</small>
<small ng-show="!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email">{{ fallbackEmailChange.error.email }}</small>
</div>
<div class="modal-body">
<form name="fallbackEmailChangeForm" role="form" novalidate ng-submit="fallbackEmailChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid) || (!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email)}">
<div class="control-label" ng-show="(!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email) || (fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid)">
<small ng-show="fallbackEmailChangeForm.email.$error.required">A valid email address is required</small>
<small ng-show="(fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid) && !fallbackEmailChangeForm.email.$error.required">The Email address is not valid</small>
<small ng-show="!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email">{{ fallbackEmailChange.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="fallbackEmailChange.email" id="inputfallbackEmailChangeEmail" name="email" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="fallbackEmailChangeForm.$invalid"/>
</form>
</div>
<div class="form-group" ng-class="{ 'has-error': (!fallbackEmailChangeForm.password.$dirty && fallbackEmailChange.error.password) || (fallbackEmailChangeForm.password.$dirty && fallbackEmailChangeForm.password.$invalid) }">
<label class="control-label" for="inputFallbackEmailChangePassword">{{ 'profile.changeFallbackEmail.password' | tr }}</label>
<input type="password" class="form-control" ng-model="fallbackEmailChange.password" id="inputFallbackEmailChangePassword" name="password" required autofocus password-reveal>
<div class="control-label" ng-show="(!fallbackEmailChangeForm.password.$dirty && fallbackEmailChange.error.password) || (fallbackEmailChangeForm.password.$dirty && fallbackEmailChangeForm.password.$invalid)">
<small ng-show="!fallbackEmailChangeForm.password.$dirty && fallbackEmailChange.error.password">{{ 'profile.changeFallbackEmail.errorWrongPassword' | tr }}</small>
<small ng-show="fallbackEmailChangeForm.password.$dirty && fallbackEmailChangeForm.password.$error.required">{{ 'profile.changeFallbackEmail.errorPasswordRequired' | tr }}</small>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="fallbackEmailChange.submit()" ng-disabled="fallbackEmailChangeForm.$invalid || fallbackEmailChange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="fallbackEmailChange.busy"></i> Change</button>
</div>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="fallbackEmailChangeForm.$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="fallbackEmailChange.submit()" ng-disabled="fallbackEmailChangeForm.$invalid || fallbackEmailChange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="fallbackEmailChange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal change displayName -->
<div class="modal fade" id="displayNameChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change your display name</h4>
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'profile.changeDisplayName.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="displayNameChangeForm" role="form" novalidate ng-submit="displayNameChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid) || (!displayNameChangeForm.displayName.$dirty && displayNameChange.error.displayName)}">
<input type="text" class="form-control" ng-model="displayNameChange.displayName" id="inputDisplayNameChangeDisplayName" name="displayName" required autofocus>
<div class="control-label" ng-show="(!displayNameChangeForm.displayName.$dirty && displayNameChange.error.displayName) || (displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid)">
<small ng-show="displayNameChangeForm.displayName.$error.required">{{ 'profile.changeDisplayName.errorDisplayNameRequired' | tr }}</small>
<small ng-show="(displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid) && !displayNameChangeForm.displayName.$error.required">{{ 'profile.changeDisplayName.errorNameInvalid' | tr }}</small>
<small ng-show="!displayNameChangeForm.email.$dirty && displayNameChange.error.displayName">{{ displayNameChange.error.displayName }}</small>
</div>
<div class="modal-body">
<form name="displayNameChangeForm" role="form" novalidate ng-submit="displayNameChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid) || (!displayNameChangeForm.displayName.$dirty && displayNameChange.error.displayName)}">
<label class="control-label">Display name</label>
<div class="control-label" ng-show="(!displayNameChangeForm.displayName.$dirty && displayNameChange.error.displayName) || (displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid)">
<small ng-show="displayNameChangeForm.displayName.$error.required">A valid display name is required</small>
<small ng-show="(displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid) && !displayNameChangeForm.displayName.$error.required">This display name is not valid</small>
<small ng-show="!displayNameChangeForm.email.$dirty && displayNameChange.error.displayName">{{ displayNameChange.error.displayName }}</small>
</div>
<input type="text" class="form-control" ng-model="displayNameChange.displayName" id="inputDisplayNameChangeDisplayName" name="displayName" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="displayNameChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="displayNameChange.submit()" ng-disabled="displayNameChangeForm.$invalid || displayNameChange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="displayNameChange.busy"></i> Change</button>
</div>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="displayNameChangeForm.$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="displayNameChange.submit()" ng-disabled="displayNameChangeForm.$invalid || displayNameChange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="displayNameChange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal enable twofactor authentication -->
@@ -168,15 +184,14 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Enable Two-Factor Authentication</h4>
<h4 class="modal-title">{{ 'profile.enable2FA.title' | tr }}</h4>
</div>
<div class="modal-body text-center" ng-hide="twoFactorAuthentication.secret">
<p class="modal-body" ng-show="twoFactorAuthentication.mandatory2FAHelp && !twoFactorAuthentication.secret">{{ 'profile.enable2FA.description' | tr }}</p>
<div class="modal-body text-center" ng-show="!twoFactorAuthentication.mandatory2FAHelp && !twoFactorAuthentication.secret">
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="modal-body" ng-show="twoFactorAuthentication.secret">
<p>
Use Google Authenticator (<a href="https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2" target="_blank">Android</a>, <a href="https://itunes.apple.com/us/app/google-authenticator/id388497605" target="_blank">iOS</a>), FreeOTP authenticator (<a href="https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp" target="_blank">Android</a>, <a href="https://itunes.apple.com/us/app/freeotp-authenticator/id872559395" target="_blank">iOS</a>) or a similar TOTP app to scan the secret.
</p>
<p ng-bind-html="'profile.enable2FA.authenticatorAppDescription' | tr:{ googleAuthenticatorPlayStoreLink: 'https://play.google.com/store/apps/details?id=com.google.android.apps.authenticator2', googleAuthenticatorITunesLink: 'https://itunes.apple.com/us/app/google-authenticator/id388497605', freeOTPPlayStoreLink: 'https://play.google.com/store/apps/details?id=org.fedorahosted.freeotp', freeOTPITunesLink: 'https://itunes.apple.com/us/app/freeotp-authenticator/id872559395' }"></p>
<center>
<img ng-src="{{ twoFactorAuthentication.qrcode }}"/>
<p>{{ twoFactorAuthentication.secret }}</p>
@@ -184,7 +199,7 @@
<br/>
<form name="twoFactorAuthenticationEnableForm" role="form" novalidate ng-submit="twoFactorAuthentication.enable()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (!twoFactorAuthenticationEnableForm.totpToken.$dirty && twoFactorAuthentication.error) || (twoFactorAuthenticationEnableForm.totpToken.$dirty && twoFactorAuthenticationEnableForm.totpToken.$invalid) }">
<label class="control-label">Token</label>
<label class="control-label">{{ 'profile.enable2FA.token' | tr }}</label>
<div class="control-label" ng-show="(!twoFactorAuthenticationEnableForm.totpToken.$dirty && twoFactorAuthentication.error) || (twoFactorAuthenticationEnableForm.totpToken.$dirty && twoFactorAuthenticationEnableForm.totpToken.$invalid)">
<small>{{ twoFactorAuthentication.error }}</small>
</div>
@@ -194,8 +209,9 @@
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="twoFactorAuthentication.enable()" ng-disabled="twoFactorAuthenticationEnableForm.$invalid || twoFactorAuthentication.busy"><i class="fa fa-circle-notch fa-spin" ng-show="twoFactorAuthentication.busy"></i> Enable</button>
<button type="button" class="btn btn-default" data-dismiss="modal" ng-if="!twoFactorAuthentication.mandatory2FA">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="twoFactorAuthentication.enable()" ng-show="twoFactorAuthentication.secret" ng-disabled="twoFactorAuthenticationEnableForm.$invalid || twoFactorAuthentication.busy"><i class="fa fa-circle-notch fa-spin" ng-show="twoFactorAuthentication.busy"></i> {{ 'profile.enable2FA.enable' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="twoFactorAuthentication.getSecret()" ng-show="twoFactorAuthentication.mandatory2FAHelp" >{{ 'profile.enable2FA.setup2FA' | tr }}</button>
</div>
</div>
</div>
@@ -206,23 +222,23 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Disable Two-Factor Authentication</h4>
<h4 class="modal-title">{{ 'profile.disable2FA.title' | tr }}</h4>
</div>
<div class="modal-body">
<form name="twoFactorAuthenticationDisableForm" role="form" novalidate ng-submit="twoFactorAuthentication.disable()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (!twoFactorAuthenticationDisableForm.password.$dirty && twoFactorAuthentication.error) || (twoFactorAuthenticationDisableForm.password.$dirty && twoFactorAuthenticationDisableForm.password.$invalid) }">
<label class="control-label">Password</label>
<label class="control-label">{{ 'profile.disable2FA.password' | tr }}</label>
<div class="control-label" ng-show="(!twoFactorAuthenticationDisableForm.password.$dirty && twoFactorAuthentication.error) || (twoFactorAuthenticationDisableForm.password.$dirty && twoFactorAuthenticationDisableForm.password.$invalid)">
<small>{{ twoFactorAuthentication.error }}</small>
</div>
<input type="password" class="form-control" ng-model="twoFactorAuthentication.password" id="twoFactorAuthenticationPasswordInput" name="password" required autofocus>
<input type="password" class="form-control" ng-model="twoFactorAuthentication.password" id="twoFactorAuthenticationPasswordInput" name="password" required autofocus password-reveal>
</div>
<input class="ng-hide" type="submit" ng-disabled="twoFactorAuthenticationDisableForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="twoFactorAuthentication.disable()" ng-disabled="twoFactorAuthenticationDisableForm.$invalid || twoFactorAuthentication.busy"><i class="fa fa-circle-notch fa-spin" ng-show="twoFactorAuthentication.busy"></i> Disable</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="twoFactorAuthentication.disable()" ng-disabled="twoFactorAuthenticationDisableForm.$invalid || twoFactorAuthentication.busy"><i class="fa fa-circle-notch fa-spin" ng-show="twoFactorAuthentication.busy"></i> {{ 'profile.disable2FA.disable' | tr }}</button>
</div>
</div>
</div>
@@ -233,21 +249,21 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Create App Password</h4>
<h4 class="modal-title">{{ 'profile.createAppPassword.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-hide="appPasswordAdd.password">
<form name="appPasswordAddForm" role="form" novalidate ng-submit="appPasswordAdd.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (appPasswordAddForm.name.$dirty && appPasswordAddForm.name.$invalid) || (!appPasswordAddForm.name.$dirty && appPasswordAdd.error.name)}">
<label class="control-label">Password Name</label>
<label class="control-label">{{ 'profile.createAppPassword.name' | tr }}</label>
<div class="control-label" ng-show="(!appPasswordAddForm.name.$dirty && appPasswordAdd.error.name) || (appPasswordAddForm.name.$dirty && appPasswordAddForm.name.$invalid)">
<small ng-show="appPasswordAddForm.name.$error.required">A name is required</small>
<small ng-show="appPasswordAddForm.name.$error.required">{{ 'profile.createAppPassword.errorNameRequired' | tr }}</small>
<small ng-show="appPasswordAdd.error.name">{{ appPasswordAdd.error.name }}</small>
</div>
<input type="text" class="form-control" ng-model="appPasswordAdd.name" id="inputAppPasswordAddName" name="name" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (appPasswordAddForm.identifier.$dirty && appPasswordAddForm.identifier.$invalid) || (!appPasswordAddForm.identifier.$dirty && appPasswordAdd.error.identifier)}">
<label class="control-label">App</label>
<label class="control-label">{{ 'profile.createAppPassword.app' | tr }}</label>
<select class="form-control" ng-model="appPasswordAdd.identifier" ng-options="a.id as a.label for a in appPassword.identifiers" required></select>
</div>
<input class="ng-hide" type="submit" ng-disabled="appPasswordAddForm.$invalid"/>
@@ -255,19 +271,18 @@
</div>
<div ng-show="appPasswordAdd.password">
Use the following password to authenticate against the app:
{{ 'profile.createAppPassword.description' | tr }}
<br/>
<b ng-click-select>{{ appPasswordAdd.password.password }}</b>
<br/>
<br/>
<p>Please copy the password now. It won't be shown again for security purposes.</p>
<p>{{ 'profile.createAppPassword.copyNow' | tr }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="appPasswordAdd.submit()" ng-hide="appPasswordAdd.password" ng-disabled="appPasswordAddForm.$invalid || appPasswordAdd.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="appPasswordAdd.busy"></i> Generate Password
<i class="fa fa-circle-notch fa-spin" ng-show="appPasswordAdd.busy"></i> {{ 'profile.createAppPassword.generatePassword' | tr }}
</button>
</div>
</div>
@@ -279,15 +294,15 @@
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Create API Token</h4>
<h4 class="modal-title">{{ 'profile.createApiToken.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-hide="tokens.add.accessToken">
<form name="apiTokenAddForm" role="form" novalidate ng-submit="tokens.add.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (apiTokenAddForm.name.$dirty && apiTokenAddForm.name.$invalid) || (!apiTokenAddForm.name.$dirty && tokens.add.error)}">
<label class="control-label">API Token Name</label>
<label class="control-label">{{ 'profile.createApiToken.name' | tr }}</label>
<div class="control-label" ng-show="(!apiTokenAddForm.name.$dirty && tokens.add.error) || (apiTokenAddForm.name.$dirty && apiTokenAddForm.name.$invalid)">
<small ng-show="apiTokenAddForm.name.$error.required">A name is required</small>
<small ng-show="apiTokenAddForm.name.$error.required">{{ 'profile.createApiToken.errorNameRequired' | tr }}</small>
<small ng-show="tokens.add.error.name">{{ tokens.add.error }}</small>
</div>
<input type="text" class="form-control" id="inputApiTokenName" ng-model="tokens.add.name" name="name" required autofocus>
@@ -297,19 +312,19 @@
</form>
</div>
<div ng-show="tokens.add.accessToken">
New API token:
{{ 'profile.createApiToken.description' | tr }}
<br/>
<b ng-click-select>{{ tokens.add.accessToken }}</b>
<br/>
<br/>
<p>Please copy the API token now. It won't be shown again for security purposes.</p>
<p>{{ 'profile.createApiToken.copyNow' | tr }}</p>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="tokens.add.submit()" ng-hide="tokens.add.accessToken" ng-disabled="apiTokenAddForm.$invalid || tokens.add.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="tokens.add.busy"></i> Generate API Token
<i class="fa fa-circle-notch fa-spin" ng-show="tokens.add.busy"></i> {{ 'profile.createApiToken.generateToken' | tr }}
</button>
</div>
</div>
@@ -319,76 +334,91 @@
<div class="content">
<div class="text-left">
<h1>Profile</h1>
<h1>{{ 'profile.title' | tr }}</h1>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-4" style="min-width: 150px;">
<div class="settings-avatar" ng-click="avatarChange.showChangeAvatar()" style="background-image: url('{{ user.avatarUrl }}');">
<div class="overlay"></div>
</div>
</div>
<div class="col-xs-8">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">Username</td>
<td class="text-right" style="vertical-align: top;">
{{ user.username }} &nbsp;&nbsp;&nbsp;
</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Display name</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
{{ user.displayName }} <a href="" ng-click="displayNameChange.show()" ng-hide="user.source"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Primary email</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
{{ user.email }} <a href="" ng-click="emailchange.show()" ng-hide="user.source"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Password recovery email</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
{{ user.fallbackEmail }} <a href="" ng-click="fallbackEmailChange.show()" ng-hide="user.source"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<tr>
<td class="text-right" colspan="2" style="vertical-align: top;">
<br/>
<button class="btn btn-primary" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'Disable 2FA' : 'Enable 2FA' }}</button>
<button class="btn btn-primary" ng-click="passwordchange.show()" ng-hide="user.source">Change Password</button>
</td>
</tr>
</table>
</div>
<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>
</div>
<div class="col-xs-9">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'main.username' | tr }}</td>
<td class="text-right" style="vertical-align: top;">
{{ user.username }} &nbsp;&nbsp;&nbsp;
</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'main.displayName' | tr }}</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
{{ user.displayName }} <a href="" ng-click="displayNameChange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
</td>
</tr>
<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>
</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">{{ 'profile.passwordRecoveryEmail' | tr }}</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
{{ 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>
<td colspan="2" class="text-right">
<a href="" ng-click="sendPasswordReset()">{{ 'profile.passwordResetAction' | tr }}</a>
</td>
</tr>
<tr><td colspan="2">&nbsp;</td></tr>
<tr>
<td class="text-muted" style="vertical-align: middle;">{{ 'profile.language' | tr }}</td>
<td class="text-right" style="vertical-align: middle;">
<multiselect ng-model="language" options="lang.display for lang in languages" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</td>
</tr>
<tr>
<td class="text-right" colspan="2" style="vertical-align: top;">
<br/>
<button class="btn" ng-class="user.twoFactorAuthenticationEnabled ? 'btn-danger' : 'btn-success'" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction' | tr }}</button>
<button class="btn btn-primary" ng-click="passwordchange.show()" ng-hide="user.source">{{ 'profile.changePasswordAction' | tr }}</button>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<br>
<div class="text-left">
<h3>App Passwords<button class="btn btn-primary btn-sm pull-right" ng-click="appPasswordAdd.show()"><i class="fa fa-plus"></i> New Password</button></h3>
<h3>{{ 'profile.appPasswords.title' | tr }}<button class="btn btn-primary btn-sm pull-right" ng-click="appPasswordAdd.show()"><i class="fa fa-plus"></i> {{ 'profile.appPasswords.newPassword' | tr }}</button></h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<p>These passwords can be used as a security measure in desktop, email &amp; mobile clients.</p>
<p>{{ 'profile.appPasswords.description' | tr }}</p>
<table class="table table-hover">
<thead>
<tr>
<th style="width: 45%">Name</th>
<th style="width: 45%">App</th>
<th style="width: 10%" class="text-right">Actions</th>
<th style="width: 45%">{{ 'profile.appPasswords.name' | tr }}</th>
<th style="width: 45%">{{ 'profile.appPasswords.app' | tr }}</th>
<th style="width: 10%" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-show="appPassword.passwords.length === 0">
<td colspan="3" class="text-center">{{ 'profile.appPasswords.noPasswordsPlaceholder' | tr }}</td>
</tr>
<tr ng-repeat="password in appPassword.passwords">
<td class="text-left elide-table-cell">
<span uib-tooltip="{{ password.creationTime | prettyLongDate }}" class="arrow">{{ password.name }}</span>
@@ -397,7 +427,7 @@
<span class="arrow">{{ password.label }}</span>
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-danger pull-right" ng-click="appPassword.del(password.id)" title="Delete Password"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-xs btn-danger pull-right" ng-click="appPassword.del(password.id)" uib-tooltip="{{ 'profile.appPasswords.deletePasswordTooltip' | tr }}"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
@@ -410,34 +440,36 @@
<br ng-show="user.isAtLeastAdmin"/>
<div class="text-left" ng-show="user.isAtLeastAdmin">
<h3>API Tokens <button class="btn btn-primary btn-sm pull-right" ng-click="tokens.add.show()"><i class="fa fa-plus"></i> New API Token</button></h3>
<h3>{{ 'profile.apiTokens.title' | tr }} <button class="btn btn-primary btn-sm pull-right" ng-click="tokens.add.show()"><i class="fa fa-plus"></i> {{ 'profile.apiTokens.newApiToken' | tr }}</button></h3>
</div>
<div class="card" ng-show="user.isAtLeastAdmin">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<p ng-bind-html="'profile.apiTokens.description' | tr:{ apiDocsLink: 'https://docs.cloudron.io/api.html' }"></p>
<table class="table table-hover" style="margin: 0;">
<thead>
<tr>
<th style="width: 45%">Name</th>
<th style="width: 45%">Expires At</th>
<th style="width: 10%" class="text-right">Actions</th>
<th style="width: 45%">{{ 'profile.apiTokens.name' | tr }}</th>
<th style="width: 45%">{{ 'profile.apiTokens.lastUsed' | tr }}</th>
<th style="width: 10%" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-show="tokens.apiTokens.length === 0">
<td colspan="3" class="text-center">No API Tokens created</td>
<td colspan="3" class="text-center">{{ 'profile.apiTokens.noTokensPlaceholder' | tr }}</td>
</tr>
<tr ng-repeat="token in tokens.apiTokens">
<td class="elide-table-cell" style="text-overflow: ellipsis; white-space: nowrap;">
{{ token.name || 'unnamed' }}
</td>
<td class="elide-table-cell" style="text-overflow: ellipsis; white-space: nowrap;">
{{ token.expires | prettyShortDate }}
<span ng-show="token.lastUsedTime">{{ token.lastUsedTime | prettyLongDate }}</span>
<span ng-show="!token.lastUsedTime">{{ 'profile.apiTokens.neverUsed' | tr }}</span>
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-danger" ng-click="tokens.revokeToken(token)" uib-tooltip="Revoke Token"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="tokens.revokeToken(token)" uib-tooltip="{{ 'profile.apiTokens.revokeTokenTooltip' | tr }}"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
@@ -450,15 +482,15 @@
<br/>
<div class="text-left">
<h3>Login Tokens</h3>
<h3>{{ 'profile.loginTokens.title' | tr }}</h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<p>You have {{ tokens.webadminTokens.length }} active web token(s) and {{ tokens.cliTokens.length }} CLI token(s).</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> Logout From All</button>
<p>{{ 'profile.loginTokens.description' | tr:{ webadminTokenCount: tokens.webadminTokens.length, cliTokenCount: 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>
<br/>
</div>
</div>
+124 -41
View File
@@ -1,14 +1,30 @@
'use strict';
/* global asyncForEach:false */
/* global angular:false */
/* global $:false */
/* global async */
/* global angular */
/* global $ */
angular.module('Application').controller('ProfileController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
angular.module('Application').controller('ProfileController', ['$scope', '$translate', '$location', 'Client', '$timeout', function ($scope, $translate, $location, Client, $timeout) {
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.apps = Client.getInstalledApps();
$scope.language = '';
$scope.languages = [];
$scope.$watch('language', function (newVal, oldVal) {
if (newVal === oldVal) return;
$translate.use(newVal.id);
});
$scope.sendPasswordReset = function () {
Client.sendSelfPasswordReset($scope.user.email, function (error) {
if (error) return console.error('Failed to reset password:', error);
Client.notify($translate.instant('profile.passwordResetNotification.title'), $translate.instant('profile.passwordResetNotification.body', { email: $scope.user.fallbackEmail || $scope.user.email }), false, 'success');
});
};
$scope.twoFactorAuthentication = {
busy: false,
error: null,
@@ -16,6 +32,8 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
totpToken: '',
secret: '',
qrcode: '',
mandatory2FA: false,
mandatory2FAHelp: false, // show the initial help text when mandatory 2fa forces modal popup
reset: function () {
$scope.twoFactorAuthentication.busy = false;
@@ -24,6 +42,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
$scope.twoFactorAuthentication.totpToken = '';
$scope.twoFactorAuthentication.secret = '';
$scope.twoFactorAuthentication.qrcode = '';
$scope.twoFactorAuthentication.mandatory2FAHelp = false;
$scope.twoFactorAuthenticationEnableForm.$setUntouched();
$scope.twoFactorAuthenticationEnableForm.$setPristine();
@@ -31,6 +50,25 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
$scope.twoFactorAuthenticationDisableForm.$setPristine();
},
getSecret: function () {
$scope.twoFactorAuthentication.mandatory2FAHelp = false;
Client.setTwoFactorAuthenticationSecret(function (error, result) {
if (error) return console.error(error);
$scope.twoFactorAuthentication.secret = result.secret;
$scope.twoFactorAuthentication.qrcode = result.qrcode;
});
},
showMandatory2FA: function () {
$scope.twoFactorAuthentication.reset();
$scope.twoFactorAuthentication.mandatory2FA = true;
$scope.twoFactorAuthentication.mandatory2FAHelp = true;
$('#twoFactorAuthenticationEnableModal').modal({ backdrop: 'static', keyboard: false }); // undimissable dialog
},
show: function () {
$scope.twoFactorAuthentication.reset();
@@ -39,12 +77,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
} else {
$('#twoFactorAuthenticationEnableModal').modal('show');
Client.setTwoFactorAuthenticationSecret(function (error, result) {
if (error) return console.error(error);
$scope.twoFactorAuthentication.secret = result.secret;
$scope.twoFactorAuthentication.qrcode = result.qrcode;
});
$scope.twoFactorAuthentication.getSecret();
}
},
@@ -97,8 +130,8 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
busy: false,
error: {},
avatar: null,
useGravatar: '',
useGravatarOrig: '',
type: '',
typeOrig: '',
pictureChanged: false,
getBlobFromImg: function (img, callback) {
@@ -151,13 +184,13 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
});
}
if ($scope.avatarChange.useGravatar) {
Client.clearAvatar(done);
} else {
if ($scope.avatarChange.type === 'custom') {
var img = document.getElementById('previewAvatar');
$scope.avatarChange.getBlobFromImg(img, function (blob) {
Client.changeAvatar(blob, done);
});
} else {
Client.changeAvatar($scope.avatarChange.type, done);
}
},
@@ -169,13 +202,19 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
avatarChangeReset: function () {
$scope.avatarChange.error.avatar = null;
$scope.avatarChange.useGravatar = $scope.user.avatarUrl.indexOf('https://www.gravatar.com') === 0 ? 'true_string' : '';
$scope.avatarChange.useGravatarOrig = $scope.avatarChange.useGravatar;
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 = '';
}
$scope.avatarChange.typeOrig = $scope.avatarChange.type;
document.getElementById('previewAvatar').src = $scope.avatarChange.type === 'custom' ? $scope.user.avatarUrl : '';
$scope.avatarChange.pictureChanged = false;
document.getElementById('previewAvatar').src = $scope.avatarChange.useGravatar ? '' : $scope.user.avatarUrl;
$scope.avatarChange.avatar = $scope.avatarChange.useGravatar ? {} : {
url: $scope.user.avatarUrl
};
$scope.avatarChange.avatar = null;
$scope.avatarChange.busy = false;
},
@@ -288,7 +327,6 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
return;
}
// update user info in the background
Client.refreshUserInfo();
$scope.emailchange.reset();
@@ -299,13 +337,19 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
$scope.fallbackEmailChange = {
busy: false,
error: {},
error: {
email: false,
password: false
},
email: '',
password: '',
reset: function () {
$scope.fallbackEmailChange.busy = false;
$scope.fallbackEmailChange.error.email = null;
$scope.fallbackEmailChange.error.password = null;
$scope.fallbackEmailChange.email = '';
$scope.fallbackEmailChange.password = '';
$scope.fallbackEmailChangeForm.$setUntouched();
$scope.fallbackEmailChangeForm.$setPristine();
@@ -318,16 +362,28 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
submit: function () {
$scope.fallbackEmailChange.error.email = null;
$scope.fallbackEmailChange.error.password = null;
$scope.fallbackEmailChange.busy = true;
var data = {
fallbackEmail: $scope.fallbackEmailChange.email
fallbackEmail: $scope.fallbackEmailChange.email,
password: $scope.fallbackEmailChange.password
};
Client.updateProfile(data, function (error) {
$scope.fallbackEmailChange.busy = false;
if (error) return console.error('Unable to change fallback email.', error);
if (error) {
if (error.statusCode === 412) {
$scope.fallbackEmailChange.error.password = true;
$scope.fallbackEmailChange.password = '';
$scope.fallbackEmailChangeForm.password.$setPristine();
$('#inputFallbackEmailChangePassword').focus();
} else {
console.error('Unable to change fallback email.', error);
}
return;
}
// update user info in the background
Client.refreshUserInfo();
@@ -363,11 +419,12 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
submit: function () {
$scope.appPasswordAdd.busy = true;
$scope.appPasswordAdd.password = {};
Client.addAppPassword($scope.appPasswordAdd.identifier, $scope.appPasswordAdd.name, function (error, result) {
$scope.appPasswordAdd.busy = false;
if (error) {
if (error.statusCode === 400) {
if (error.statusCode === 400 || error.statusCode === 409) {
$scope.appPasswordAdd.error.name = error.message;
$scope.appPasswordAddForm.name.$setPristine();
$('#inputAppPasswordName').focus();
@@ -377,7 +434,6 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
return;
}
$scope.appPasswordAdd.busy = false;
$scope.appPasswordAdd.password = result;
$scope.appPassword.refresh();
@@ -399,15 +455,20 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
$scope.appPassword.identifiers = [];
var appsById = {};
$scope.apps.forEach(function (app) {
// ignore apps without ldap or with email
if (!app.manifest.addons || !app.manifest.addons.ldap || app.manifest.addons.email || !app.sso) return;
if (!app.manifest.addons) return;
if (app.manifest.addons.email) return;
var ftp = app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
var sso = app.sso && (app.manifest.addons.ldap || app.manifest.addons.proxyAuth);
if (!ftp && !sso) return;
appsById[app.id] = app;
if (app.label) {
$scope.appPassword.identifiers.push({ id: app.id, label: app.label + ' (' + app.fqdn + ')' });
} else {
$scope.appPassword.identifiers.push({ id: app.id, label: app.fqdn });
}
var labelSuffix = '';
if (ftp && sso) labelSuffix = ' - SFTP & App Login';
else if (ftp) labelSuffix = ' - SFTP Only';
var label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
$scope.appPassword.identifiers.push({ id: app.id, label: label });
});
$scope.appPassword.identifiers.push({ id: 'mail', label: 'Mail client' });
@@ -417,11 +478,9 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
var app = appsById[password.identifier];
if (!app) return password.label = password.identifier + ' (App not found)';
if (app.label) {
password.label = app.label + ' (' + app.fqdn + ')';
} else {
password.label = app.fqdn;
}
var ftp = app.manifest.addons && app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
var labelSuffix = ftp ? ' - SFTP' : '';
password.label = app.label ? app.label + ' (' + app.fqdn + ')' + labelSuffix : app.fqdn + labelSuffix;
});
});
},
@@ -512,7 +571,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
revokeAllWebAndCliTokens: function () {
$scope.tokens.busy = true;
asyncForEach($scope.tokens.webadminTokens.concat($scope.tokens.cliTokens), function (token, callback) {
async.eachSeries($scope.tokens.webadminTokens.concat($scope.tokens.cliTokens), function (token, callback) {
// do not revoke token for this session, will do at the end with logout
if (token.accessToken === Client.getToken()) return callback();
@@ -575,6 +634,19 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
Client.onReady(function () {
$scope.appPassword.refresh();
$scope.tokens.refresh();
Client.refreshUserInfo(); // 2fa status might have changed by admin
$translate.onReady(function () {
var usedLang = $translate.use() || $translate.fallbackLanguage();
$scope.languages = Client.getAvailableLanguages().map(function (l) {
return {
display: $translate.instant('lang.'+l, {}, undefined, 'en'),
id: l
};
}).sort(function (a, b) { return a.display.localeCompare(b.display); });
$scope.language = $scope.languages.find(function (l) { return l.id === usedLang; });
});
});
$('#avatarFileInput').get(0).onchange = function (event) {
@@ -602,4 +674,15 @@ angular.module('Application').controller('ProfileController', ['$scope', '$locat
});
$('.modal-backdrop').remove();
if ($location.search().setup2fa) {
// the form elements of the FormController won't appear in scope yet
$timeout(function () { $scope.twoFactorAuthentication.showMandatory2FA(); }, 1000);
} else {
// don't let the user bypass 2FA by removing the 'setup2FA' in the url
if (Client.getConfig().mandatory2FA && !Client.getUserInfo().twoFactorAuthenticationEnabled) {
$location.path('/profile').search({ setup2fa: true });
return;
}
}
}]);
+155
View File
@@ -0,0 +1,155 @@
<!-- Modal service configure -->
<div class="modal fade" id="serviceConfigureModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'services.configure.title' | tr:{ name: serviceConfigure.service.displayName } }}</h4>
</div>
<div class="modal-body">
<form name="serviceConfigureForm" role="form" novalidate ng-submit="serviceConfigure.submit()" autocomplete="off">
<fieldset>
<p class="has-error text-center" ng-show="serviceConfigure.error">{{ serviceConfigure.error }}</p>
<div class="form-group">
<label class="control-label" style="display: block;" for="memoryLimit">
{{ 'services.memoryLimit' | tr }}: <b>{{ serviceConfigure.memoryLimit / 1024 / 1024 + 'MB' }}</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>
</div>
</div>
<div class="form-group">
<br>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="serviceConfigure.recoveryMode"><b>{{ 'services.configure.enableRecoveryMode' | tr }}</b></input>
</label>
</div>
<p ng-bind-html="'services.configure.recoveryModeDescription' | tr:{ docsLink: 'https://docs.cloudron.io/troubleshooting/#unresponsive-service' }"></p>
</div>
<input class="ng-hide" type="submit" ng-disabled="serviceConfigureForm.$invalid || serviceConfigure.busy"/>
</fieldset>
</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="serviceConfigure.submit()" ng-disabled="serviceConfigureForm.$invalid || serviceConfigure.busy"><i class="fa fa-circle-notch fa-spin" ng-show="serviceConfigure.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
</div>
</div>
<div class="content">
<div class="text-left">
<h1>{{ 'services.title' | tr }}
<button class="btn btn-default pull-right" ng-click="refreshAll()">{{ 'services.refresh' | tr }}</button>
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>{{ 'services.description' | tr }}</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="row ng-hide" ng-show="!servicesReady">
<div class="col-md-12 text-center">
<h2><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
</div>
<div class="row animateMeOpacity ng-hide" ng-show="servicesReady">
<div class="col-md-12">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 5%;"></th>
<th style="width: 20%">{{ 'services.service' | tr }}</th>
<th style="width: 50%">{{ 'services.memoryUsage' | tr }}</th>
<th style="width: 20%" class="text-center no-wrap">{{ 'services.memoryLimit' | tr }}</th>
<th style="width: 5%" class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr>
<td><i class="fa fa-circle status-active" uib-tooltip="active"></i></td>
<td class="elide-table-cell">cloudron</td>
<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>
</td>
</tr>
<tr ng-repeat="service in services | filter:{ isRedis: false } | orderBy:'name'">
<td>
<span ng-switch on="service.status" ng-show="service.status">
<span ng-switch-when="active">
<i class="fa fa-circle status-active" uib-tooltip="active"></i>
</span>
<span ng-switch-when="starting">
<i class="fa fa-circle status-starting" uib-tooltip="starting" ng-show="!service.config.recoveryMode"></i>
<i class="fa fa-circle status-inactive" uib-tooltip="recovery mode" ng-show="service.config.recoveryMode"></i>
</span>
<span ng-switch-default>
<i class="fa fa-circle status-error" uib-tooltip="{{ service.status }}"></i>
</span>
</span>
<i class="fa fa-circle-notch fa-spin" ng-hide="service.status"></i>
</td>
<td class="elide-table-cell">
{{ service.displayName }}
</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>
</td>
<td class="elide-table-cell text-center">
<span ng-show="service.config.memoryLimit">{{ service.config.memoryLimit | prettyByteSize }}</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="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>
</td>
</tr>
<tr ng-show="hasRedisServices" ng-click="redisServicesExpanded = !redisServicesExpanded" class="hand">
<td>
<i class="fas fa-angle-right" ng-class="{'fa-rotate-90': redisServicesExpanded }"></i>
</td>
<td colspan="4">redis</td>
</tr>
<tr ng-show="redisServicesExpanded" ng-repeat="service in services | filter:{ isRedis: true } | orderBy:'name'">
<td>
<i class="fa fa-circle" uib-tooltip="{{ service.status }}" ng-class="{ 'status-active': service.status === 'active', 'status-starting': service.status === 'starting', 'status-error': (service.status !== 'starting' && service.status !== 'active') }" ng-show="service.status"></i>
<i class="fa fa-circle-notch fa-spin" ng-hide="service.status"></i>
</td>
<td class="elide-table-cell">
{{ service.displayName }}
</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>
</td>
<td class="elide-table-cell text-center">
<span ng-show="service.config.memoryLimit">{{ service.config.memoryLimit | prettyByteSize }}</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-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>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
+183
View File
@@ -0,0 +1,183 @@
'use strict';
/* global angular */
/* global $ */
angular.module('Application').controller('ServicesController', ['$scope', '$location', '$timeout', 'Client', function ($scope, $location, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.config = Client.getConfig();
$scope.servicesReady = false;
$scope.services = [];
$scope.hasRedisServices = false;
$scope.redisServicesExpanded = false;
$scope.memory = null;
function refresh(serviceName, callback) {
callback = callback || function () {};
Client.getService(serviceName, function (error, result) {
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;
service.status = result.status;
service.config = result.config;
service.memoryUsed = result.memoryUsed;
service.memoryPercent = result.memoryPercent;
callback(null, service);
});
}
function waitForActive(serviceName) {
refresh(serviceName, function (error, result) {
if (result.status === 'active') return;
setTimeout(function () { waitForActive(serviceName); }, 3000);
});
}
$scope.restartService = function (serviceName) {
$scope.services.find(function (s) { return s.name === serviceName; }).status = 'starting';
Client.restartService(serviceName, function (error) {
if (error && error.statusCode === 404) {
Client.rebuildService(serviceName, function (error) {
if (error) return Client.error(error);
// show "busy" indicator for 3 seconds to show some ui activity
setTimeout(function () { waitForActive(serviceName); }, 3000);
});
return;
}
if (error) return Client.error(error);
// show "busy" indicator for 3 seconds to show some ui activity
setTimeout(function () { waitForActive(serviceName); }, 3000);
});
};
$scope.serviceConfigure = {
error: null,
busy: false,
service: null,
// form model
memoryLimit: 0,
memoryTicks: [],
recoveryMode: false,
show: function (service) {
$scope.serviceConfigure.reset();
$scope.serviceConfigure.service = service;
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
$scope.serviceConfigure.recoveryMode = !!service.config.recoveryMode;
$scope.serviceConfigure.memoryTicks = [];
// 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);
}
$('#serviceConfigureModal').modal('show');
},
submit: function () {
$scope.serviceConfigure.busy = true;
$scope.serviceConfigure.error = null;
var data = {
memoryLimit: $scope.serviceConfigure.memoryLimit,
recoveryMode: $scope.serviceConfigure.recoveryMode
};
Client.configureService($scope.serviceConfigure.service.name, data, function (error) {
$scope.serviceConfigure.busy = false;
if (error) {
$scope.serviceConfigure.error = error.message;
return;
}
if ($scope.serviceConfigure.recoveryMode === true) {
refresh($scope.serviceConfigure.service.name);
} else {
waitForActive($scope.serviceConfigure.service.name);
}
$('#serviceConfigureModal').modal('hide');
$scope.serviceConfigure.reset();
});
},
resetToDefaults: function () {
$scope.serviceConfigure.memoryLimit = 536870912; // 512MB default
},
reset: function () {
$scope.serviceConfigure.busy = false;
$scope.serviceConfigure.error = null;
$scope.serviceConfigure.service = null;
$scope.serviceConfigure.memoryLimit = 0;
$scope.serviceConfigure.memoryTicks = [];
$scope.serviceConfigureForm.$setPristine();
$scope.serviceConfigureForm.$setUntouched();
}
};
$scope.refreshAll = function (callback) {
Client.getServices(function (error, result) {
if (error) return Client.error(error);
$scope.services = result.map(function (name) {
var displayName = name;
var isRedis = false;
if (name.indexOf('redis') === 0) {
isRedis = true;
var app = Client.getCachedAppSync(name.slice('redis:'.length));
if (app) {
displayName = 'Redis (' + (app.label || app.fqdn) + ')';
} else {
displayName = 'Redis (unknown app)';
}
}
return {
name: name,
displayName: displayName,
isRedis: isRedis
};
});
$scope.hasRedisServices = !!$scope.services.find(function (service) { return service.isRedis; });
// just kick off the status fetching
$scope.services.forEach(function (s) { refresh(s.name); });
if (callback) return callback();
});
};
Client.onReady(function () {
Client.memory(function (error, memory) {
if (error) console.error(error);
$scope.memory = memory;
$scope.refreshAll(function () {
$scope.servicesReady = true;
});
});
});
}]);

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