Compare commits

..

356 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
70 changed files with 17891 additions and 2940 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
+1 -1
View File
@@ -1,5 +1,5 @@
The Cloudron Subscription license
Copyright (c) 2021 Cloudron UG
Copyright (c) 2022 Cloudron UG
With regard to the Cloudron Software:
+20 -19
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');
@@ -103,6 +103,7 @@ 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' }))
@@ -113,7 +114,7 @@ gulp.task('js-index', function () {
});
gulp.task('js-logs', function () {
return gulp.src(['src/js/logs.js', 'src/js/client.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: ';' }))
@@ -122,7 +123,7 @@ gulp.task('js-logs', function () {
});
gulp.task('js-filemanager', function () {
return gulp.src(['src/js/filemanager.js', 'src/js/client.js'])
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: ';' }))
@@ -131,7 +132,7 @@ gulp.task('js-filemanager', function () {
});
gulp.task('js-terminal', function () {
return gulp.src(['src/js/terminal.js', 'src/js/client.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: ';' }))
@@ -140,7 +141,7 @@ gulp.task('js-terminal', function () {
});
gulp.task('js-login', function () {
return gulp.src(['src/js/login.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: ';' }))
@@ -149,7 +150,7 @@ gulp.task('js-login', function () {
});
gulp.task('js-setupaccount', function () {
return gulp.src(['src/js/setupaccount.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: ';' }))
@@ -158,7 +159,7 @@ gulp.task('js-setupaccount', function () {
});
gulp.task('js-setup', function () {
return gulp.src(['src/js/setup.js', 'src/js/client.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: ';' }))
@@ -167,7 +168,7 @@ gulp.task('js-setup', function () {
});
gulp.task('js-setupdns', function () {
return gulp.src(['src/js/setupdns.js', 'src/js/client.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: ';' }))
@@ -176,7 +177,7 @@ gulp.task('js-setupdns', function () {
});
gulp.task('js-restore', function () {
return gulp.src(['src/js/restore.js', 'src/js/client.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: ';' }))
@@ -251,16 +252,16 @@ gulp.task('watch', function (done) {
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/filemanager.js', 'src/js/client.js'], gulp.series(['js-filemanager']));
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();
});
+10160 -639
View File
File diff suppressed because it is too large Load Diff
+8 -7
View File
@@ -4,7 +4,7 @@
"description": "[Cloudron](https://cloudron.io) is the best way to run apps on your server.",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"update-translations": "curl https://translate.cloudron.io/download/cloudron/dashboard/?format=zip -o lang.zip && unzip -jo lang.zip -d ./src/translation/ && rm lang.zip"
"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",
@@ -13,22 +13,23 @@
"author": "",
"license": "SEE LICENSE IN LICENSE",
"dependencies": {
"@fortawesome/fontawesome-free": "^5.15.2",
"@fortawesome/fontawesome-free": "^5.15.4",
"bootstrap-sass": "^3.4.1",
"gulp": "^4.0.2",
"gulp-autoprefixer": "^7.0.1",
"gulp-autoprefixer": "^8.0.0",
"gulp-concat": "^2.6.1",
"gulp-cssnano": "^2.1.3",
"gulp-ejs": "^5.1.0",
"gulp-sass": "^4.1.0",
"gulp-sass": "^5.1.0",
"gulp-serve": "^1.4.0",
"gulp-sourcemaps": "^3.0.0",
"monaco-editor": "^0.23.0",
"monaco-editor": "^0.32.1",
"node-sass": "^7.0.1",
"rimraf": "^3.0.2",
"xterm": "^4.11.0",
"xterm": "^4.17.0",
"xterm-addon-attach": "^0.6.0",
"xterm-addon-fit": "^0.5.0",
"yargs": "^16.2.0"
"yargs": "^17.3.1"
},
"eslintConfig": {
"env": {
+15 -13
View File
@@ -73,7 +73,7 @@
<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="restartAppBusy" ng-cloak><i class="fa fa-circle-notch fa-spin"></i> {{ 'filemanager.status.restartingApp' | tr}}</div>
<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">
@@ -172,7 +172,7 @@
</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-danger" 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>
<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>
@@ -202,7 +202,7 @@
</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-danger" 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>
<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>
@@ -293,7 +293,8 @@
<input type="file" id="uploadFileInput" style="display: none" multiple/>
<input type="file" id="uploadFolderInput" style="display: none" multiple webkitdirectory directory/>
<h4 class="text-center">{{ title }}</h4>
<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;">
@@ -318,15 +319,16 @@
<li><a class="hand" ng-click="onUploadFolder()">{{ 'filemanager.toolbar.uploadFolder' | tr }}</a></li>
</ul>
</div>
<div class="btn-group" ng-show="type === 'app'">
<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-click="onRestartApp()"><i class="fas fa-sync-alt fa-fw"></i> {{ 'filemanager.toolbar.restartApp' | tr }}</a></li>
<li><a class="hand" ng-href="{{ '/logs.html?appId=' + id }}" target="_blank"><i class="fas fa-align-left fa-fw"></i> {{ 'filemanager.toolbar.openLogs' | tr }}</a></li>
<li><a class="hand" ng-href="{{ '/terminal.html?id=' + id }}" target="_blank"><i class="fa fa-terminal fa-fw"></i> {{ 'filemanager.toolbar.openTerminal' | tr }}</a></li>
<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?volumeId=' + volume.id }}" target="_blank"><i class="fas fa-folder fa-fw"></i> {{ volume.name }}</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>
@@ -338,9 +340,9 @@
<tr>
<th style="width: 40px;">&nbsp;</th>
<th style="">{{ 'filemanager.list.name' | tr }}</th>
<th style="width:100px">{{ 'filemanager.list.mtime' | tr }}</th>
<th style="width: 80px">{{ 'filemanager.list.size' | 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>
@@ -361,9 +363,9 @@
<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)" uib-tooltip="{{ entry.mtime | prettyLongDate }}" tooltip-append-to-body="true">{{ entry.mtime | prettyDate }}</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)">{{ 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>
+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

+12 -6
View File
@@ -147,7 +147,13 @@
<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' ? 'Set up' : 'Reactivate' }} Subscription</span>
<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>
@@ -157,9 +163,9 @@
<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> {{ 'users.title' | tr }}</a>
<a ng-class="{ active: isActive('/users')}" href="#/users"><i class="fa fa-users fa-fw"></i> {{ 'main.navbar.users' | tr }}</a>
</li>
<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>
@@ -170,11 +176,11 @@
<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.isAtLeastAdmin" class="divider"></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.role === 'owner'"><a href="#/branding"><i class="fa fa-passport fa-fw"></i> {{ 'branding.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.isAtLeastAdmin"><a href="#/email"><i class="fa fa-envelope fa-fw"></i> {{ 'emails.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>
+758 -100
View File
File diff suppressed because it is too large Load Diff
+70 -16
View File
@@ -1,4 +1,4 @@
"use strict";
'use strict';
require.config({ paths: { 'vs': '3rdparty/vs' }});
@@ -65,8 +65,8 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
$scope.client = Client;
$scope.cwd = null;
$scope.cwdParts = [];
$scope.id = search.appId || search.volumeId;
$scope.type = search.appId ? 'app' : 'volume';
$scope.id = search.id;
$scope.type = search.type;
$scope.rootDirLabel = '';
$scope.title = '';
$scope.entries = [];
@@ -78,6 +78,7 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
$scope.sortProperty = 'fileName';
$scope.view = 'fileTree';
$scope.volumes = [];
$scope.applicationLink = '';
$scope.owners = [
{ name: 'cloudron', value: 1000 },
@@ -106,7 +107,10 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
function getLanguage(filename) {
var ext = '.' + filename.split('.').pop();
var language = LANGUAGES.find(function (l) { return !!l.extensions.find(function (e) { return e === ext; }); }) || '';
var language = LANGUAGES.find(function (l) {
if (!l.extensions) return false;
return !!l.extensions.find(function (e) { return e === ext; });
}) || '';
return language ? language.id : '';
}
@@ -144,6 +148,7 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
if (entry.isDirectory) {
$scope.cwd = path;
$scope.selected = [];
// refresh will set busy to false once done
$scope.refresh();
@@ -226,7 +231,7 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
if (error) {
console.error(error);
$scope.extractStatus.error = $translate.instant('filemanager.extract.error', error);
$scope.extractStatus.error = $translate.instant('filemanager.extract.error', error.message);
return;
}
@@ -389,7 +394,6 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
});
$scope.entries = result.entries;
$scope.cwdParts = $scope.cwd.split('/').filter(function (p) { return !!p; }).map(function (p, i) { return { name: decodeURIComponent(p), path: $scope.cwd.split('/').slice(0, i+1).join('/') }; });
$scope.busy = false;
});
@@ -543,7 +547,7 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
};
$scope.goDirectoryUp = function () {
openPath($scope.cwd + '/..');
location.hash = sanitize($scope.cwd + '/..');
};
$scope.changeDirectory = function (path) {
@@ -650,9 +654,9 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
$('#uploadFolderInput').on('change', function (e ) { uploadFiles(e.target.files || [], $scope.cwd, false); });
$scope.onUploadFolder = function () { $('#uploadFolderInput').click(); };
$scope.restartAppBusy = false;
$scope.restartBusy = false;
$scope.onRestartApp = function () {
$scope.restartAppBusy = true;
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getApp($scope.id, function (error, result) {
@@ -669,7 +673,30 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartAppBusy = false;
$scope.restartBusy = false;
});
});
};
$scope.onRestartMail = function () {
$scope.restartBusy = true;
function waitUntilRestarted(callback) {
Client.getService('mail', function (error, result) {
if (error) return callback(error);
if (result.status === 'active') return callback();
setTimeout(waitUntilRestarted.bind(null, callback), 2000);
});
}
Client.restartService('mail', function (error) {
if (error) console.error('Failed to restart mail.', error);
waitUntilRestarted(function (error) {
if (error) console.error('Failed wait for restart.', error);
$scope.restartBusy = false;
});
});
};
@@ -996,7 +1023,14 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
Client.refreshUserInfo(function (error) {
if (error) return Client.initError(error, init);
var getter = $scope.type === 'app' ? Client.getApp.bind(Client, $scope.id) : Client.getVolume.bind(Client, $scope.id);
var getter;
if ($scope.type === 'app') {
getter = Client.getApp.bind(Client, $scope.id);
} else if ($scope.type === 'volume') {
getter = Client.getVolume.bind(Client, $scope.id);
} else if ($scope.type === 'mail') {
getter = function (next) { next(null, null); };
}
getter(function (error, result) {
if (error) {
@@ -1005,16 +1039,33 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
}
// fine to do async
fetchVolumesInfo(result.mounts || []);
if ($scope.type === 'app') fetchVolumesInfo(result.mounts || []);
switch ($scope.type) {
case 'app':
$scope.title = result.label || result.fqdn;
$scope.rootDirLabel = '/app/data/';
$scope.applicationLink = 'https://' + result.fqdn;
break;
case 'volume':
$scope.title = result.name;
$scope.rootDirLabel = result.hostPath;
break;
case 'mail':
$scope.title = 'mail';
$scope.rootDirLabel = 'mail';
break;
}
$scope.title = $scope.type === 'app' ? (result.label || result.fqdn) : result.name;
$scope.rootDirLabel = $scope.type === 'app' ? '/app/data/' : result.hostPath;
window.document.title = $scope.title + ' - ' + $translate.instant('filemanager.title');
// now mark the Client to be ready
Client.setReady();
openPath(window.location.hash.slice(1));
var hashPath = window.location.hash.slice(1);
$scope.cwdParts = hashPath.split('/').filter(function (p) { return !!p; }).map(function (p, i) { return { name: decodeURIComponent(p), path: hashPath.split('/').slice(0, i+1).join('/') }; });
openPath(hashPath);
$scope.initialized = true;
});
@@ -1042,7 +1093,10 @@ app.controller('FileManagerController', ['$scope', '$translate', '$timeout', 'Cl
$scope.mediaViewer.close();
$scope.textEditor.close();
openPath(window.location.hash.slice(1));
var hashPath = window.location.hash.slice(1);
$scope.cwdParts = hashPath.split('/').filter(function (p) { return !!p; }).map(function (p, i) { return { name: decodeURIComponent(p), path: hashPath.split('/').slice(0, i+1).join('/') }; });
openPath(hashPath);
});
});
+7 -3
View File
@@ -81,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', {
@@ -234,7 +237,7 @@ app.filter('selectedDomainFilter', function () {
if (selectedDomain.domain === app.domain) return true;
if (app.aliasDomains.find(function (ad) { return ad.domain === selectedDomain.domain; })) return true;
if (app.alternateDomains.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;
});
@@ -387,7 +390,8 @@ app.filter('prettyHref', function () {
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);
};
+7 -10
View File
@@ -28,7 +28,6 @@ app.config(['$translateProvider', function ($translateProvider) {
prefix: 'translation/',
suffix: '.json'
});
$translateProvider.useLocalStorage();
$translateProvider.preferredLanguage('en');
$translateProvider.fallbackLanguage('en');
}]);
@@ -37,9 +36,6 @@ app.config(['$translateProvider', function ($translateProvider) {
// 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 || {
@@ -130,32 +126,33 @@ app.controller('LoginController', ['$scope', '$translate', '$http', function ($s
var data = {
resetToken: search.resetToken,
password: $scope.newPassword
password: $scope.newPassword,
totpToken: $scope.totpToken
};
function 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);
+5 -4
View File
@@ -52,8 +52,6 @@ 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
@@ -64,6 +62,8 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
};
function refreshNotifications() {
if (!Client.getUserInfo().isAtLeastAdmin) return;
Client.getNotifications({ acknowledged: false }, 1, 100, function (error, results) { // counter maxes out at 100
if (error) console.error(error);
else $scope.notificationCount = results.length;
@@ -71,8 +71,9 @@ angular.module('Application').controller('MainController', ['$scope', '$route',
}
// update state of acknowledged notification
$scope.notificationAcknowledged = function (ack) {
$scope.notificationCount += (ack ? -1 : +1);
$scope.notificationAcknowledged = function () {
if ($scope.notificationCount === 0) return; // already down to 0
$scope.notificationCount--;
};
function init() {
+12 -7
View File
@@ -49,6 +49,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
password: '',
diskPath: '',
user: '',
seal: false,
port: 22,
privateKey: ''
};
@@ -157,6 +158,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
{ 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
@@ -169,6 +171,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
{ 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' }
];
@@ -182,7 +185,7 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos'
|| provider === 'digitalocean-spaces' || provider === 'wasabi' || provider === 'scaleway-objectstorage'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'backblaze-b2'
|| provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage';
|| provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage';
};
$scope.mountlike = function (provider) {
@@ -196,7 +199,6 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
var backupConfig = {
provider: $scope.provider,
format: $scope.format,
setupToken: $scope.setupToken
};
if ($scope.password) backupConfig.password = $scope.password;
@@ -237,6 +239,10 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
} else if (backupConfig.provider === 'vultr-objectstorage') {
backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === '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';
}
@@ -265,26 +271,25 @@ app.controller('RestoreController', ['$scope', 'Client', function ($scope, Clien
backupConfig.mountOptions = {};
if (backupConfig.provider === 'cifs' || backupConfig.provider === 'sshfs' || backupConfig.provider === 'nfs') {
backupConfig.mountPoint = '/mnt/cloudronbackup'; // harcoded for ease of use
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.mountPoint = '/mnt/cloudronbackup'; // harcoded for ease of use
backupConfig.mountOptions.diskPath = $scope.diskPath;
backupConfig.mountOptions.diskPath = $scope.mountOptions.diskPath;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountPoint = $scope.mountPoint;
}
} else if (backupConfig.provider === 'filesystem') {
backupConfig.backupFolder = $scope.configureBackup.backupFolder;
backupConfig.backupFolder = $scope.backupFolder;
}
if ($scope.backupId.indexOf('/') === -1) {
@@ -318,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, $scope.skipDnsSetup, 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) {
+29 -24
View File
@@ -38,24 +38,24 @@ app.config(['$translateProvider', function ($translateProvider) {
// 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';
'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);
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 $translate.instant(translationId, interpolateParams, interpolation, forceLanguage);
};
if ($translate.statefulFilter()) {
translateFilter.$stateful = true;
}
return translateFilter;
return translateFilter;
}
translateFilterFactory.displayName = 'translateFilterFactory';
app.filter('tr', translateFilterFactory);
@@ -84,12 +84,12 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
$scope.error = null;
var data = {
resetToken: search.resetToken,
inviteToken: search.inviteToken,
password: $scope.password
};
if (!$scope.profileLocked) {
data.username = $scope.username;
if (!$scope.existingUsername) data.username = $scope.username;
data.displayName = $scope.displayName;
}
@@ -129,15 +129,20 @@ app.controller('SetupAccountController', ['$scope', '$translate', '$http', funct
}).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;
if (data.language) $translate.use(data.language);
if (data.language) $translate.use(data.language);
$scope.status = data;
}).error(function () {
$scope.initialized = false;
});
$scope.status = data;
}).error(function () {
$scope.initialized = false;
});
}
}]);
+1 -1
View File
@@ -226,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,
+1 -1
View File
@@ -149,7 +149,7 @@ angular.module('Application').controller('TerminalController', ['$scope', '$tran
function createTerminalSocket() {
try {
// websocket cannot use relative urls
var cmd = JSON.stringify([ '/bin/bash', '--rcfile', '/app/data/.bashrc' ]);
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.loadAddon(new AttachAddon.AttachAddon($scope.terminalSocket));
+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);
}
};
});
+9 -5
View File
@@ -52,7 +52,7 @@
<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;">
@@ -78,7 +78,7 @@
</div>
<div class="form-group">
<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>
<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="inputTotpToken">{{ 'login.2faToken' | tr }}</label>
@@ -157,14 +157,18 @@
<div class="control-label" ng-show="newPasswordForm.newPassword.$dirty && newPasswordForm.newPassword.$invalid">
<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">{{ 'passwordReset.newPassword.passwordRepeat' | tr }}</label>
<div class="control-label" ng-show="newPasswordForm.newPasswordRepeat.$dirty && (newPassword !== newPasswordRepeat)">
<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> {{ 'passwordReset.passwordChanged.submitAction' | tr }}</button>
@@ -189,7 +193,7 @@
</div>
</div>
<footer class="text-center ng-cloak">
<footer class="text-center">
<span class="text-muted" ng-bind-html="status.footer | markdown2html"></span>
</footer>
+1 -1
View File
@@ -70,7 +70,7 @@
<!-- 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?appId=' + 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-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>
+10 -3
View File
@@ -4,7 +4,7 @@
<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</title>
<title>Cloudron - Not Found</title>
<!-- Use static style as we can't include local stylesheets -->
<style>
@@ -46,14 +46,21 @@
text-decoration: underline;
}
</style>
</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>You found a <a href="https://cloudron.io">Cloudron</a> out in the wild!</p>
<p id="message"></p>
</div>
</body>
+11 -4
View File
@@ -105,6 +105,13 @@
<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>
@@ -120,7 +127,7 @@
<!-- 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">
<input type="password" class="form-control" ng-model="mountOptions.password" id="configureBackupPassword" name="password" ng-disabled="busy" password-reveal>
</div>
<!-- EXT4 -->
@@ -154,9 +161,9 @@
</div>
<!-- S3/Minio/SOS -->
<div class="form-group" ng-class="{ 'has-error': error.endpoint }" ng-show="provider === 'minio' || provider === 'backblaze-b2' || 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" ng-required="provider === 'minio' || provider === 'backblaze-b2' || 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'" >
@@ -320,7 +327,7 @@
</div>
<footer class="text-center">
<span class="text-muted">&copy;2021 <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>
+3 -3
View File
@@ -84,7 +84,7 @@
</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">
<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>
@@ -124,7 +124,7 @@
<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 on another 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>
@@ -146,7 +146,7 @@
</div>
<footer class="text-center">
<span class="text-muted">&copy;2021 <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>
+21 -7
View File
@@ -74,12 +74,12 @@
<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>
<div class="control-label" ng-show="setupAccountForm.username.$dirty && setupAccountForm.username.$invalid">
<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" 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">{{ '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>
</div>
<div class="form-group">
@@ -89,18 +89,18 @@
<div class="form-group" ng-class="{ 'has-error': (setupAccountForm.password.$dirty && setupAccountForm.password.$invalid) }">
<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">{{ 'setupAccount.errorPassword' | tr }}</small>
</div>
<input type="password" class="form-control" ng-model="password" name="password" id="inputPassword" ng-pattern="/^.{8,}$/" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (setupAccountForm.passwordRepeat.$dirty && (password !== passwordRepeat)) }">
<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)">{{ 'setupAccount.errorPasswordNoMatch' | tr }}</small>
</div>
<input type="password" class="form-control" ng-model="passwordRepeat" name="passwordRepeat" id="inputPasswordRepeat" 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> {{ 'setupAccount.setupAction' | tr }}</button>
@@ -110,6 +110,20 @@
</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">
+2 -2
View File
@@ -68,7 +68,7 @@
<br/>
<br/>
<p>
Please wait while Cloudron is setting up the dashboard at my.{{dnsCredentials.domain}}.
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>
@@ -296,7 +296,7 @@
</div>
<footer class="text-center">
<span class="text-muted">&copy;2021 <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>
+36
View File
@@ -2,8 +2,15 @@
<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 {
@@ -241,6 +248,35 @@
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>
+88 -20
View File
@@ -69,6 +69,10 @@ $state-danger-border: $brand-danger;
// Bootstrap extension
// ----------------------------
.text-monospace {
font-family: $font-family-monospace;
}
.row-same-height {
height: 100%;
display: table;
@@ -145,6 +149,10 @@ input[type="checkbox"], input[type="radio"] {
margin-left: 5px;
}
.nav-tabs > li.active > a, .nav-tabs > li.active > a:focus, .nav-tabs > li.active > a:hover {
border: 1px solid white;
}
// ----------------------------
// Main classes
// ----------------------------
@@ -211,9 +219,14 @@ html, body {
max-width: 720px;
margin: 0 auto;
@media(min-width:768px) {
width: 720px;
.title-toolbar {
float: right !important;
}
&.content-large {
width: 970px;
max-width: 970px;
@@ -280,6 +293,23 @@ textarea {
max-width: 100%;
}
// status classes for circle indicators
.status-active {
color: #27CE65;
}
.status-inactive {
color: #7c7c7c;
}
.status-starting {
color: #f0ad4e;
}
.status-error {
color: #ec534f;
}
// ----------------------------
// Apps view
// ----------------------------
@@ -293,6 +323,7 @@ textarea {
.grid-item {
margin: 10px;
width: 221px;
position: relative;
@media(max-width:767px) {
width: 100%;
@@ -312,7 +343,12 @@ textarea {
}
}
&.stopped {
filter: grayscale(1);
}
.grid-item-content {
position: relative;
display: block;
background-color: white;
box-shadow: 0px 2px 5px rgba(0, 0, 0, 0.1);
@@ -327,7 +363,14 @@ textarea {
background-color: rgba(0, 0, 0, 0.1) !important;
& > .grid-item-action {
background-color: #cecece;
opacity: 1;
border-bottom-right-radius: 0;
border-top-left-radius: 0;
&:hover {
background-color: #f8f8f8;
}
}
}
@@ -339,7 +382,6 @@ textarea {
margin-bottom: -14px;
background-color: unset;
opacity: 0;
transition: opacity 200ms;
color: $text-dark;
&:hover {
@@ -380,7 +422,7 @@ textarea {
justify-content: center;
align-items: center;
position: absolute;
right: -12px;
left: -12px;
top: -12px;
font-size: 24px;
height: 36px;
@@ -492,16 +534,16 @@ multiselect {
margin-top: 5px;
}
.alternate-domains .col-lg-11 {
.redirect-domains .col-lg-11 {
padding-right: 5px;
}
.alternate-domains .col-lg-1 {
.redirect-domains .col-lg-1 {
padding-left: 0px;
padding-right: 0px;
}
.alternate-domains .row {
.redirect-domains .row {
margin-top: 5px;
}
@@ -543,6 +585,9 @@ multiselect {
.maillog-filter {
display: inline-block;
padding-left: 0;
margin: 20px 0;
border-radius: 2px;
.form-control {
display: inline-block;
@@ -594,7 +639,7 @@ multiselect {
}
.card {
min-height: 418px;
min-height: 488px;
}
@media(min-width:768px) {
@@ -1419,16 +1464,10 @@ footer {
overflow: hidden;
}
.users-filter {
display: inline-block;
padding-left: 0;
margin: 20px 0;
border-radius: 2px;
.form-control {
display: inline-block;
width: 200px;
}
.users-toolbar {
display: flex;
margin-bottom: 5px;
gap: 5px;
}
// ----------------------------
@@ -1518,6 +1557,14 @@ footer {
&:hover {
box-shadow: 0 2px 27px rgba(0,0,0,.1);
}
&.notification-unread {
border-left: solid 3px #2196f3;
.notification-title {
font-weight: bold;
}
}
}
// ----------------------------
@@ -1851,6 +1898,18 @@ tag-input {
overflow: hidden;
margin-bottom: 10px;
margin-top: 10px;
h4 {
a {
text-decoration: none;
color: $text-dark;
&:hover {
text-decoration: none;
color: $brand_primary;
}
}
}
}
}
@@ -1863,13 +1922,13 @@ tag-input {
$backgroundLight: #2b2b2e;
$backgroundDark: #1c1c1c;
body, .appstore-toolbar, .modal-content {
body, .appstore-toolbar, .modal-content, .setup {
color: $textColor;
background-color: $backgroundLight;
}
select.form-control {
background-image: url("data:image/svg+xml;utf8,<svg fill='#959595' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
background-image: url("data:image/svg+xml;utf8,<svg fill='lightgray' height='24' viewBox='0 0 24 24' width='24' xmlns='http://www.w3.org/2000/svg'><path d='M7 10l5 5 5-5z'/><path d='M0 0h24v24H0z' fill='none'/></svg>");
}
.navbar-default {
@@ -1887,9 +1946,10 @@ tag-input {
.grid-item-action {
color: white;
background-color: rgba($backgroundDark, 0.1) !important;
&:hover {
background-color: rgba(0, 0, 0, 0.5);
background-color: $backgroundDark !important;
}
}
}
@@ -1917,6 +1977,10 @@ tag-input {
background-color: $backgroundDark;
}
.btn[disabled] {
opacity: 0.25;
}
.btn-default {
color: $textColor;
@@ -1948,6 +2012,10 @@ tag-input {
color: $textColor;
}
fieldset[disabled] .form-control, .form-control[disabled], .form-control[readonly] {
background-color: $backgroundDark;
}
.app-configure .app-header-container h1 a {
color: $textColor;
}
@@ -2008,4 +2076,4 @@ tag-input {
color: $textColor;
border: 1px solid $backgroundDark;
}
}
}
+113 -46
View File
@@ -18,7 +18,12 @@
"tagsFilterHeader": "Schlagworte: {{ tags }}",
"stateFilterHeader": "Jeder Status",
"searchPlaceholder": "Suche Apps",
"groupsFilterHeader": "Wähle Gruppe"
"groupsFilterHeader": "Wähle Gruppe",
"auth": {
"nosso": "Die App verwendet eine eigene Benutzerverwaltung",
"email": "Mit E-Mail-Adresse und Passwort anmelden",
"sso": "Mit Cloudron Zugangsdaten anmelden"
}
},
"main": {
"offline": "Cloudron ist nicht erreichbar. Verbindungsaufbau…",
@@ -71,13 +76,13 @@
"daysAgo": "vor {{ d }} Tagen",
"weeksAgo": "vor {{ w }} Wochen",
"monthsAgo": "vor {{ m }} Monaten"
}
},
"disableAction": "Deaktivieren",
"enableAction": "Aktivieren"
},
"network": {
"title": "Netzwerk",
"dyndns": {
"useLabel": "Dynamisches DNS verwenden",
"saved": "Gespeichert",
"title": "Dynamischer DNS",
"description": "Diese Option aktivieren, um alle DNS-Einträge mit einer sich ändernden IP-Adresse synchron zu halten. Dies ist nützlich, wenn Cloudron in einem Netzwerk mit einer sich häufig ändernden öffentlichen IP-Adresse wie einer Heimverbindung läuft."
},
@@ -98,7 +103,6 @@
"ip": {
"description": "Cloudron verwendet diese IP-Adresse beim Einrichten von DNS-Einträgen.",
"provider": "Anbieter",
"address": "IP-Adresse",
"interface": "Name der Netzwerkschnittstelle",
"configure": "Konfigurieren",
"interfaceDescription": "Verfügbare Netzwerkgeräte auf dem Server anzeigen mit:",
@@ -144,7 +148,8 @@
"setupAction": "Konto einrichten",
"subscription": "Abonnement-Typ",
"subscriptionReactivateAction": "Abonnement reaktivieren",
"subscriptionEndsAt": "Gekündigt - endet am"
"subscriptionEndsAt": "Gekündigt - endet am",
"emailNotVerified": "E-Mail noch nicht verifiziert"
},
"privateDockerRegistryDialog": {
"passwordToken": "Passwort/Token",
@@ -226,7 +231,7 @@
"users": {
"removeUserTooltip": "User löschen",
"editUserTooltip": "User bearbeiten",
"resetPasswordTooltip": "Passwort oder 2FA zurücksetzen",
"resetPasswordTooltip": "Passwort zurücksetzen",
"notActivatedYetTooltip": "Dieser User ist noch nicht aktiviert",
"externalLdapTooltip": "Aus externem LDAP Verzeichnis",
"inactiveTooltip": "Dieser User ist inaktiv",
@@ -236,7 +241,8 @@
"empty": "Keine User gefunden",
"groups": "Gruppen",
"user": "User",
"transferOwnershipTooltip": "Besitzer*in wechseln"
"transferOwnershipTooltip": "Besitzer*in wechseln",
"invitationTooltip": "User einladen"
},
"newUserAction": "Neuer User",
"role": {
@@ -250,13 +256,13 @@
},
"passwordResetDialog": {
"sendEmailLinkAction": "Link per E-Mail an User senden",
"description": "Link für Passwort wiederherstellen oder {{ username }} erneut einladen:",
"title": "Passwort oder 2FA zurücksetzen Link für {{ username }}",
"description": "Der folgende Link zum Passwort wiederherstellen wurde an {{ email }} gesendet:",
"title": "Passwort zurücksetzen für {{ username }}",
"reset2FAAction": "2FA zurücksetzen",
"emailSent": "Gesendet",
"no2FASetup": "User hat 2FA nicht aktiviert.",
"2FAIsSetup": "Hier kann das 2FA Setup des User's deaktiviert werden. Es kann anschließend im Profil vom User wieder eingerichtet werden.",
"newLinkAction": "Neuen Link erstellen",
"newLinkAction": "Wiederherstellungslink senden",
"resetLinkExplanation": "Hier kann ein neuer Link für die initiale User Aktivierung oder zum Passwort zurücksetzen erstellt werden. Dies macht den vorherigen Link ungültig."
},
"deleteGroupDialog": {
@@ -304,7 +310,9 @@
"groups": "Gruppen",
"role": "Rolle",
"username": "Username",
"fullName": "Vollständiger Name"
"fullName": "Vollständiger Name",
"fallbackEmailPlaceholder": "Optional. Falls nicht gesetzt wird die Primäre E-Mail benutzt",
"displayNamePlaceholder": "Optional. Kann während der Registrierung gewählt werden"
},
"addUserDialog": {
"addUserAction": "User hinzufügen",
@@ -320,6 +328,18 @@
"title": "Wirklich Besitzer*in wechseln?",
"description": "Dies macht den ausgewählten User zum Administrator und nimmt dem aktuellen User diese.",
"newOwner": "Neue Besitzer*in"
},
"invitationDialog": {
"title": "{{ username }} einladen",
"newLinkAction": "User jetzt einladen",
"description": "Der folgende Einladungslink wurde an {{ email }} gesendet:"
},
"setGhostDialog": {
"password": "Passwort",
"setPassword": "Passwort setzen"
},
"setGhost": {
"password": "Passwort"
}
},
"profile": {
@@ -327,7 +347,8 @@
"changeAvatar": {
"title": "Avatar ändern",
"useGravatar": "<a target=\"_blank\" href=\"{{ gravatarLink }}\">Gravatar</a> benutzen",
"useCustomPicture": "Eigenes Bild benutzen"
"useCustomPicture": "Eigenes Bild benutzen",
"noAvatar": "Kein Profilfoto"
},
"disable2FA": {
"disable": "Deaktivieren",
@@ -390,7 +411,10 @@
"changeFallbackEmail": {
"errorEmailInvalid": "Die E-Mail-Adresse ist nicht gültig",
"errorEmailRequired": "Eine gültige E-Mail-Adresse ist erforderlich",
"title": "Alternative E-Mail-Adresse ändern"
"title": "Alternative E-Mail-Adresse ändern",
"errorWrongPassword": "Falsches Passwort",
"password": "Passwort zur Bestätigung",
"errorPasswordRequired": "Ein Passwort ist erforderlich"
},
"changeEmail": {
"errorEmailRequired": "Eine gültige E-Mail-Adresse ist erforderlich",
@@ -413,7 +437,12 @@
"lastUsed": "Zuletzt Verwendet",
"neverUsed": "nie"
},
"passwordRecoveryEmail": "Alternative E-Mail-Adresse"
"passwordRecoveryEmail": "Alternative E-Mail-Adresse",
"passwordResetAction": "Passwort vergessen",
"passwordResetNotification": {
"body": "Email gesendet an {{ email }}",
"title": "Passwort erfolgreich zurückgesetzt"
}
},
"emails": {
"title": "E-Mail",
@@ -429,7 +458,8 @@
"changeDomainProgress": "E-Mail-Domäne ändern:",
"solrEnabled": "Aktiviert",
"solrNotRunning": "Inaktiv",
"solrRunning": "Aktiv"
"solrRunning": "Aktiv",
"aclOverview": "{{ dnsblZonesCount }} DNSBL Zonen"
},
"domains": {
"testEmailTooltip": "Test E-Mail senden",
@@ -447,28 +477,31 @@
"notEnoughMemory": "Mindestens 3GB Arbeitsspeicher dem E-Mail Dienst zuweisen um Solr aktivieren zu können."
},
"eventlog": {
"title": "Ereignisprotokoll",
"title": "Email Ereignisprotokoll",
"type": {
"bounceInfo": "Bounce-Mail gesendet an {{ mailFrom | prettyEmailAddresses }} für E-Mail, die an {{ rcptTo | prettyEmailAddresses }} gesendet wird. {{ details.message || details.reason }}",
"bounceInfo": "Bounce-Mail gesendet",
"deferred": "Zurückgestellt",
"outboundInfo": "In die Warteschlange gestellte E-Mail zur Zustellung an {{ rcptTo | prettyEmailAddresses }} von {{ mailFrom | prettyEmailAddresses }}",
"outboundInfo": "Zur Zustellung in die Warteschlange gestellt",
"denied": "Verweigert",
"bounce": "Bounce",
"incoming": "Eingehend",
"queued": "Warteschlange",
"deferredInfo": "Die Zustellung von E-Mails an {{ rcptTo | prettyEmailAddresses }} ist fehlgeschlagen. {{ details.message || details.reason }}. Wird in {{ details.delay }} Sekunden erneut versucht.",
"deliveredInfo": "Zugestellte E-Mail an {{ rcptTo | prettyEmailAddresses }} von {{ mailFrom | prettyEmailAddresses }}",
"receivedInfo": "Gespeicherte E-Mail von {{ mailFrom | prettyEmailAddresses }} in der Mailbox {{ rcptTo | prettyEmailAddresses }}",
"deniedInfo": "Verbindung von {{ remote.ip }} verweigert. {{ details.message || details.reason }}",
"deferredInfo": "Die Zustellung von E-Mail ist fehlgeschlagen. Wird in {{ details.delay }} Sekunden erneut versucht.",
"deliveredInfo": "Zugestellte E-Mail",
"receivedInfo": "Gespeichert",
"deniedInfo": "Verbindung verweigert",
"spamFilterTrainedInfo": "Der Spam-Filter wird durch Mailbox-Inhalte trainiert",
"inboundInfo": "Eingehende E-Mail von {{ mailFrom | prettyEmailAddresses }} an {{ rcptTo | prettyEmailAddresses }}. Spam: {{ details.spamStatus.indexOf('Yes,') === 0 ? 'Yes' : 'No' }}",
"inboundInfo": "Eingehend",
"outgoing": "Ausgehend",
"spamFilterTrained": "Spam-Filter trainiert"
},
"time": "Zeit",
"searchPlaceholder": "Suche",
"details": "Details",
"empty": "Das Ereignisprotokoll ist leer."
"empty": "Das Ereignisprotokoll ist leer.",
"from": "Von",
"mailFrom": "Von",
"rcptTo": "Zu"
},
"changeDomainDialog": {
"locationPlaceholder": "Leer lassen, um die Haupt-Domäne zu verwenden",
@@ -497,7 +530,10 @@
"description": "Dies wird eine Test-E-Mail von <b>no-reply@{{ domain }}</b> an die unten angegebene Adresse senden.",
"sendAction": "Senden"
},
"typeFilterHeader": "Alle Ereignisse"
"typeFilterHeader": "Alle Ereignisse",
"aclDialog": {
"dnsblZones": "DNSBL Zonen"
}
},
"support": {
"title": "Support",
@@ -517,7 +553,8 @@
"typeApp": "Anwendungsfehler",
"typeBug": "Fehlermeldung",
"report": "Meldung",
"subscriptionRequiredDescription": "Antworten auf die häufigsten Fragen sind in der <a href=\"{{ supportViewLink }}\" target=\"_blank\">Dokumentation</a> verfügbar. Unser <a href=\"{{ forumLink }}\" target=\"_blank\">Forum</a> bietet einen Platz in die Community einzusteigen und sich auszutauschen."
"subscriptionRequiredDescription": "Antworten auf die häufigsten Fragen sind in der <a href=\"{{ supportViewLink }}\" target=\"_blank\">Dokumentation</a> verfügbar. Unser <a href=\"{{ forumLink }}\" target=\"_blank\">Forum</a> bietet einen Platz in die Community einzusteigen und sich auszutauschen.",
"emailVerifyAction": "Jetzt verifizieren"
},
"remoteSupport": {
"title": "Fernwartung",
@@ -687,7 +724,8 @@
"setupMountDescription": "Wenn aktiv, konfiguriert Cloudron den Einhängepunkts auf dem Server",
"port": "Port",
"user": "User",
"privateKey": "Privater Schlüssel"
"privateKey": "Privater Schlüssel",
"diskPath": "Datenträger-Pfad"
},
"configureBackupSchedule": {
"retentionPolicy": "Aufbewahrungsrichtlinie",
@@ -745,7 +783,12 @@
"provider": "Anbieter",
"disabledList": "Bei folgenden Anwendungen ist die automatische Datensicherung deaktiviert:",
"description": "Cloudron erstellt ein komplettes Systembackup auf dem konfigurierten Ort.",
"title": "Backup-Ort"
"title": "Backup-Ort",
"remount": "Speicher neu einhängen"
},
"check": {
"noop": "Die Cloudron-Backups sind deaktiviert. Bitte stellen Sie sicher, dass dieser Server auf alternativen Wegen gesichert wird. Siehe https://docs.cloudron.io/backups/#storage-providers für weitere Informationen.",
"sameDisk": "Die Cloudron-Backups befinden sich derzeit auf dem gleichen Datenträger wie die Cloudron-Server-Instanz. Dies ist gefährlich und kann zu einem kompletten Datenverlust führen, wenn der Datenträger ausfällt. Siehe https://docs.cloudron.io/backups/#storage-providers zum Speichern von Backups an einem externen Ort."
}
},
"appstore": {
@@ -786,7 +829,7 @@
"userManagementAllUsers": "Allen Usern dieser Cloudron-Instanz den Zugriff erlauben",
"userManagementLeaveToApp": "Die User-Verwaltung der Anwendung überlassen",
"userManagementMailbox": "Alle Nutzer mit einem Postfach auf diesem Cloudron haben Zugriff.",
"userManagementNone": "Diese Anwendung verfügt über eine eigene User-Verwaltung.",
"userManagementNone": "Diese Anwendung verfügt über eine eigene User-Verwaltung. Diese Einstellung bestimmt die Sichtbarkeit der Anwendung im Dashboard.",
"userManagement": "User-Verwaltung",
"manualWarning": "Manuell einen DNS-A-Eintrag für <b>{{ location }}</b> erstellen, der auf die Cloudron-IP zeigt",
"locationPlaceholder": "Leer lassen um Hauptdomäne zu benutzen",
@@ -837,7 +880,8 @@
"title": "{{ name }} konfigurieren",
"memoryLimitDescription": "Cloudron weist 50% dieses Wertes als RAM und 50% als Swap zu.",
"resetToDefaults": "Auf Standardwert zurücksetzen",
"accessControlDescription": "Wenn Nicht-Administratoren den Zugriff auf SFTP erhalten, können diese die Konfigurationsdateien und geheimen Schlüssel der Anwendung lesen. Bei einigen Anwendungen wie WordPress können sie auch das Passwort protokollieren."
"accessControlDescription": "Wenn Nicht-Administratoren den Zugriff auf SFTP erhalten, können diese die Konfigurationsdateien und geheimen Schlüssel der Anwendung lesen. Bei einigen Anwendungen wie WordPress können sie auch das Passwort protokollieren.",
"enableRecoveryMode": "Wiederherstellungsmodus aktivieren"
},
"configureActionTooltip": "Konfigurieren",
"restartActionTooltip": "Neustart",
@@ -940,10 +984,8 @@
"usage": "Benutzung",
"addAction": "Hinzufügen"
},
"description": "Mit dem Cloudron <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-Mail-Server</a> können User E-Mails für diese Domäne empfangen. Die Anwendungen <a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> sind für den Zugriff auf Cloudron E-Mail bereits vorkonfiguriert.",
"outgointServerInfo": "Ausgehende E-Mails (SMTP)",
"sieveServerInfo": "Sieve-Filter verwalten",
"loginHelp": "<i>Postfachname</i>@{{ domain }} und das Passwort des Postfach-Users verwenden, um auf die Postfächer dieser Domäne zuzugreifen",
"incomingServerInfo": "Eintreffende E-Mail (IMAP)"
},
"masquerading": {
@@ -1002,8 +1044,7 @@
"title": "Abonnement erforderlich"
},
"config": {
"title": "E-Mail-Konfiguration für {{ domain }}",
"connectionDetails": "Verbindungsdetails für andere E-Mail-Clients"
"title": "E-Mail-Konfiguration für {{ domain }}"
},
"addMailboxDialog": {
"owner": "Besitzer*in des Postfachs",
@@ -1042,7 +1083,8 @@
"title": "Die Mail-Liste {{ name }}@{{ domain }} bearbeiten"
},
"updateMailboxDialog": {
"activeCheckbox": "Postfach ist aktiv"
"activeCheckbox": "Postfach ist aktiv",
"enablePop3": "POP3 Zugriff aktivieren"
},
"updateMailinglistDialog": {
"activeCheckbox": "Mailing-Liste ist aktiv"
@@ -1259,12 +1301,16 @@
"saveAction": "Speichern",
"disableDescription": "Die E-Mail Einstellungen werden nicht automatisch vorgenommen, dies muss in der App selbst gemacht werden.",
"enable": "Verwende Cloudron um E-Mails zu versenden",
"enableDescription": "Diese App ist verwendet die <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail Konfiguration</a> der Domäne {{ domain }}.",
"enableDescription": "Diese App verwendet die <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail Konfiguration</a> der Domäne {{ domain }}.",
"disable": "E-Mail Konfiguration nicht automatisch vornehmen",
"description2": "Wenn dies aktiviert ist, wird der interne E-Mail Server verwendet. Dieser verwendet die <a href=\"{{ domainConfigLink }}\">ausgehende E-Mail Konfiguration</a> der Domäne {{ domain }}. Wenn dies deaktiviert ist, muss die E-Mail Konfiguration in der App selber vorgenommen werden."
},
"csp": {
"title": "Content-Security-Policy"
},
"inbox": {
"title": "Eingehende E-Mail",
"enable": "Benutze Cloudron Mail um E-Mails zu empfangen"
}
},
"repair": {
@@ -1273,7 +1319,7 @@
"enableRecoveryModeAction": "Wiederherstellungsmodus aktivieren",
"description": "Wenn die Anwendung nicht antwortet, bitte einen Neustart versuchen. Wenn die Anwendung aufgrund eines defekten Plugins oder einer Fehlkonfiguration ständig neu gestartet wird, die Anwendung in den Wiederherstellungsmodus bringen, um auf die Konsole zuzugreifen.\nFolgende <a href=\"{{ docsLink }}\" target=\"_blank\">Hinweise</a> befolgen, um die Anwendung wieder zum Laufen zu bringen.",
"restartAction": "Anwendung neustarten",
"disableRecoveryModeAction": "Wiederherstellungsmodus aktivieren"
"disableRecoveryModeAction": "Wiederherstellungsmodus deaktivieren"
},
"taskError": {
"description": "Wenn ein Konfigurations-, Aktualisierungs-, Wiederherstellungs- oder Sicherungsauftrag zu einem Fehler geführt hat, Auftrag erneut versuchen.",
@@ -1473,7 +1519,24 @@
},
"stopDialog": {
"title": "App {{ app }} wirklich stoppen?"
}
},
"cron": {
"commonPattern": {
"twicePerDay": "Zweimal am Tag",
"everyMinute": "Jede Minute",
"everyHour": "Jede Stunde",
"twicePerHour": "Zweimal die Stunde",
"everyDay": "Jeden Tag",
"everySunday": "Jeden Sonntag"
},
"title": "Crontab",
"saveAction": "Speichern",
"addCommonPattern": "Häufige Muster hinzufügen"
},
"sftpInfoAction": "SFTP Zugang",
"cronTabTitle": "Cron",
"forumUrlAction": "Hilfe benötigt? Im Forum fragen",
"eventlogTabTitle": "Ereignisprotokoll"
},
"logs": {
"download": "Vollständige Logfiles herunterladen",
@@ -1489,7 +1552,8 @@
"zh_Hans": "Chinesisch (vereinfacht)",
"vi": "Vietnamesisch",
"pl": "Polnisch",
"es": "Spanisch"
"es": "Spanisch",
"ru": "Russisch"
},
"storage": {
"mounts": {
@@ -1498,7 +1562,7 @@
},
"volumes": {
"backupWarning": "Die Datenträger werden <i>nicht</i> gesichert. Das Wiederherstellen einer App stellt den Inhalt des Datenträgers nicht wieder her. Bitte sicherstellen, dass für jeden Datenträger ein geeigneter Sicherungsplan existiert.",
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können. Dabei kann es sich um NFS/SSHFS-Mounts oder externe Speicherplatten handeln, die an den Server angeschlossen sind.",
"description": "Datenträger sind Verzeichnisse auf dem Server, die von Anwendungen gemeinsam genutzt werden können. Dabei kann es sich um NFS/SSHFS/CIFS-Mounts oder externe Speicherplatten handeln, die an den Server angeschlossen sind. Datenträger werden dem App-Container unter <code>/media</code> zur Verfügung gestellt.",
"removeVolumeDialog": {
"removeAction": "Entfernen",
"description": "Dies wird den Datenträger <code>{{ volume }}</code> löschen. Daten innerhalb des Host-Pfades werden nicht entfernt.",
@@ -1506,23 +1570,23 @@
},
"addVolumeDialog": {
"addAction": "Hinzufügen",
"nameWarning": "Cloudron wird den Host-Pfad in den Container der Anwendung mit diesem Namen unter <code>/media</code> einhängen.",
"nameWarning": "Apps haben unter <code>/media/{name}</code> Zugriff auf den Datenträger.",
"title": "Datenträger hinzufügen",
"server": "Server IP oder Hostname",
"remoteDirectory": "Remote-Verzeichnis",
"username": "Username",
"password": "Passwort",
"diskPath": "Festplattenpfad",
"noopWarning": "Cloudron konfiguriert den Server nicht um diesen Datenträger einzubinden",
"mountTypeInfo": "Cloudron konfiguriert den Server um diesen Datenträger einzubinden",
"port": "Port",
"user": "User",
"privateKey": "Privater SSH Schlüssel"
"privateKey": "Privater SSH Schlüssel",
"mountpointWarning": "Das automatische Mounten des Datenträgers, wird durch die Cloudron-Konfiguration nicht vorgenommen."
},
"removeVolumeActionTooltip": "Datenträger entfernen",
"openFileManagerActionTooltip": "File-Manager öffnen",
"name": "Name",
"hostPath": "Host-Pfad",
"hostPath": "Mount-Point",
"addVolumeAction": "Datenträger hinzufügen",
"title": "Datenträger",
"mountType": "Einhängepunkttyp",
@@ -1530,7 +1594,10 @@
"title": "Konfiguriere Datenträger {{ volume }}"
},
"tooltipEdit": "Konfiguriere Datenträger",
"mountStatus": "Einhängestatus"
"mountStatus": "Einhängestatus",
"localDirectory": "Lokales Verzeichnis",
"type": "Typ",
"remountActionTooltip": "Datenträger neu einhängen"
},
"lang.ja": "Japanisch"
}
+263 -62
View File
@@ -18,7 +18,12 @@
"tagsFilterHeader": "Tags: {{ tags }}",
"tagsFilterHeaderAll": "All Tags",
"domainsFilterHeader": "All Domains",
"groupsFilterHeader": "Select Group"
"groupsFilterHeader": "Select Group",
"auth": {
"sso": "Log in with your Cloudron credentials",
"nosso": "Log in with dedicated account",
"email": "Log in with your email address"
}
},
"main": {
"offline": "Cloudron is offline. Reconnecting…",
@@ -71,7 +76,14 @@
"weeksAgo": "{{ w }} weeks ago",
"monthsAgo": "{{ m }} months ago",
"yearsAgo": "{{ y }} years ago"
}
},
"navbar": {
"users": "Users"
},
"disableAction": "Disable",
"enableAction": "Enable",
"statusEnabled": "Enabled",
"statusDisabled": "Disabled"
},
"appstore": {
"title": "App Store",
@@ -111,7 +123,7 @@
"locationPlaceholder": "Leave empty to use bare domain",
"manualWarning": "Add an A record manually for <b>{{ location }}</b> to this Cloudron's public IP",
"userManagement": "User management",
"userManagementNone": "This app has its own user management.",
"userManagementNone": "This app has its own user management. This setting determines whether this app is visible in the user's dashboard.",
"userManagementMailbox": "All users with a mailbox on this Cloudron have access.",
"userManagementLeaveToApp": "Leave user management to the app",
"userManagementAllUsers": "Allow all users from this Cloudron",
@@ -126,7 +138,8 @@
"setupSubscriptionAction": "Set up Subscription",
"installAnywayAction": "Install anyway",
"installAction": "Install",
"doInstallAction": "Install {{ dnsOverwrite ? 'and overwrite DNS' : '' }}"
"doInstallAction": "Install {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
"cloudflarePortWarning": "Cloudflare proxying must be disabled for the app's domain to access this port"
},
"appNotFoundDialog": {
"title": "App not found",
@@ -151,7 +164,7 @@
"categoryLabel": "Category"
},
"users": {
"title": "Users",
"title": "User Directory",
"newUserAction": "New User",
"users": {
"user": "User",
@@ -163,10 +176,13 @@
"inactiveTooltip": "User is inactive",
"externalLdapTooltip": "From external LDAP directory",
"notActivatedYetTooltip": "User is not activated yet",
"resetPasswordTooltip": "Reset password, disable 2FA or send invite link",
"resetPasswordTooltip": "Reset password",
"editUserTooltip": "Edit User",
"removeUserTooltip": "Remove User",
"transferOwnershipTooltip": "Transfer Ownership"
"transferOwnershipTooltip": "Transfer Ownership",
"invitationTooltip": "Invite User",
"setGhostTooltip": "Impersonate",
"mailmanagerTooltip": "This user can manage users and mailboxes"
},
"groups": {
"title": "Groups",
@@ -184,7 +200,7 @@
"saveAction": "Save"
},
"externalLdap": {
"title": "LDAP",
"title": "Connect an External Directory",
"description": "Cloudron will synchronize users and groups from an external LDAP or ActiveDirectory server. Password verification for authenticating those users is done against the external server. The synchronization is not run automatically but needs to be triggered manually.",
"subscriptionRequired": "This feature is only available in the paid plans.",
"subscriptionRequiredAction": "Set up Subscription Now",
@@ -235,7 +251,9 @@
"primaryEmail": "Primary email",
"recoveryEmail": "Password recovery email",
"errorDisplayNameRequired": "Name is required",
"activeCheckbox": "User is active"
"activeCheckbox": "User is active",
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up",
"fallbackEmailPlaceholder": "Optional. If not specified, primary email will be used"
},
"deleteUserDialog": {
"title": "Delete user {{ username }}",
@@ -267,15 +285,18 @@
"deleteAction": "Delete"
},
"passwordResetDialog": {
"title": "Password/2FA reset for {{ username }}",
"description": "Use the link below to reset {{ username }}'s password or re-invite:",
"title": "Reset password for {{ username }}",
"description": "The following password reset link was sent to {{ email }}:",
"sendEmailLinkAction": "Email link to user",
"emailSent": "Sent",
"no2FASetup": "This user has not set up 2FA.",
"2FAIsSetup": "Use this to disable user's 2FA. The user can set it up again from the Profile view.",
"newLinkAction": "Generate new link",
"resetLinkExplanation": "Use this to generate a password reset or invitation link. The new link will invalidate any old link immediately.",
"reset2FAAction": "Reset 2FA"
"newLinkAction": "Email reset link",
"resetLinkExplanation": "Use this to email a password reset link to the user's fallback email address - {{ email }}.",
"reset2FAAction": "Reset 2FA",
"sendAction": "Send Mail",
"descriptionLink": "Copy password reset link",
"descriptionEmail": "Send password reset link"
},
"externalLdapDialog": {
"title": "Configure LDAP"
@@ -284,13 +305,74 @@
"user": "User",
"usermanager": "User Manager",
"admin": "Administrator",
"owner": "Superadmin"
"owner": "Superadmin",
"mailmanager": "User & Email Manager"
},
"transferOwnershipDialog": {
"title": "Really transfer ownership?",
"description": "This will make the selected user the owner and admin of this Cloudron and remove admin rights to the current owner.",
"transferAction": "Transfer Ownership",
"newOwner": "New Owner"
},
"invitationDialog": {
"title": "Invite {{ username }}",
"inviteLinkExplanation": "Use this to generate a new invite link. The link will also be sent to the user and will reset the password.",
"newLinkAction": "Invite user now",
"description": "The following invite link was sent to {{ email }}:",
"sendAction": "Send Mail",
"descriptionLink": "Copy invite link",
"descriptionEmail": "Send invite link"
},
"setGhostDialog": {
"title": "Create password to impersonate {{ username }}",
"description": "Set a temporary password to login on behalf of this user in apps or the dashboard. This password is valid for 6 hours.",
"password": "Password",
"setPassword": "Set Password",
"generatePassword": "Generate Password"
},
"setGhost": {
"password": "Password"
},
"invitationNotification": {
"title": "Invitation link sent",
"body": "Email sent to {{ email }}"
},
"exposedLdap": {
"title": "Directory Server",
"description": "Cloudron can act as a central user directory server for external applications.",
"enabled": "Enabled",
"ipRestriction": {
"description": "The directory server can be limited to specific IPs or ranges.",
"placeholder": "Line separated IP address or Subnet",
"label": "Restrict Access"
},
"secret": {
"label": "Secret",
"description": "All LDAP queries have to be authenticated with this secret and the user DN <i>{{ userDN }}</i>"
}
},
"userImportDialog": {
"title": "Import Users",
"fileInput": "Select JSON or CSV file",
"importAction": "Import",
"description": "Upload a JSON or CSV file with the schema described in our <a href=\"{{ docsLink }}\" target=\"_blank\">documentation</a>",
"usersFound": "Found {{ count }} user(s) to import.",
"success": "{{ count }} user(s) imported.",
"failed": "The following users were not imported:",
"sendInviteCheckbox": "Send invitation email to imported users"
},
"userExport": {
"csv": "Export as CSV",
"json": "Export as JSON",
"tooltip": "Export Users"
},
"userImport": {
"tooltip": "Import Users"
},
"stateFilter": {
"all": "All Users",
"active": "Active Users",
"inactive": "Inactive Users"
}
},
"profile": {
@@ -298,7 +380,8 @@
"changeAvatar": {
"title": "Change Your Avatar",
"useGravatar": "Use <a target=\"_blank\" href=\"{{ gravatarLink }}\">Gravatar</a>",
"useCustomPicture": "Use Custom Picture"
"useCustomPicture": "Use Custom Picture",
"noAvatar": "No Profile Picture"
},
"primaryEmail": "Primary email",
"passwordRecoveryEmail": "Password recovery email",
@@ -358,7 +441,11 @@
"changeFallbackEmail": {
"title": "Change password recovery email address",
"errorEmailRequired": "A valid email address is required",
"errorEmailInvalid": "The Email address is not valid"
"errorEmailInvalid": "The Email address is not valid",
"email": "New password recovery email address",
"password": "Password for confirmation",
"errorWrongPassword": "Wrong password",
"errorPasswordRequired": "A password is required"
},
"changeDisplayName": {
"title": "Change your display name",
@@ -384,7 +471,12 @@
},
"changePasswordAction": "Change Password",
"disable2FAAction": "Disable 2FA",
"enable2FAAction": "Enable 2FA"
"enable2FAAction": "Enable 2FA",
"passwordResetAction": "I forgot my password",
"passwordResetNotification": {
"title": "Password reset successful",
"body": "Email sent to {{ email }}"
}
},
"backups": {
"title": "Backups",
@@ -396,7 +488,8 @@
"location": "Location",
"endpoint": "Endpoint",
"format": "Storage Format",
"configure": "Configure"
"configure": "Configure",
"remount": "Remount Storage"
},
"schedule": {
"title": "Schedule and Retention",
@@ -407,7 +500,7 @@
},
"listing": {
"title": "Listing",
"noBackups": "No backups have been made yet",
"noBackups": "No backups have been made yet.",
"contents": "Contents",
"version": "Version",
"noApps": "No apps",
@@ -491,7 +584,9 @@
"port": "Port",
"user": "User",
"privateKey": "Private Key",
"diskPath": "Disk Path"
"diskPath": "Disk Path",
"cifsSealSupport": "Use seal encryption. Requires at least SMB v3",
"chown": "Remote file system supports chown"
},
"check": {
"noop": "Cloudron backups are disabled. Please ensure this server is backed up using alternate means. See https://docs.cloudron.io/backups/#storage-providers for more information.",
@@ -535,10 +630,12 @@
"solrEnabled": "Enabled",
"solrDisabled": "Disabled",
"solrRunning": "Running",
"solrNotRunning": "Not Running"
"solrNotRunning": "Not Running",
"acl": "Mail ACL",
"aclOverview": "{{ dnsblZonesCount }} DNSBL zone(s)"
},
"eventlog": {
"title": "Event Log",
"title": "Email Event Log",
"time": "Time",
"details": "Details",
"empty": "Event Log is empty.",
@@ -550,16 +647,19 @@
"denied": "Denied",
"bounce": "Bounce",
"spamFilterTrained": "Spam filter trained",
"bounceInfo": "Sent bounce to {{ mailFrom | prettyEmailAddresses }} for mail sent to {{ rcptTo | prettyEmailAddresses }}. {{ details.message || details.reason }}",
"deferredInfo": "Failed to deliver mail to {{ rcptTo | prettyEmailAddresses }}. {{ details.message || details.reason }}. Will retry in {{ details.delay }}s.",
"inboundInfo": "Incoming mail from {{ mailFrom | prettyEmailAddresses }} to {{ rcptTo | prettyEmailAddresses }}. Spam: {{ details.spamStatus.indexOf('Yes,') === 0 ? 'Yes' : 'No' }}",
"outboundInfo": "Queued mail for delivery to {{ rcptTo | prettyEmailAddresses }} from {{ mailFrom | prettyEmailAddresses }}",
"receivedInfo": "Saved mail from {{ mailFrom | prettyEmailAddresses }} in mailbox {{ rcptTo | prettyEmailAddresses }}",
"deliveredInfo": "Delivered mail to {{ rcptTo | prettyEmailAddresses }} from {{ mailFrom | prettyEmailAddresses }}",
"deniedInfo": "Connection from {{ remote.ip }} denied. {{ details.message || details.reason }}",
"bounceInfo": "Sending bounce",
"deferredInfo": "Delivery failure, will retry in {{ delay }}s.",
"inboundInfo": "Received",
"outboundInfo": "Queued for delivery",
"receivedInfo": "Saved",
"deliveredInfo": "Delivered mail",
"deniedInfo": "Connection denied",
"spamFilterTrainedInfo": "Spam filter trained using mailbox content"
},
"searchPlaceholder": "Search"
"searchPlaceholder": "Search",
"from": "From",
"mailFrom": "From",
"rcptTo": "To"
},
"changeDomainDialog": {
"title": "Change Email Server Location",
@@ -594,7 +694,20 @@
"enableSolrCheckbox": "Enable Full Text Search using Solr",
"notEnoughMemory": "Please allocate at least 3GB to the mail service to enable solr."
},
"typeFilterHeader": "All Events"
"typeFilterHeader": "All Events",
"aclDialog": {
"dnsblZones": "DNSBL Zones",
"dnsblZonesInfo": "Connecting IP address is looked up in these IP blocklists",
"dnsblZonesPlaceholder": "Line separated zone names",
"title": "Change Email ACL"
},
"mailboxSharing": {
"title": "Mailbox Sharing",
"description": "When enabled, users can share their IMAP folders with other users.",
"enabled": "Mailbox sharing is currently enabled.",
"disabled": "Mailbox sharing is currently disabled.",
"enableAction": "Enable"
}
},
"network": {
"title": "Network",
@@ -602,11 +715,11 @@
"title": "IP Address",
"description": "Cloudron uses this IP address when setting up DNS records.",
"provider": "Provider",
"address": "IP Address",
"interface": "Network Interface Name",
"configure": "Configure",
"interfaceDescription": "List available devices on the server with:",
"detected": "detected"
"detected": "detected",
"address": "IP Address"
},
"firewall": {
"title": "Firewall",
@@ -620,13 +733,22 @@
},
"dyndns": {
"title": "Dynamic DNS",
"description": "Enable this option to keep all your DNS records in sync with a changing IP address. This is useful when Cloudron runs in a network with a frequently changing public IP address like a home connection.",
"useLabel": "Use Dynamic DNS",
"saved": "Saved"
"description": "Enable this option to keep all your DNS records in sync with a changing IP address. This is useful when Cloudron runs in a network with a frequently changing public IP address like a home connection."
},
"configureIp": {
"title": "Configure IP Provider",
"providerGenericDescription": "The Public IP address of the server will be automatically detected."
},
"ipv4": {
"address": "IPv4 Address"
},
"ipv6": {
"address": "IPv6 Address",
"title": "IPv6",
"description": "Cloudron uses this IPv6 address to setup DNS AAAA records.\n"
},
"configureIpv6": {
"title": "Configure IPv6 Provider"
}
},
"services": {
@@ -643,7 +765,9 @@
"accessControl": "Access Control",
"accessControlDescription": "Allowing non-admins to access SFTP will let them read application config files and secret keys. For some apps like WordPress, they can also log the password.",
"requireAdminRoleLabel": "Require admin role to access SFTP",
"resetToDefaults": "Reset to default"
"resetToDefaults": "Reset to default",
"enableRecoveryMode": "Enable Recovery Mode",
"recoveryModeDescription": "If the service is constantly restarting or not responding because of data corruption, place the service in recovery mode. Use the following <a href=\"{{ docsLink }}\" target=\"_blank\">instructions</a> to get the service running again."
},
"refresh": "Refresh"
},
@@ -659,7 +783,8 @@
"subscriptionEndsAt": "Canceled and ends on",
"subscriptionSetupAction": "Set up Subscription",
"subscriptionChangeAction": "Change Subscription",
"subscriptionReactivateAction": "Reactivate Subscription"
"subscriptionReactivateAction": "Reactivate Subscription",
"emailNotVerified": "Email not yet verified"
},
"timezone": {
"title": "Time Zone",
@@ -738,7 +863,9 @@
"sshCheckbox": "Allow support engineers to connect to this server via SSH",
"submitAction": "Submit",
"reportPlaceholder": "Describe your issue",
"emailPlaceholder": "If needed, provide an email address different from above to reach you"
"emailPlaceholder": "If needed, provide an email address different from above to reach you",
"emailVerifyAction": "Verify now",
"emailNotVerified": "Your cloudron.io account email {{ email }} is not verified. Please verify it to open support tickets."
},
"remoteSupport": {
"title": "Remote Support",
@@ -836,12 +963,14 @@
"fallbackCertCustomCertInfo": "This <a href=\"{{ customCertLink }}\" 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.",
"fallbackCertKeyPlaceholder": "Key",
"fallbackCertCertificatePlaceholder": "Certificate",
"matrixHostname": "Matrix server location",
"mastodonHostname": "Mastodon server location",
"matrixHostname": "Matrix Server Location",
"mastodonHostname": "Mastodon Server Location",
"netcupCustomerNumber": "Customer Number",
"netcupApiKey": "API Key",
"netcupApiPassword": "API Password",
"vultrToken": "Vultr Token"
"vultrToken": "Vultr Token",
"wellKnownDescription": "The values will be used by Cloudron to respond to <code>/.well-known/</code> URLs. Note that an app must be available on the bare domain <code>{{ domain }}</code> for this to work. See the <a href=\"{{docsLink}}\" target=\"_blank\">docs</a> for more information.",
"jitsiHostname": "Jitsi Location"
},
"removeDialog": {
"title": "Really remove {{ domain }}?",
@@ -853,7 +982,11 @@
"description": "This will reprovision the app and email DNS records across all domains.",
"syncAction": "Sync DNS",
"showLogsAction": "Show Logs"
}
},
"domainWellKnown": {
"title": "Well-Known locations of {{ domain }}"
},
"tooltipWellKnown": "Set Well-Known Locations"
},
"notifications": {
"title": "Notifications",
@@ -984,16 +1117,14 @@
"backAction": "Back to Email",
"config": {
"title": "Email configuration {{ domain }}",
"connectionDetails": "Connection details for other email clients"
"clientConfiguration": "Configuring Email Clients"
},
"incoming": {
"title": "Incoming Email",
"description": "Cloudron <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Email Server</a> allows users to receive email for this domain. <a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> are pre-configured to access Cloudron Email.",
"disableAction": "Disable",
"enableAction": "Enable",
"outgointServerInfo": "Outgoing Mail (SMTP)",
"sieveServerInfo": "ManageSieve",
"loginHelp": "Use <i>mailboxname</i>@{{ domain }} and the mailbox owner password to access mailboxes of this domain",
"server": "Server",
"port": "Port",
"tabTitle": "Mailboxes",
@@ -1004,7 +1135,13 @@
"name": "Name",
"owner": "Owner",
"aliases": "Aliases",
"usage": "Usage"
"usage": "Usage",
"importTooltip": "Import Mailboxes",
"exportTooltip": "Export Mailboxes",
"mailboxExport": {
"csv": "CSV",
"json": "JSON"
}
},
"mailinglists": {
"title": "Mailing Lists",
@@ -1020,7 +1157,13 @@
"subscriptionRequired": "This feature is only available in the paid plans. <a href=\"\" class=\"pull-right\" ng-click=\"openSubscriptionSetup()\">Set up Subscription Now</a>",
"saveAction": "Save"
},
"incomingServerInfo": "Incoming Mail (IMAP)"
"incomingServerInfo": "Incoming Mail (IMAP)",
"enabled": "Cloudron Email Server is configured to receive incoming emails for this domain.",
"disabled": "Cloudron Email Server will not receive incoming emails for this domain.",
"howToConnectDescription": "Use the settings below to configure email clients.",
"incomingUserInfo": "Username",
"incomingPasswordInfo": "Password",
"incomingPasswordUsage": "Password of the owner of the mailbox"
},
"outbound": {
"tabTitle": "Outbound",
@@ -1136,13 +1279,25 @@
},
"mailboxboxDialog": {
"usersHeader": "Users",
"groupsHeader": "Groups"
"groupsHeader": "Groups",
"appsHeader": "Apps"
},
"updateMailinglistDialog": {
"activeCheckbox": "Mailing list is active"
},
"updateMailboxDialog": {
"activeCheckbox": "Mailbox is active"
"activeCheckbox": "Mailbox is active",
"enablePop3": "Enable POP3 access"
},
"howToConnectInfoModal": "Configuring Email Clients",
"mailboxImportDialog": {
"title": "Import Mailboxes",
"description": "Upload a JSON or CSV file with the schema described in our <a href=\"{{ docsLink }}\" target=\"_blank\">documentation</a>.",
"fileInput": "Select JSON or CSV file",
"mailboxesFound": "Found {{ count }} mailbox(es) to import",
"success": "{{ count }} mailbox(es) imported.",
"failed": "The following mailboxes were not imported:",
"importAction": "Import"
}
},
"app": {
@@ -1192,8 +1347,8 @@
"accessControl": {
"userManagement": {
"title": "User management",
"description": "This app is configured to authenticate with the Cloudron User Directory.",
"descriptionSftp": "Also controls SFTP access.",
"description": "This app is configured to authenticate with the Cloudron User Directory. This setting controls who can log in and use the app.",
"descriptionSftp": "This setting also controls SFTP access.",
"dashboardVisibility": "Dashboard visibility",
"sftpAccessControl": "This setting also controls SFTP access.",
"visibleForAllUsers": "Visible to all users on this Cloudron",
@@ -1204,6 +1359,10 @@
"server": "Server",
"port": "Port",
"username": "Username"
},
"operators": {
"title": "Operators",
"description": "Operators can configure and maintain this app."
}
},
"resources": {
@@ -1253,12 +1412,19 @@
"saveAction": "Save",
"enable": "Use Cloudron Mail to send emails",
"description2": "When enabled, the app is configured to send emails via the internal mail server using this address. The internal mail server will use the {{ domain }}'s <a href=\"{{ domainConfigLink }}\">Outbound Email</a> settings to send mail. When disabled, you can configure the email settings within the app.",
"disable": "Do not configure mail settings",
"enableDescription": "The app is configured to send mails using the address below and the {{ domain }}'s <a href=\\\"{{ domainConfigLink }}\\\">Outbound Email</a> settings.",
"disable": "Do not configure app's mail delivery settings",
"enableDescription": "The app is configured to send mails using the address below and {{ domain }}'s <a href=\"{{ domainConfigLink }}\">Outbound Email</a> settings.",
"disableDescription": "The app's mail delivery settings is left alone. You can configure it inside the app."
},
"csp": {
"title": "Content Security Policy"
},
"inbox": {
"disable": "Do not configure inbox",
"disableDescription": "The app's incoming mail settings is left alone. You can configure it inside the app. Select this if the domain's email is not hosted on Cloudron.",
"title": "Incoming mail",
"enable": "Use Cloudron Mail to receive emails",
"enableDescription": "The app is configured to receive mails using the address below. Select this option if {{ domain }}'s email is hosted on this server."
}
},
"security": {
@@ -1403,7 +1569,7 @@
"title": "Clone {{ app }}",
"description": "Using backup from <b>{{ creationTime }}</b> and version <b>v{{ packageVersion }}</b>",
"location": "Location",
"cloneAction": "Clone"
"cloneAction": "Clone {{ dnsOverwrite ? 'and overwrite DNS' : '' }}"
},
"states": {
"running": "Running",
@@ -1412,7 +1578,25 @@
},
"stopDialog": {
"title": "Really stop app {{ app }}?"
}
},
"eventlogTabTitle": "Event Log",
"sftpInfoAction": "SFTP Access",
"cronTabTitle": "Cron",
"cron": {
"title": "Crontab",
"saveAction": "Save",
"addCommonPattern": "Add common pattern",
"commonPattern": {
"everyMinute": "Every Minute",
"everyHour": "Every Hour",
"twicePerHour": "Twice per Hour",
"everyDay": "Every Day",
"twicePerDay": "Twice per Day",
"everySunday": "Every Sunday"
},
"description": "Custom app-specific cron jobs can be added here. Note that cron jobs required for the app to function are already integrated into the app package and don't need to be configured here."
},
"forumUrlAction": "Need help? Ask in the forum"
},
"login": {
"loginTo": "Login to",
@@ -1466,6 +1650,10 @@
"success": {
"title": "Your Account is ready",
"openDashboardAction": "Open Dashboard"
},
"noUsername": {
"title": "Cannot setup account",
"description": "Account cannot be setup without a username."
}
},
"welcomeEmail": {
@@ -1495,7 +1683,8 @@
"pl": "Polish",
"vi": "Vietnamese",
"zh_Hans": "Chinese (Simplified)",
"es": "Spanish"
"es": "Spanish",
"ru": "Russian"
},
"volumes": {
"title": "Volumes",
@@ -1506,18 +1695,18 @@
"removeVolumeActionTooltip": "Remove Volume",
"addVolumeDialog": {
"title": "Add Volume",
"nameWarning": "Cloudron will mount the host path into the app's container with this name under <code>/media</code>.",
"nameWarning": "Apps can access this volume via <code>/media/{name}</code>.",
"addAction": "Add",
"server": "Server IP or Hostname",
"remoteDirectory": "Remote Directory",
"username": "Username",
"password": "Password",
"diskPath": "Disk Path",
"noopWarning": "Cloudron will not configure the server to mount this volume",
"mountTypeInfo": "Cloudron will configure the server to automatically mount this volume",
"port": "Port",
"user": "User",
"privateKey": "Private SSH Key"
"privateKey": "Private SSH Key",
"mountpointWarning": "Cloudron will not configure the server to automatically mount this volume"
},
"removeVolumeDialog": {
"title": "Really remove {{ volume }} ?",
@@ -1532,11 +1721,23 @@
},
"tooltipEdit": "Edit Volume",
"mountStatus": "Mount Status",
"type": "Type"
"type": "Type",
"localDirectory": "Local Directory",
"remountActionTooltip": "Remount Volume"
},
"storage": {
"mounts": {
"volumeLocation": "Volumes are mounted by volume name in the <code>/media</code> directory of this app."
}
},
"newLoginEmail": {
"subject": "[<%= cloudron %>] New login on your account",
"topic": "We've noticed a new login on your Cloudron account.",
"salutation": "Hi <%= user %>,",
"notice": "We noticed a login on your Cloudron account from a new device.",
"action": "If this was you, you can safely disregard this email. If this wasn't you, you should change your password immediately."
},
"supportConfig": {
"emailNotVerified": "Please verify the cloudron.io account email first to ensure we are able to contact you."
}
}
+264 -61
View File
@@ -12,7 +12,7 @@
"userManagementAllUsers": "Permitir a todos los usuarios de este Cloudron",
"userManagementLeaveToApp": "Deja la gestión de usuarios a la aplicación",
"userManagementMailbox": "Todos los usuarios con un buzón en este Cloudron tienen acceso.",
"userManagementNone": "Esta aplicación tiene su propia gestión de usuarios.",
"userManagementNone": "Esta aplicación tiene su propia gestión de usuarios. Esta configuración determina si esta aplicación está visible en el panel del usuario.",
"userManagement": "Gestión de usuarios",
"manualWarning": "Añadir un A record manualmente para <b>{{ location }}</b> a la IP pública de Cloudron",
"locationPlaceholder": "Dejar vacío para usar solo el dominio",
@@ -22,7 +22,8 @@
"doInstallAction": "Instalar {{ dnsOverwrite ? 'and overwrite DNS' : '' }}",
"installAction": "Instalar",
"installAnywayAction": "Instalar de todas formas",
"setupSubscriptionAction": "Configura tu suscripción"
"setupSubscriptionAction": "Configura tu suscripción",
"cloudflarePortWarning": "El proxy de Cloudflare debe estar deshabilitado para que el dominio de la aplicación acceda a este puerto"
},
"unstable": "Inestable",
"appMissing": "¿Falta alguna aplicación? Háznoslo saber.",
@@ -127,7 +128,12 @@
"selected": "{{ n }} seleccionado",
"select": "Seleccionar",
"filterPlaceholder": "Escribe para filtrar opciones"
}
},
"disableAction": "Deshabilitar",
"navbar": {
"users": "Usuarios"
},
"enableAction": "Habilitar"
},
"apps": {
"domainsFilterHeader": "Todos los Dominios",
@@ -148,7 +154,12 @@
"title": "¡No hay aplicaciones instaladas todavía!"
},
"title": "Mis aplicaciones",
"groupsFilterHeader": "Selecciona Grupo"
"groupsFilterHeader": "Selecciona Grupo",
"auth": {
"nosso": "Inicia sesión con una cuenta dedicada",
"sso": "Inicia sesión con tus credenciales de Cloudron",
"email": "Inicia sesión con tu dirección de correo electrónico"
}
},
"users": {
"addUserDialog": {
@@ -182,7 +193,7 @@
"subscriptionRequiredAction": "Configura tu Suscripción Ahora",
"subscriptionRequired": "Esta característica solo está habilitada en planes de pago.",
"description": "Cloudron sincronizará usuarios y grupos desde un servidor LDAP o ActiveDirectory externo. La verificación de la contraseña para autentificar a esos usuarios se realiza en el servidor externo. La sincronización no se ejecuta automáticamente, sino que debe activarse manualmente.",
"title": "LDAP",
"title": "Conectar un directorio externo",
"auth": "Auth",
"providerOther": "Otra",
"providerDisabled": "Deshabilitada"
@@ -206,7 +217,7 @@
"transferOwnershipTooltip": "Transferir Propiedad",
"removeUserTooltip": "Borrar Usuario",
"editUserTooltip": "Editar Usuario",
"resetPasswordTooltip": "Restablece la contraseña, deshabilita 2FA o envía enlace de invitación",
"resetPasswordTooltip": "Restablece la contraseña",
"notActivatedYetTooltip": "Usuario todavía no activado",
"externalLdapTooltip": "Desde un directorio LDAP externo",
"inactiveTooltip": "Usuario está inactivo",
@@ -215,10 +226,13 @@
"adminTooltip": "Este usuario es un administrador",
"empty": "No se han encontrado usuarios",
"groups": "Grupos",
"user": "Usuario"
"user": "Usuario",
"setGhostTooltip": "Suplantar",
"invitationTooltip": "Invitar Usuario",
"mailmanagerTooltip": "Este usuario puede administrar usuarios y buzones de correo"
},
"newUserAction": "Nuevo Usuario",
"title": "Usuarios",
"title": "Directorio de Usuarios",
"transferOwnershipDialog": {
"description": "Esto hará que el usuario seleccionado sea el propietario y administrador de este Cloudron y eliminará los derechos de administrador del propietario actual.",
"title": "¿Realmente quieres transferir la propiedad?",
@@ -229,21 +243,25 @@
"owner": "Super-administrador",
"admin": "Administrador",
"usermanager": "Gestión de usuarios",
"user": "Usuario"
"user": "Usuario",
"mailmanager": "Administrador de usuario y correo electrónico"
},
"externalLdapDialog": {
"title": "Configurar LDAP"
},
"passwordResetDialog": {
"sendEmailLinkAction": "Enviar enlace al usuario",
"description": "Usa el enlace de abajo para restablecer la contraseña o re-invitar a {{ username }}:",
"title": "Restablecer contraseña/2FA para {{ username }}",
"description": "El siguiente enlace de restablecimiento de contraseña se envió a {{ email }}:",
"title": "Restablecer contraseña para {{ username }}",
"emailSent": "Enviados",
"newLinkAction": "Generar nuevo enlace",
"resetLinkExplanation": "Usa esto para generar un enlace de invitación o restablecimiento de contraseña. El nuevo enlace invalidará cualquier enlace antiguo inmediatamente.",
"newLinkAction": "Enlace de restablecimiento de correo electrónico",
"resetLinkExplanation": "Use esto para enviar por correo electrónico un enlace de restablecimiento de contraseña a la dirección de correo electrónico alternativa del usuario - {{ email }}.",
"2FAIsSetup": "Usa esto para deshabilitar 2FA del usuario. El usuario puede configurarlo nuevamente desde la vista Perfil.",
"no2FASetup": "Este usuario no ha configurado 2FA.",
"reset2FAAction": "Restablecer 2FA"
"reset2FAAction": "Restablecer 2FA",
"sendAction": "Enviar correo",
"descriptionLink": "Copiar enlace de restablecimiento de contraseña",
"descriptionEmail": "Enviar enlace de restablecimiento de contraseña"
},
"deleteGroupDialog": {
"deleteAction": "Borrar",
@@ -290,7 +308,63 @@
"groups": "Grupos",
"role": "Rol",
"username": "Usuario",
"fullName": "Nombre Completo"
"fullName": "Nombre Completo",
"displayNamePlaceholder": "Opcional. Si no se proporciona, el usuario puede proporcionarlo durante el registro",
"fallbackEmailPlaceholder": "Opcional. Si no se especifica, se utilizará el correo electrónico principal"
},
"setGhostDialog": {
"title": "Crear contraseña para suplantar {{ username }}",
"description": "Establecer una contraseña temporal para iniciar sesión en nombre de este usuario en las aplicaciones o en el panel. Esta contraseña es válida por 6 horas.",
"password": "Contraseña",
"setPassword": "Establecer contraseña",
"generatePassword": "Generar Contraseña"
},
"setGhost": {
"password": "Contraseña"
},
"invitationDialog": {
"title": "Invitar {{ username }}",
"newLinkAction": "Invitar usuario ahora",
"description": "El siguiente enlace de invitación se envió a {{ email }}:",
"sendAction": "Enviar correo",
"descriptionLink": "Copiar enlace de invitación",
"descriptionEmail": "Enviar enlace de invitación",
"inviteLinkExplanation": "Usa esto para generar un nuevo enlace de invitación. El enlace también se enviará al usuario y restablecerá la contraseña."
},
"invitationNotification": {
"title": "Enlace de invitación enviado",
"body": "Correo enviado a {{ email }}"
},
"exposedLdap": {
"ipRestriction": {
"description": "El servidor de directorio puede limitarse a una IP específica o rangos.",
"placeholder": "Dirección IP o Subred separada por líneas",
"label": "Acceso Restringido"
},
"enabled": "Habilitado",
"title": "Servidor de Directorio",
"description": "Cloudron puede actuar como un servidor de directorio de usuarios central para aplicaciones externas.",
"secret": {
"label": "Secreta",
"description": "Todas las consultas LDAP deben autentificarse con este secreto y el DN de usuario <i>{{ userDN }}</i>"
}
},
"userImportDialog": {
"title": "Importar Usuarios",
"fileInput": "Selecciona un archivo JSON o CSV",
"importAction": "Importar",
"description": "La importación requiere un esquema específico tanto para JSON como para CSV. El esquema detallado se describe en nuestra <a href=\"{{ docsLink }}\" target=\"_blank\">documentación</a>",
"usersFound": "Se han encontrado {{ count }} usuarios para importar.",
"success": "{{ count }} usuarios importados correctamente.",
"failed": "Los siguientes usuarios no fueron importados:"
},
"userExport": {
"csv": "Exportar como CSV",
"json": "Exportar como JSON",
"tooltip": "Exportar Usuarios"
},
"userImport": {
"tooltip": "Importar Usuarios"
}
},
"backups": {
@@ -308,7 +382,7 @@
"noApps": "Sin Aplicaciones",
"version": "Versión",
"contents": "Contenidos",
"noBackups": "No se han hecho copias de seguridad aún",
"noBackups": "No se han hecho copias de seguridad aún.",
"title": "Listado"
},
"schedule": {
@@ -326,7 +400,8 @@
"provider": "Proveedor",
"disabledList": "Las siguientes aplicaciones tienen las copias de seguridad deshabilitadas:",
"title": "Ubicación",
"description": "Cloudron realiza una copia de seguridad completa de su sistema en la ubicación configurada."
"description": "Cloudron realiza una copia de seguridad completa de su sistema en la ubicación configurada.",
"remount": "Volver a montar almacenamiento"
},
"title": "Backups",
"configureBackupStorage": {
@@ -364,7 +439,18 @@
"mountPoint": "Punto de montaje",
"noopNote": "Esta opción rompe la funcionalidad de copia de seguridad y restauración de Cloudron y solo debe usarse para realizar pruebas. Asegúrese de que se haya realizado una copia de seguridad completa del servidor utilizando medios alternativos.",
"provider": "Proveedor de almacenamiento",
"title": "Configurar el almacenamiento de la Copia de Seguridad"
"title": "Configurar el almacenamiento de la Copia de Seguridad",
"password": "Contraseña",
"setupMountDescription": "Cuando está marcado, Cloudron configurará el punto de montaje en el servidor",
"diskPath": "Ruta del disco",
"server": "IP del servidor o Nombre de host",
"remoteDirectory": "Directorio Remoto",
"user": "Usuario",
"privateKey": "Clave privada",
"username": "Nombre de usuario",
"configureMount": "Especificar la configuración del punto de montaje",
"port": "Puerto",
"cifsSealSupport": "Utiliza la encriptación seal. Requiere al menos SMB v3"
},
"configureBackupSchedule": {
"retentionPolicy": "Política de Retención",
@@ -424,7 +510,11 @@
"changeFallbackEmail": {
"errorEmailInvalid": "La dirección de correo electrónico no es válida",
"errorEmailRequired": "Se requiere una dirección de correo electrónico válida",
"title": "Cambiar la dirección de correo electrónico de recuperación de contraseña"
"title": "Cambiar la dirección de correo electrónico de recuperación de contraseña",
"password": "Contraseña para confirmación",
"errorWrongPassword": "Contraseña errónea",
"errorPasswordRequired": "Se requiere una contraseña",
"email": "Nueva dirección de correo electrónico para la recuperación de contraseña"
},
"changeEmail": {
"errorEmailRequired": "Se requiere una dirección de email válida",
@@ -484,22 +574,28 @@
"changeAvatar": {
"useCustomPicture": "Usar Imagen personalizada",
"useGravatar": "Usa <a target=\"_blank\" href=\"{{ gravatarLink }}\">Gravatar</a>",
"title": "Cambia tu Avatar"
"title": "Cambia tu Avatar",
"noAvatar": "Sin imagen de perfil"
},
"title": "Perfil"
"title": "Perfil",
"passwordResetAction": "Olvidé mi contraseña",
"passwordResetNotification": {
"title": "Restablecimiento de contraseña exitosa",
"body": "Correo enviado a {{ email }}"
}
},
"emails": {
"eventlog": {
"searchPlaceholder": "Buscar",
"type": {
"spamFilterTrainedInfo": "Filtro de spam entrenado con contenido del buzón",
"deniedInfo": "Conexión de {{ remote.ip }} denegada. {{ details.message || details.reason }}",
"deliveredInfo": "Correo entregado a {{rcptTo | prettyEmailAddresses}} de {{mailFrom | prettyEmailAddresses}}",
"receivedInfo": "Correo guardado de {{mailFrom | prettyEmailAddresses}} en el buzón {{rcptTo | prettyEmailAddresses}}",
"outboundInfo": "Correo en cola para entregar a {{rcptTo | prettyEmailAddresses}} de {{mailFrom | prettyEmailAddresses}}",
"inboundInfo": "Correo entrante de {{ mailFrom | prettyEmailAddresses }} a {{ rcptTo | prettyEmailAddresses }}. Spam: {{ details.spamStatus.indexOf(Sí,') === 0 ? 'Sí' : 'No' }}",
"deferredInfo": "No se pudo entregar el correo a {{rcptTo | prettyEmailAddresses}}. {{detalles.mensaje || details.reason}}. Se reintentará en {{details.delay}} seg.",
"bounceInfo": "Rebote enviado a {{mailFrom | prettyEmailAddresses}} para el correo enviado a {{rcptTo | prettyEmailAddresses}}. {{detalles.mensaje || details.reason}}",
"deniedInfo": "Conexión denegada",
"deliveredInfo": "Correo entregado",
"receivedInfo": "Guardado",
"outboundInfo": "En cola para entrega",
"inboundInfo": "Recibido",
"deferredInfo": "Error de entrega, se volverá a intentar en {{ delay }}s.",
"bounceInfo": "Rebote de envíos",
"spamFilterTrained": "Filtro de spam entrenado",
"bounce": "Rebote",
"denied": "Denegada",
@@ -511,7 +607,10 @@
"empty": "El Registro de Eventos está vacío.",
"details": "Detalles",
"time": "Hora",
"title": "Registro de Eventos"
"title": "Registro de Eventos del Correo electrónico",
"from": "De",
"mailFrom": "De",
"rcptTo": "Para"
},
"settings": {
"solrNotRunning": "Parada",
@@ -525,7 +624,9 @@
"maxMailSize": "Tamaño máximo de correo electrónico",
"location": "Ubicación del Servidor de Correo",
"info": "Esta configuración es global y se aplica a todos los dominios.",
"title": "Ajustes"
"title": "Ajustes",
"acl": "Correo ACL",
"aclOverview": "{{ dnsblZonesCount }} zona(s) DNSBL"
},
"domains": {
"testEmailTooltip": "Enviar Email de prueba",
@@ -570,6 +671,19 @@
"location": "Ubicación",
"description": "Cloudron realizará los cambios de DNS necesarios en todos los dominios y reiniciará el servidor de correo. Los clientes de correo electrónico de escritorio y móviles deben reconfigurarse para usar esta nueva ubicación como servidor IMAP y SMTP.",
"title": "Cambiar ubicación del Servidor de Correo"
},
"aclDialog": {
"dnsblZones": "Zonas DNSBL",
"dnsblZonesInfo": "La dirección IP de conexión es buscada en estas listas de bloqueo de IP",
"dnsblZonesPlaceholder": "Nombres de zonas separados por líneas",
"title": "Cambiar Correo ACL"
},
"mailboxSharing": {
"title": "Compartir buzón",
"description": "Cuando está habilitado, los usuarios pueden compartir sus carpetas IMAP con otros usuarios.",
"enabled": "El uso compartido de buzones está habilitado actualmente.",
"disabled": "El uso compartido de buzones de correo está actualmente deshabilitado.",
"enableAction": "Habilitar"
}
},
"branding": {
@@ -602,10 +716,10 @@
"interfaceDescription": "Enumere los dispositivos disponibles en el servidor con:",
"configure": "Configurar",
"interface": "Nombre de la interfaz de red",
"address": "Dirección IP",
"provider": "Proveedor",
"description": "Cloudron usa esta dirección IP al configurar los registros DNS.",
"title": "Dirección IP"
"title": "Dirección IP",
"address": "Dirección IP"
},
"title": "Red",
"configureIp": {
@@ -613,10 +727,16 @@
"title": "Configurar Proveedor de IP"
},
"dyndns": {
"saved": "Guardado",
"useLabel": "Usar DNS Dinámico",
"description": "Habilite esta opción para mantener todos sus registros DNS sincronizados con una dirección IP cambiante. Esto es útil cuando Cloudron se ejecuta en una red con una dirección IP pública que cambia con frecuencia, como una conexión doméstica.",
"title": "DNS Dinámico"
},
"ipv4": {
"address": "Dirección IPv4"
},
"ipv6": {
"address": "Dirección IPv6 (opcional)",
"title": "IPv6",
"description": "Habilita esta opción para configurar registros AAAA de DNS para las aplicaciones y el servidor de correo."
}
},
"services": {
@@ -626,7 +746,9 @@
"accessControl": "Control de Acceso",
"memoryLimitDescription": "Cloudron asigna el 50% de este valor como RAM y el 50% como intercambio.",
"title": "Configurar {{ name }}",
"resetToDefaults": "Restablecer a lo predeterminado"
"resetToDefaults": "Restablecer a lo predeterminado",
"enableRecoveryMode": "Habilitar el Modo de Recuperación",
"recoveryModeDescription": "Si el servicio se reinicia constantemente o no responde debido a daños en los datos, pon el servicio en modo de recuperación. Utiliza las siguientes <a href=\"{{ docsLink }}\" target=\"_blank\">instrucciones</a> para volver a ejecutar el servicio."
},
"restartActionTooltip": "Reiniciar",
"configureActionTooltip": "Configurar",
@@ -648,7 +770,8 @@
"subscription": "Suscripción",
"cloudronId": "ID de Cloudron",
"subscriptionChangeAction": "Cambiar Suscripción",
"description": "Se utiliza una cuenta de Cloudron.io para acceder a la App Store y administrar su suscripción."
"description": "Se utiliza una cuenta de Cloudron.io para acceder a la App Store y administrar su suscripción.",
"emailNotVerified": "Correo aún no verificado"
},
"title": "Ajustes",
"updateScheduleDialog": {
@@ -748,7 +871,7 @@
"fallbackCertCustomCert": "Certificado personalizado",
"fallbackCertKeyPlaceholder": "Clave",
"fallbackCertCertificatePlaceholder": "Certificado",
"mastodonHostname": "Ubicación del servidor Mastodon",
"mastodonHostname": "Ubicación del Servidor Mastodon",
"netcupCustomerNumber": "Número de cliente",
"netcupApiKey": "Clave API",
"netcupApiPassword": "Contraseña API",
@@ -756,9 +879,12 @@
"namecheapUsername": "Usuario de Namecheap",
"namecheapInfo": "La IP del servidor debe estar incluida en la lista de permisos para esta clave de API.",
"wildcardInfo": "Configura <i>los registros A</i> para <b>*.{{ domain }}</b> y <b>{{ domain }}</b> con la IP de este servidor.",
"matrixHostname": "Ubicación del servidor Matrix",
"matrixHostname": "Ubicación del Servidor Matrix",
"fallbackCertInfo": "Los certificados se obtienen y renuevan automáticamente desde <a href=\"https://letsencrypt.org/\" target=\"_blank\"> Let's Encrypt </a>. Consulta el límite de frecuencia actual <a href=\"https://letsencrypt.org/docs/rate-limits/\" target=\"_blank\"> aquí </a>.\nEste certificado se utilizará en caso de que falle el certificado de Let's Encrypt. Si no se proporciona, se utilizará como respaldo un certificado autofirmado generado automáticamente.",
"fallbackCertCustomCertInfo": "Este <a href=\"{{ customCertLink }}\" target=\"_blank\"> certificado wildcard </a> se utilizará para todas las aplicaciones de este dominio. Si no se proporciona, se generará automáticamente un certificado autofirmado."
"fallbackCertCustomCertInfo": "Este <a href=\"{{ customCertLink }}\" target=\"_blank\"> certificado wildcard </a> se utilizará para todas las aplicaciones de este dominio. Si no se proporciona, se generará automáticamente un certificado autofirmado.",
"vultrToken": "Token Vultr",
"jitsiHostname": "Ubicación de Jitsi",
"wellKnownDescription": "Cloudron utilizará los valores para responder a las URLs <code>/.well-known/</code> . Ten en cuenta que la aplicación debe estar disponible en el dominio desnudo <code>{{ domain }}</code> para que esto funcione. Consulta <a href=\"{{docsLink}}\" target=\"_blank\">esta documentación</a> para más información."
},
"subscriptionRequired": {
"setupAction": "Configura tu suscripción",
@@ -786,7 +912,11 @@
"title": "Realmente quieres borrar {{ domain }}?",
"removeAction": "Borrar",
"description": "Esto borrará el dominio <code>{{ domain }}</code>."
}
},
"domainWellKnown": {
"title": "Ubicaciones Well-known de {{ domain }}"
},
"tooltipWellKnown": "Establece las ubicaciones Well-Known"
},
"app": {
"appInfo": {
@@ -898,13 +1028,20 @@
"disableDescription": "La configuración de entrega de correo de la aplicación es independiente. Puedes configurarla dentro de la aplicación.",
"description2": "Cuando está habilitada, la aplicación está configurada para enviar correos electrónicos a través del servidor de correo interno usando esta dirección. El servidor de correo interno utilizará la configuración de {{domain}} <a href=\"{{ domainConfigLink }}\"> correo electrónico saliente </a> para enviar correo. Cuando está deshabilitado, puede configurar los ajustes de correo electrónico dentro de la aplicación.",
"mailboxPlaceholder": "Dejar vacío para usar la plataforma predeterminada",
"disable": "No configurar los ajustes de correo",
"enableDescription": "La aplicación está configurada para enviar correos electrónicos utilizando la dirección que aparece a continuación y la configuración de <a href=\\\"{{ domainConfigLink }}\\\"> correo electrónico saliente </a> de {{domain}}.",
"disable": "No configurar la configuración de entrega de correo de la aplicación",
"enableDescription": "La aplicación está configurada para enviar correos electrónicos utilizando la dirección que aparece a continuación y la configuración de <a href=\"{{ domainConfigLink }}\"> correo electrónico saliente </a> de {{domain}}.",
"description": "Esto establece la dirección desde la que esta aplicación envía el correo electrónico. Esta aplicación ya está configurada para enviar correo mediante la configuración de {{domain}} <a href=\\\"{{ domainConfigLink }}\\\"> correo electrónico saliente </a>.",
"enable": "Utilizar Cloudron Mail para enviar correos electrónicos"
},
"csp": {
"title": "Política de seguridad de contenido"
},
"inbox": {
"title": "Correo entrante",
"enable": "Utiliza Cloudron Mail para recibir correos electrónicos",
"disable": "No configurar la bandeja de entrada",
"disableDescription": "La configuración de correo entrante de la aplicación es independiente. Puedes configurarlo dentro de la aplicación. Selecciona esta opción si el correo electrónico del dominio no está alojado en Cloudron.",
"enableDescription": "La aplicación está configurada para recibir correos electrónicos utilizando la siguiente dirección. Selecciona esta opción si el correo electrónico de {{ domain }} está alojado en este servidor."
}
},
"resources": {
@@ -932,9 +1069,13 @@
"visibleForAllUsers": "Visible para todos los usuarios de Cloudron",
"sftpAccessControl": "Este ajuste también controla el acceso SFTP.",
"dashboardVisibility": "Visibilidad del Panel",
"descriptionSftp": "También controla el acceso SFTP.",
"description": "Esta aplicación está configurada para autentificarse con el directorio de usuarios de Cloudron.",
"descriptionSftp": "Este ajuste también controla el acceso SFTP.",
"description": "Esta aplicación está configurada para autentificarse con el directorio de usuarios de Cloudron. Esta configuración controla quién puede iniciar sesión y usar la aplicación.",
"title": "Gestión de usuario"
},
"operators": {
"title": "Operadores",
"description": "Los operadores pueden configurar y mantener esta aplicación."
}
},
"location": {
@@ -1055,7 +1196,25 @@
"retryAction": "Reintentar {{ task }} tarea"
},
"appIsBusyTooltip": "La aplicación está ocupada"
}
},
"eventlogTabTitle": "Registro",
"sftpInfoAction": "Acceso SFTP",
"cronTabTitle": "Cron",
"cron": {
"commonPattern": {
"twicePerDay": "Dos veces al día",
"everyMinute": "Cada Minuto",
"everyHour": "Cada Hora",
"twicePerHour": "Dos veces por hora",
"everyDay": "Cada día",
"everySunday": "Cada Domingo"
},
"title": "Crontab",
"saveAction": "Guardar",
"addCommonPattern": "Agregar patrón común",
"description": "Aquí se pueden añadir trabajos cron personalizados específicos de la aplicación. Ten en cuenta que los trabajos cron necesarios para que la aplicación funcione ya están integrados en el paquete de la aplicación y no es necesario configurarlos aquí."
},
"forumUrlAction": "¿Necesitas ayuda? Pregunta en el foro"
},
"lang": {
"zh_Hans": "Chino (simple)",
@@ -1067,7 +1226,8 @@
"fr": "Francés",
"de": "Alemán",
"en": "Inglés",
"es": "Español"
"es": "Español",
"ru": "Ruso"
},
"system": {
"title": "Información del Sistema",
@@ -1113,7 +1273,9 @@
"subscriptionRequiredDescription": "Puedes encontrar respuestas en nuestra <a href=\"{{ supportViewLink }}\" target=\"_blank\">documentación</a> o pregunta en el <a href=\"{{ forumLink }}\" target=\"_blank\">Foro</a>.",
"emailInfo": "(El email de suscripción es {{ email }})",
"sshCheckbox": "Permitir que los ingenieros de soporte se conecten a este servidor a través de SSH",
"emailPlaceholder": "Si es necesario, proporciona una dirección de correo electrónico diferente de la anterior para contactarte"
"emailPlaceholder": "Si es necesario, proporciona una dirección de correo electrónico diferente de la anterior para contactarte",
"emailVerifyAction": "Verificar ahora",
"emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte."
},
"title": "Soporte"
},
@@ -1126,16 +1288,35 @@
"addVolumeDialog": {
"addAction": "Añadir",
"title": "Añadir Volumen",
"nameWarning": "Cloudron montará la ruta del host en el contenedor de la aplicación con este nombre en <code> /media </code>."
"nameWarning": "Las aplicaciones pueden acceder a este volumen a través de <code>/media/{name}</code>.",
"mountpointWarning": "Cloudron no configurará el servidor para montar automáticamente este volumen",
"mountTypeInfo": "Cloudron configurará el servidor para montar automáticamente este volumen",
"server": "IP del servidor o Nombre de host",
"remoteDirectory": "Directorio remoto",
"username": "Nombre de usuario",
"password": "Contraseña",
"diskPath": "Ruta del disco",
"port": "Puerto",
"user": "Usuario",
"privateKey": "Clave privada SSH"
},
"removeVolumeActionTooltip": "Borrar Volumen",
"openFileManagerActionTooltip": "Abrir Gestor de Archivos",
"name": "Nombre",
"hostPath": "Directorio del servidor",
"hostPath": "Punto de montaje",
"addVolumeAction": "Añade un Volumen",
"title": "Volúmenes",
"description": "Los volúmenes son directorios en el servidor que se pueden compartir entre aplicaciones. Estos pueden ser montajes NFS / SSHFS o discos de almacenamiento externos conectados al servidor.",
"backupWarning": "Los volúmenes <i> no </i> están respaldados. Restaurar una aplicación no restaurará el contenido del volumen. Asegúrate de tener un plan de copias de seguridad adecuado para cada volumen."
"description": "Los volúmenes son directorios en el servidor que se pueden compartir entre aplicaciones. Estos pueden ser soportes NFS/SSHFS /CIFS o discos de almacenamiento externos conectados al servidor. Los volúmenes se adjuntan al contenedor de la aplicación en <code>/media</code>.",
"backupWarning": "Los volúmenes <i> no </i> están respaldados. Restaurar una aplicación no restaurará el contenido del volumen. Asegúrate de tener un plan de copias de seguridad adecuado para cada volumen.",
"localDirectory": "Directorio Local",
"mountStatus": "Estado de montaje",
"type": "Tipo",
"mountType": "Tipo de montaje",
"updateVolumeDialog": {
"title": "Actualizar Volumen {{ volume }}"
},
"tooltipEdit": "Editar Volumen",
"remountActionTooltip": "Volver a montar Volumen"
},
"eventlog": {
"filterAllEvents": "Todos los Eventos",
@@ -1252,7 +1433,6 @@
"title": "Atrapa todo",
"saveAction": "Guardar"
},
"description": "<a href=\"{{ emailDocsLink }}\" target=\"_blank\">El Servidor de Correo</a> de Cloudron permite a los usuarios recibir emails para este dominio. <a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> están pre-configurados para acceder al Correo de Cloudron.",
"mailboxes": {
"aliases": "Alias",
"title": "Buzones de correo",
@@ -1272,14 +1452,19 @@
},
"outgointServerInfo": "Correo Saliente (SMTP)",
"sieveServerInfo": "ManageSieve",
"loginHelp": "Utiliza <i> nombre del buzón </i> @ {{domain}} y la contraseña del propietario del buzón para acceder a los buzones de este dominio",
"title": "Correo electrónico entrante",
"disableAction": "Deshabilitar",
"enableAction": "Habilitar",
"server": "Servidor",
"port": "Puerto",
"tabTitle": "Buzones de correo",
"incomingServerInfo": "Correo entrante (IMAP)"
"incomingServerInfo": "Correo entrante (IMAP)",
"enabled": "El servidor de correo electrónico de Cloudron está configurado para recibir correos electrónicos entrantes para este dominio.",
"disabled": "El servidor de correo electrónico de Cloudron no recibirá correos electrónicos entrantes para este dominio.",
"howToConnectDescription": "Utiliza la siguiente configuración para configurar los clientes de correo electrónico.",
"incomingUserInfo": "Nombre de Usuario",
"incomingPasswordInfo": "Contraseña",
"incomingPasswordUsage": "Contraseña del propietario del buzón"
},
"outbound": {
"noopAdminDomainWarning": "Cloudron no puede enviar invitaciones de usuario, restablecimiento de contraseña y otras notificaciones cuando el correo electrónico está deshabilitado en el dominio principal",
@@ -1302,10 +1487,11 @@
"backAction": "Volver a Correo Electrónico",
"config": {
"title": "Configuración de Correo electrónico {{ domain }}",
"connectionDetails": "Detalles de conexión para otros clientes de correo electrónico"
"clientConfiguration": "Configuración de clientes de correo electrónico"
},
"updateMailboxDialog": {
"activeCheckbox": "El buzón de correo está activo"
"activeCheckbox": "El buzón de correo está activo",
"enablePop3": "Habilitar acceso POP3"
},
"dnsStatus": {
"ptrInfo": "El registro PTR lo establece tu proveedor de VPS y no tu proveedor de DNS.",
@@ -1392,14 +1578,16 @@
},
"mailboxboxDialog": {
"usersHeader": "Usuarios",
"groupsHeader": "Grupos"
"groupsHeader": "Grupos",
"appsHeader": "Aplicaciones"
},
"updateMailinglistDialog": {
"activeCheckbox": "La lista de correo está activa"
},
"status": {
"tabTitle": "Estado"
}
},
"howToConnectInfoModal": "Configuración de clientes de correo electrónico"
},
"passwordResetEmail": {
"expireNote": "Tenga en cuenta que el enlace para restablecer la contraseña caducará en 24 horas.",
@@ -1413,7 +1601,8 @@
"clearAll": "Borrar todo",
"dismissTooltip": "Descartar",
"title": "Notificaciones",
"nonePending": "No hay notificaciones!"
"nonePending": "No hay notificaciones!",
"markAllAsRead": "Marcar Todos como leídos"
},
"terminal": {
"title": "Terminal",
@@ -1485,6 +1674,10 @@
"success": {
"title": "Tu cuenta está lista",
"openDashboardAction": "Abrir Panel"
},
"noUsername": {
"title": "No se puede configurar la cuenta",
"description": "La cuenta no se puede configurar sin un nombre de usuario."
}
},
"welcomeEmail": {
@@ -1492,8 +1685,8 @@
"expireNote": "Tenga en cuenta que el enlace de invitación caducará en 7 días.",
"salutation": "Hola <%= user %>,",
"inviteLinkAction": "Empezar",
"invitor": "Recibió este correo electrónico porque fue invitado por <% = invitor%>.",
"inviteLinkActionText": "Siga el enlace para comenzar: <% - invite Link%>",
"invitor": "Recibió este correo electrónico porque fue invitado por <%= invitor%>.",
"inviteLinkActionText": "Siga el enlace para comenzar: <%- inviteLink %>",
"subject": "Bienvenid@ a <%= cloudron %>"
},
"login": {
@@ -1509,5 +1702,15 @@
"mounts": {
"volumeLocation": "Los volúmenes se montan por nombre de volumen en el directorio <code> / media </code> de esta aplicación."
}
},
"newLoginEmail": {
"subject": "[<% = cloudron%>] Nuevo inicio de sesión en tu cuenta",
"topic": "Hemos notado un nuevo inicio de sesión en tu cuenta de Cloudron.",
"salutation": "Hola <%= user %>,",
"notice": "Notamos un inicio de sesión en tu cuenta de Cloudron desde un nuevo dispositivo.",
"action": "Si fuiste tú, puedes ignorar este correo electrónico. Si no fuiste tú, debes cambiar tu contraseña de inmediato."
},
"supportConfig": {
"emailNotVerified": "Por favor, primero verifica el correo electrónico de tu cuenta cloudron.io para asegurarnos de que podamos comunicarnos contigo."
}
}
+236 -60
View File
@@ -18,7 +18,12 @@
"description": "Lorsque ce sera le cas, elles apparaîtront ici."
},
"tagsFilterHeader": "Tags : {{ tags }}",
"groupsFilterHeader": "Sélectioner groupe"
"groupsFilterHeader": "Sélectionner Groupe",
"auth": {
"nosso": "Se connecter avec un compte dédié",
"email": "Se connecter avec une adresse email",
"sso": "Se connecter avec vos identifiants Cloudron"
}
},
"main": {
"offline": "Cloudron est hors ligne. Reconnexion…",
@@ -56,7 +61,22 @@
"clickToCopyBackupId": "Cliquez pour sauvegarder l'identifiant de sauvegarde",
"copied": "Copier dans le presse-papiers"
},
"searchPlaceholder": "Rechercher"
"searchPlaceholder": "Rechercher",
"multiselect": {
"select": "Sélectionner",
"selected": "{{ n }} sélectionné(s)",
"filterPlaceholder": "Écrire pour filtrer les options"
},
"prettyDate": {
"yeserday": "Hier",
"justNow": "À l'instant",
"hoursAgo": "Il y a {{ h }} heures",
"daysAgo": "Il y a {{ d }} jours",
"weeksAgo": "Il y a {{ w }} semaines",
"yearsAgo": "Il y a {{ y }} ans",
"minutesAgo": "Il y a {{ m }} minutes",
"monthsAgo": "Il y a {{ m }} mois"
}
},
"users": {
"title": "Utilisateurs",
@@ -71,9 +91,11 @@
"groups": "Groupes",
"editUserTooltip": "Modifier l'utilisateur",
"removeUserTooltip": "Supprimer l'utilisateur",
"resetPasswordTooltip": "Réinitialiser le mot de passe ou le lien d'invitation",
"resetPasswordTooltip": "Réinitialiser le mot de passe",
"transferOwnershipTooltip": "Transférer la propriété",
"externalLdapTooltip": "Depuis un annuaire LDAP externe"
"externalLdapTooltip": "Depuis un annuaire LDAP externe",
"setGhostTooltip": "Emprunter l'identité",
"invitationTooltip": "Envoyer une invitation à l'utilisateur"
},
"newUserAction": "Nouvel utilisateur",
"groups": {
@@ -114,7 +136,9 @@
"groupBaseDn": "Groupe Base DN",
"baseDn": "Base DN",
"description": "Cloudron va importer les utilisateurs et les groupes depuis un annuaire LDAP externe ou Active Directory. La vérification du mot de passe pour l'authentification de ces utilisateurs se fait via le serveur externe. La synchronisation ne s'exécute pas automatiquement, elle doit être lancée manuellement.",
"subscriptionRequiredAction": "Paramétrer mon abonnement maintenant"
"subscriptionRequiredAction": "Paramétrer mon abonnement maintenant",
"providerOther": "Autre",
"providerDisabled": "Désactivé"
},
"role": {
"usermanager": "Gestionnaire",
@@ -143,7 +167,9 @@
"role": "Rôle",
"activeCheckbox": "Utilisateur actif",
"errorInvalidEmail": "Cette adresse email est invalide",
"usernamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la première connexion"
"usernamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la première connexion",
"fallbackEmailPlaceholder": "Optionnel. Si laissé vide, ce sera l'adresse email principale qui sera utilisée",
"displayNamePlaceholder": "Optionnel. Si laissé vide, l'utilisateur peut en choisir un lors de la création du compte"
},
"group": {
"errorNameRequired": "Un nom est nécessaire",
@@ -175,8 +201,17 @@
},
"passwordResetDialog": {
"sendEmailLinkAction": "Envoyer le lien par email à l'utilisateur",
"description": "Utiliser le lien ci-dessous pour réinitialiser le mot de passe ou l'invitation de {{ username }} :",
"title": "Réinitialiser le mot de passe ou le lien d'invitation pour {{ username }}"
"description": "Le lien de réinitialisation du mot de passe suivant a été envoyé à {{ email }} :",
"title": "Réinitialiser le mot de passe pour {{ username }}",
"emailSent": "Envoyé",
"no2FASetup": "Cet utilisateur n'a pas configuré 2FA.",
"newLinkAction": "Envoyer le lien de réinitialisation",
"resetLinkExplanation": "Utiliser cette option pour envoyer un lien de réinitialisation du mot de passe à l'adresse email de secours - {{ email }}.",
"2FAIsSetup": "Utilisez ceci pour désactiver le 2FA de l'utilisateur. L'utilisateur pourra le configurer à nouveau à partir de son Profil.",
"reset2FAAction": "Réinitialiser 2FA",
"sendAction": "Envoyer l'email",
"descriptionLink": "Copier le lien de réinitialisation du mot de passe",
"descriptionEmail": "Envoyer le lien de réinitialisation du mot de passe"
},
"editGroupDialog": {
"title": "Modifier le groupe {{ name }}",
@@ -191,14 +226,37 @@
"subscriptionDialog": {
"title": "Abonnement nécessaire",
"setupAction": "Paramétrer mon abonnement"
},
"setGhost": {
"password": "Mot de passe"
},
"setGhostDialog": {
"description": "Choisissez un mot de passe temporaire pour vous connecter au tableau de bord et aux applications sous l'identité de cet utilisateur. Ce mot de passe sera valide pendant 6 heures.",
"title": "Créer un mot de passe pour emprunter l'identité de {{ username }}",
"password": "Mot de passe",
"setPassword": "Définir le mot de passe"
},
"invitationDialog": {
"newLinkAction": "Inviter l'utilisateur maintenant",
"description": "Le lien d'invitation suivant a été envoyé à {{ email }} :",
"sendAction": "Envoyer l'email",
"descriptionLink": "Copier le lien de l'invitation",
"descriptionEmail": "Envoyer le lien d'invitation",
"title": "Inviter {{ username }}",
"inviteLinkExplanation": "Cette action permet de générer un nouveau lien d'invitation. Le lien sera aussi envoyé à l'utilisateur et réinitialisera le mot de passe."
},
"invitationNotification": {
"title": "Lien d'invitation envoyé",
"body": "Email envoyé à {{ email }}"
}
},
"profile": {
"title": "Profil",
"changeAvatar": {
"title": "Changer votre avatar",
"title": "Changer votre photo de profil",
"useCustomPicture": "Utiliser une photo personnalisée",
"useGravatar": "Utiliser <a target=\"_blank\" href=\"{{ gravatarLink }}\">Gravatar</a>"
"useGravatar": "Utiliser <a target=\"_blank\" href=\"{{ gravatarLink }}\">Gravatar</a>",
"noAvatar": "Pas de photo de profil"
},
"passwordRecoveryEmail": "Adresse email de récupération de mot de passe",
"language": "Langue",
@@ -243,12 +301,16 @@
"changeFallbackEmail": {
"title": "Modifier l'adresse email de récupération du mot de passe",
"errorEmailRequired": "Une adresse email valide est nécessaire",
"errorEmailInvalid": "Cette adresse email est invalide"
"errorEmailInvalid": "Cette adresse email est invalide",
"errorWrongPassword": "Mot de passe erroné",
"errorPasswordRequired": "Un mot de passe est nécessaire",
"email": "Nouvelle adresse email de récupération du mot de passe",
"password": "Mot de passe pour confirmation"
},
"enable2FA": {
"description": "Votre administrateur Cloudron a demandé à tous les membres d'activer l'authentification à deux facteurs (2FA). Pour accéder au tableau de bord, veuillez l'activer.",
"token": "Jeton",
"title": "Autoriser l'authentification à deux facteurs (2FA)",
"title": "Activer l'authentification à deux facteurs (2FA)",
"enable": "Activer",
"setup2FA": "Paramétrer l'authentification à deux facteurs (2FA)",
"authenticatorAppDescription": "Scannez le code avec Google Authenticator (<a href=\"{{ googleAuthenticatorPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ googleAuthenticatorITunesLink }}\" target=\"_blank\">iOS</a>), FreeOTP (<a href=\"{{ freeOTPPlayStoreLink }}\" target=\"_blank\">Android</a>, <a href=\"{{ freeOTPITunesLink }}\" target=\"_blank\">iOS</a>) ou une application d'authentification similaire."
@@ -274,7 +336,9 @@
"revokeTokenTooltip": "Révoquer jeton",
"newApiToken": "Nouveau jeton API",
"title": "Jetons API",
"description": "Utilisez ces jetons d'accès personnels pour vous authentifier avec <a target=\"_blank\" href=\"{{ apiDocsLink }}\">l'API Cloudron</a>"
"description": "Utilisez ces jetons d'accès personnels pour vous authentifier avec <a target=\"_blank\" href=\"{{ apiDocsLink }}\">l'API Cloudron</a>",
"neverUsed": "jamais",
"lastUsed": "Dernière utilisation"
},
"loginTokens": {
"logoutAll": "Déconnecter de tous",
@@ -282,7 +346,12 @@
"description": "Vous avez {{ webadminTokens.length }} jeton(s) web actif(s) et {{ cliTokens.length }} jeton(s) CLI."
},
"disable2FAAction": "Désactiver l'authentification à deux facteurs (2FA)",
"enable2FAAction": "Activer l'authentification à deux facteurs (2FA)"
"enable2FAAction": "Activer l'authentification à deux facteurs (2FA)",
"passwordResetAction": "J'ai oublié mon mot de passe",
"passwordResetNotification": {
"title": "Réinitialisation du mot de passe réussie",
"body": "Email envoyé à {{ email }}"
}
},
"backups": {
"title": "Sauvegardes",
@@ -294,7 +363,8 @@
"configure": "Paramétrer",
"description": "Cloudron effectue une sauvegarde complète de votre système à l'emplacement défini.",
"provider": "Fournisseur",
"endpoint": "Point de terminaison"
"endpoint": "Point de terminaison",
"remount": "Remonter le stockage"
},
"configureBackupSchedule": {
"days": "Jours",
@@ -351,7 +421,17 @@
"copyConcurrencyDigitalOceanNote": "La limite pour DigitalOcean Spaces est fixée à 20.",
"encryptionPasswordPlaceholder": "Phrase secrète utilisée pour le chiffrement des sauvegardes",
"uploadPartSize": "Taille des partitions",
"uploadPartSizeDescription": "Taille des partitions dans le cadre du chargement partitionné. Jusqu'à 3 partitions peuvent être chargées simultanément, chacune nécessitant sa part de mémoire."
"uploadPartSizeDescription": "Taille des partitions dans le cadre du chargement partitionné. Jusqu'à 3 partitions peuvent être chargées simultanément, chacune nécessitant sa part de mémoire.",
"server": "IP du serveur ou Nom d'hôte",
"diskPath": "Chemin du disque",
"remoteDirectory": "Répertoire Distant",
"user": "Utilisateur",
"privateKey": "Clé Privée",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"configureMount": "Spécifier la configuration du point de montage",
"setupMountDescription": "Lorsque coché, Cloudron configurera le point de montage sur le serveur",
"port": "Port"
},
"backupDetails": {
"title": "Informations sur la sauvegarde",
@@ -372,14 +452,18 @@
"tooltipDownloadBackupConfig": "Télécharger le fichier de configuration de la sauvegarde",
"cleanupBackups": "Supprimer toutes les sauvegardes",
"backupNow": "Faire une sauvegarder maintenant",
"stopTask": "Interrompre {{ taskType === 'backup' ? 'la saBackup' : 'Cleanup' }}",
"noBackups": "Aucune sauvegarde n'a encore été effectuée",
"stopTask": "Interrompre {{ taskType === 'backup' ? 'la sauvegarde' : 'Nettoyer' }}",
"noBackups": "Aucune sauvegarde n'a encore été effectuée.",
"contents": "Contenu",
"version": "Version",
"noApps": "Aucune application"
},
"backupFailed": {
"title": "La sauvegarde a échoué"
},
"check": {
"noop": "Les sauvegardes Cloudron sont désactivées. Veuillez vous assurer que ce serveur est sauvegardé par d'autres moyens. Voir https://docs.cloudron.io/backups/#storage-providers pour plus d'informations.",
"sameDisk": "Les sauvegardes Cloudron sont actuellement sur le même disque que l'instance de serveur Cloudron. Ceci est dangereux et peut entraîner une perte totale de données si le disque tombe en panne. Voir https://docs.cloudron.io/backups/#storage-providers pour stocker les sauvegardes vers un emplacement externe."
}
},
"emails": {
@@ -398,12 +482,12 @@
"empty": "Le journal est vide.",
"type": {
"outgoing": "Sortant",
"deniedInfo": "Connexion depuis {{ remote.ip }} refusée. {{ details.message || details.reason }}",
"deferredInfo": "L'envoi de l'email à {{ rcptTo | prettyEmailAddresses }} a échoué. {{ details.message || details.reason }}. Nouvelle tentative dans {{ details.delay }}s.",
"deliveredInfo": "Email envoyé vers {{ rcptTo | prettyEmailAddresses }} depuis {{ mailFrom | prettyEmailAddresses }}",
"inboundInfo": "Email entrant de {{ mailFrom | prettyEmailAddresses }} vers {{ rcptTo | prettyEmailAddresses }}. Spam : {{ details.spamStatus.indexOf('Yes,') === 0 ? 'Yes' : 'No' }}",
"receivedInfo": "Email de {{ mailFrom | prettyEmailAddresses }} sauvegardé dans la messagerie {{ rcptTo | prettyEmailAddresses }}",
"outboundInfo": "Email en attente vers {{ rcptTo | prettyEmailAddresses }} depuis {{ mailFrom | prettyEmailAddresses }}",
"deniedInfo": "Accès refusé",
"deferredInfo": "Échec de l'envoi. Nouvelle tentative dans {{ delay }}s.",
"deliveredInfo": "Email envoyé",
"inboundInfo": "Reçu",
"receivedInfo": "Sauvegardé",
"outboundInfo": "Envoi en attente",
"deferred": "Reporté",
"incoming": "Entrant",
"queued": "En attente",
@@ -411,9 +495,12 @@
"bounce": "Non distribué",
"spamFilterTrained": "Rejeté par le filtre anti-spam",
"spamFilterTrainedInfo": "Rejeté par le filtre anti-spam utilisé par la messagerie",
"bounceInfo": "Notification d'email non distribué à {{ mailFrom | prettyEmailAddresses }} pour l'email envoyé à {{ rcptTo | prettyEmailAddresses }}. {{ details.message || details.reason }}"
"bounceInfo": "Notification d'email non distribué"
},
"title": "Journal des événements"
"title": "Journal des événements de la messagerie",
"from": "De",
"mailFrom": "De",
"rcptTo": "À"
},
"settings": {
"solrDisabled": "Désactivé",
@@ -427,7 +514,9 @@
"spamFilterOverview": "{{ blacklistCount }} adresse(s) email sur liste noire",
"solrFts": "Recherche en texte intégral (Solr)",
"solrEnabled": "Activé",
"solrRunning": "Actif"
"solrRunning": "Actif",
"acl": "Adresse ACL (liste de contrôle d'accès)",
"aclOverview": "{{ dnsblZonesCount }} liste(s) DNSBL"
},
"domains": {
"disabled": "Désactivé",
@@ -464,7 +553,13 @@
"enableSolrCheckbox": "Activer la recherche en texte intégral avec Solr",
"notEnoughMemory": "Veuillez allouer au moins 3GB au service email pour activer Solr."
},
"typeFilterHeader": "Tous les statuts"
"typeFilterHeader": "Tous les statuts",
"aclDialog": {
"dnsblZones": "Listes DNSBL",
"dnsblZonesInfo": "Recherche de l'adresse IP de connexion dans les listes noires suivantes",
"dnsblZonesPlaceholder": "Saisir une liste par ligne",
"title": "Changer l'adresse ACL (liste de contrôle d'accès)"
}
},
"network": {
"title": "Réseau",
@@ -480,12 +575,9 @@
},
"dyndns": {
"title": "DNS dynamique",
"useLabel": "Utiliser un DNS dynamique",
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique.",
"saved": "Enregistré"
"description": "Activez cette option pour conserver tous vos enregistrements DNS synchronisés avec une adresse IP dynamique. Cette option est utile lorsque Cloudron fonctionne avec un réseau dont l'adresse IP publique change fréquemment, comme dans le cas d'une connexion domestique."
},
"ip": {
"address": "Adresse IP",
"configure": "Paramétrer",
"interfaceDescription": "Liste des appareils disponibles sur le serveur :",
"detected": "détecté",
@@ -509,7 +601,8 @@
"username": "Nom d'utilisateur",
"server": "Adresse du serveur",
"description": "Cloudron peut extraire et installer des <a href=\"{{ customAppsLink }}\" target=\"_blank\">applications personnalisées</a> depuis un registre Docker privé.",
"title": "Registre Docker privé"
"title": "Registre Docker privé",
"serverNotSet": "Pas encore défini"
},
"appstoreAccount": {
"subscriptionSetupAction": "Paramétrer mon abonnement",
@@ -521,10 +614,13 @@
"email": "Adresse email du compte",
"setupAction": "Créer un compte",
"description": "Un compte Cloudron.io permet d'accéder à l'App Store et de gérer votre abonnement.",
"title": "Compte Cloudron.io"
"title": "Compte Cloudron.io",
"emailNotVerified": "Adresse email pas encore confirmée"
},
"registryConfig": {
"provider": "Fournisseur du registre Docker"
"provider": "Fournisseur du registre Docker",
"providerOther": "Autre",
"providerDisabled": "Désactivé"
},
"language": {
"description": "Le choix de la langue par défaut de ce Cloudron s'effectue ici. Elle sera également utilisée pour les emails transactionnels (envoi des liens d'invitation, réinitialisation des mots de passe...). Les utilisateurs pourront la modifier et sélectionner leur langue préférée depuis le tableau de bord de leur profil.",
@@ -595,14 +691,17 @@
"typeApp": "Problème avec une application",
"type": "Type",
"subscriptionRequiredDescription": "Vous devriez trouver votre réponse dans notre <a href=\"{{ supportViewLink }}\" target=\"_blank\">documentation</a>, vous pouvez également poser votre question sur le <a href=\"{{ forumLink }}\" target=\"_blank\">forum</a>.",
"title": "Ticket"
"title": "Ticket",
"emailVerifyAction": "Confirmer maintenant",
"emailNotVerified": "L'adresse email de votre compte Cloudron.io {{ email }} n'a pas encore été confirmée. Veuillez la valider pour ouvrir des tickets d'incident."
}
},
"notifications": {
"title": "Notifications",
"clearAll": "Tout effacer",
"nonePending": "Vous êtes à jour !",
"dismissTooltip": "Supprimer"
"dismissTooltip": "Supprimer",
"markAllAsRead": "Tout marquer comme lu"
},
"appstore": {
"category": {
@@ -627,7 +726,8 @@
"project": "Gestion de projet",
"media": "Médias",
"analytics": "Analyse de données",
"notes": "Notes"
"notes": "Notes",
"federated": "Fédération"
},
"accountDialog": {
"password": "Mot de passe",
@@ -653,7 +753,7 @@
"categoryLabel": "Catégorie",
"installDialog": {
"setupSubscriptionAction": "Paramétrer mon abonnement",
"lastUpdated": "Dernière mise à jour le {{ date }}",
"lastUpdated": "Dernière mise à jour {{ date }}",
"location": "Emplacement",
"errorUserManagementSelectAtLeastOne": "Sélectionnez au moins un utilisateur ou un groupe",
"installAnywayAction": "Installer quand même",
@@ -670,10 +770,11 @@
"pleaseUpgradeServer": "Veuillez souscrire à un serveur disposant d'une plus grande capacité de stockage. Vous pouvez également libérer de l'espace en désinstallant les applications que vous n'utilisez pas.",
"configuredForCloudronEmail": "Cette application a été préconfigurée pour être utilisée avec la <a href=\"{{ emailDocsLink }}\" target=\"_blank\">messagerie Cloudron</a>.",
"userManagementMailbox": "Accessible à tous les utilisateurs disposant d'une adresse de messagerie sur ce Cloudron.",
"userManagementNone": "Cette application a son propre système de gestion des utilisateurs.",
"userManagementNone": "Cette application a son propre système de gestion par les utilisateurs. Ce paramètre détermine si cette application apparaît sur le tableau de bord de l'utilisateur.",
"manualWarning": "Ajouter manuellement un enregistrement de type A à l'adresse IP publique de ce Cloudron pour <b>{{location}}</b>",
"userManagementLeaveToApp": "Laisser la gestion des utilisateurs à l'application",
"locationPlaceholder": "Laisser vide pour utiliser le nom de domaine nu"
"locationPlaceholder": "Laisser vide pour utiliser le nom de domaine nu",
"cloudflarePortWarning": "Le proxy Cloudflare doit être désactivé pour que le domaine de l'application puisse accéder à ce port"
},
"appMissing": "Une application manque sur Cloudron ? Dites-le nous !",
"noAppsFound": "Aucune application trouvée.",
@@ -747,7 +848,7 @@
"sftpAccessControl": "Ce paramètre contrôle aussi l'accès au SFTP.",
"dashboardVisibility": "Visibilité du tableau de bord",
"descriptionSftp": "Contrôle également l'accès au SFTP.",
"description": "Cette application est configurée pour s'authentifier avec l'annuaire des utilisateurs Cloudron.",
"description": "Cette application est configurée pour s'authentifier avec l'annuaire des utilisateurs Cloudron. Ce paramètre détermine qui peut se connecter à l'application et l'utiliser.",
"title": "Gestion des utilisateurs"
},
"sftp": {
@@ -755,6 +856,10 @@
"port": "Port",
"server": "Serveur",
"title": "SFTP"
},
"operators": {
"title": "Opérateurs",
"description": "Les opérateurs peuvent configurer et assurer la maintenance de cette application."
}
},
"repair": {
@@ -776,11 +881,23 @@
"from": {
"saveAction": "Sauvegarder",
"mailboxPlaceholder": "Laisser vide pour utiliser la plateforme par défaut",
"description": "Définit l'adresse avec laquelle cette application envoie des emails. Cette application est déjà paramétrée pour envoyer des emails avec les paramètres <a href=\"{{ domainConfigLink }}\">email sortant</a> de {{ domain }}.",
"title": "Adresse email expéditeur"
"description": "Définit l'adresse avec laquelle cette application envoie des emails. Cette application est déjà paramétrée pour envoyer des emails avec les paramètres <a href=\\\"{{ domainConfigLink }}\\\">email sortant</a> de {{ domain }}.",
"title": "Adresse email expéditeur",
"enable": "Utiliser Cloudron Mail pour envoyer les emails",
"enableDescription": "L'application est configurée pour envoyer des e-mails à l'aide de l'adresse ci-dessous et des paramètres <a href=\"{{ domainConfigLink }}\">Email sortant</a> du {{ domain }}.",
"disable": "Ne pas configurer les paramètres de messagerie de l'application",
"disableDescription": "Les paramètres de distribution des emails de l'application restent inchangés. Vous pouvez les configurer depuis l'application.",
"description2": "Si activé, l'application est paramétrée pour envoyer des emails via le serveur de messagerie interne en utilisant cette adresse. Le serveur de messagerie interne utilisera la configuration <a href=\"{{ domainConfigLink }}\">email sortant</a> de {{ domain }} pour envoyer les messages. Si désactivé, vous pouvez gérer les paramètres de la messagerie depuis l'application."
},
"csp": {
"title": "Politique de sécurité du contenu (CSP)"
},
"inbox": {
"title": "Email entrant",
"enable": "Utiliser la messagerie Cloudron pour recevoir des emails",
"enableDescription": "L'application est paramétrée pour recevoir des emails en utilisant l'adresse ci-dessous. Sélectionnez cette option si le {{ domain }} de l'adresse email est hébergé sur ce serveur.",
"disableDescription": "Les paramètres de réception des emails de l'application restent inchangés. Vous pouvez les configurer depuis l'application. Sélectionnez cette option si le domaine de l'adresse email n'est pas hébergé sur Cloudron.",
"disable": "Ne pas paramétrer la boîte de réception"
}
},
"emailTabTitle": "Messagerie",
@@ -885,7 +1002,8 @@
"30d": "30 jours",
"7d": "7 jours",
"24h": "24 heures",
"12h": "12 heures"
"12h": "12 heures",
"6h": "6 heures"
}
},
"resources": {
@@ -938,7 +1056,27 @@
"filemanagerActionTooltip": "Gestionnaire de fichiers",
"terminalActionTooltip": "Terminal",
"logsActionTooltip": "Journaux",
"backAction": "Retour vers Mes applications"
"backAction": "Retour vers Mes applications",
"stopDialog": {
"title": "Vraiment arrêter l'application {{ app }} ?"
},
"cron": {
"addCommonPattern": "Ajouter une tâche régulière",
"commonPattern": {
"everyMinute": "Toutes les minutes",
"twicePerHour": "Deux fois par heure",
"everyDay": "Tous les jours",
"twicePerDay": "Deux fois par jour",
"everySunday": "Tous les dimanches",
"everyHour": "Toutes les heures"
},
"title": "Crontab",
"saveAction": "Sauvegarder"
},
"forumUrlAction": "Besoin d'aide ? Consultez le forum",
"cronTabTitle": "Cron",
"sftpInfoAction": "Accès SFTP",
"eventlogTabTitle": "Journal des événements"
},
"logs": {
"title": "Journaux",
@@ -948,7 +1086,7 @@
"volumes": {
"name": "Nom",
"backupWarning": "Les volumes <i>ne sont pas</i> sauvegardés. La restauration d'une application ne restaurera pas le contenu du volume. Assurez-vous de prévoir une solution de sauvegarde adaptée pour chaque volume.",
"description": "Les volumes sont des répertoires sur le serveur qui peuvent être partagés entre les applications. Il peut s'agir de montages NFS/SSHFS ou de disques de stockage externes rattachés au serveur.",
"description": "Les volumes sont des répertoires sur le serveur qui peuvent être partagés entre les applications. Il peut s'agir de montages NFS/SSHFS/CIFS ou de disques de stockage externes connectés au serveur. Les volumes sont attachés au conteneur de l'application sous <code>/media</code>.",
"removeVolumeDialog": {
"removeAction": "Supprimer",
"description": "Cette action entraînera la suppression du volume <code>{{ volume }}</code>. Les données stockées dans le chemin d'accès de l'hôte ne seront pas effacées.",
@@ -956,14 +1094,33 @@
},
"addVolumeDialog": {
"addAction": "Ajouter",
"nameWarning": "Cloudron va monter le chemin d'accès de l'hôte dans le conteneur de l'application avec ce nom sous <code>/media</code>.",
"title": "Ajouter un volume"
"nameWarning": "Les applications peuvent accéder à ce volume via <code>/media/{name}</code>.",
"title": "Ajouter un volume",
"mountpointWarning": "Cloudron ne configurera pas le serveur pour monter automatiquement ce volume",
"mountTypeInfo": "Cloudron configurera le serveur pour monter automatiquement ce volume",
"server": "IP du serveur ou Nom d'hôte",
"remoteDirectory": "Répertoire distant",
"username": "Nom d'utilisateur",
"password": "Mot de passe",
"diskPath": "Chemin du Disque",
"port": "Port",
"user": "Utilisateur",
"privateKey": "Clé privée SSH"
},
"openFileManagerActionTooltip": "Ouvrir le gestionnaire de fichiers",
"removeVolumeActionTooltip": "Supprimer le volume",
"hostPath": "Chemin d'accès de l'hôte",
"hostPath": "Point de montage",
"addVolumeAction": "Ajouter un volume",
"title": "Volumes"
"title": "Volumes",
"localDirectory": "Répertoire local",
"tooltipEdit": "Modifier le Volume",
"remountActionTooltip": "Remonter le volume",
"mountType": "Type de montage",
"updateVolumeDialog": {
"title": "Mettre à jour le volume {{ volume }}"
},
"mountStatus": "Statut du montage",
"type": "Type"
},
"lang": {
"en": "Anglais",
@@ -975,7 +1132,8 @@
"pl": "Polonais",
"vi": "Vietnamien",
"zh_Hans": "Chinois (Simplifié)",
"es": "Espagnol"
"es": "Espagnol",
"ru": "Russe"
},
"email": {
"mailboxboxDialog": {
@@ -1020,11 +1178,9 @@
"addAction": "Ajouter"
},
"tabTitle": "Messageries",
"loginHelp": "Utilisez l'adresse <i>mailboxname</i>@{{ domain }} et le mot de passe de la messagerie de l'utilisateur pour accéder à toutes les messageries de ce domaine.",
"sieveServerInfo": "ManageSieve",
"incomingServerInfo": "Réception (IMAP)",
"outgointServerInfo": "Envoi (SMTP)",
"description": "Le <a href=\"{{ emailDocsLink }}\" target=\"_blank\">serveur de messagerie</a> Cloudron permet aux utilisateurs de recevoir des emails sur ce domaine. <a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> sont préconfigurés pour fonctionner avec la messagerie Cloudron.",
"port": "Port",
"server": "Serveur",
"enableAction": "Activer",
@@ -1035,7 +1191,8 @@
"membersInfo": "Saisir une adresse email par ligne",
"members": "Liste des membres",
"title": "Ajouter une liste de diffusion",
"membersOnlyCheckbox": "Limiter l'utilisation de la liste à ses membres"
"membersOnlyCheckbox": "Limiter l'utilisation de la liste à ses membres",
"name": "Nom"
},
"deleteMailboxDialog": {
"purgeMailboxCheckbox": "Supprimer tous les messages et les filtres de cette messagerie",
@@ -1128,11 +1285,17 @@
},
"backAction": "Retour vers Messagerie",
"config": {
"title": "Configuration de la messagerie {{ domain }}",
"connectionDetails": "Informations de connexion pour les autres clients de la messagerie"
"title": "Configuration de la messagerie {{ domain }}"
},
"editMailinglistDialog": {
"title": "Modifier la liste de diffusion {{ name }}@{{ domain }}"
},
"updateMailinglistDialog": {
"activeCheckbox": "La liste de diffusion est active"
},
"updateMailboxDialog": {
"enablePop3": "Activer l'accès POP3",
"activeCheckbox": "L'adresse de messagerie est active"
}
},
"domains": {
@@ -1164,7 +1327,7 @@
"zoneName": "Nom de la zone (optionnel)",
"advancedAction": "Paramètres avancés…",
"letsEncryptInfo": "Pour le bon fonctionnement de Let's Encrypt, votre serveur doit être joignable sur le port 80.",
"wildcardInfo": "Paramétrez les enregistrements <i>A</i> de <b>*.{{ domain }}</b> et <b>{{ domain }}</b> vers l'adresse IP de ce serveur.",
"wildcardInfo": "Paramétrez les enregistrements <i>A</i> de <b>*.{{ domain }}.</b> et <b>{{ domain }}.</b> vers l'adresse IP de ce serveur.",
"manualInfo": "Tous les enregistrements DNS doivent être paramétrés manuellement avant l'installation d'une nouvelle application.",
"namecheapInfo": "Le serveur IP doit faire l'objet d'une autorisation pour cette clé API.",
"namecheapApiKey": "Clé API",
@@ -1186,7 +1349,8 @@
"provider": "Fournisseur d'hébergement DNS",
"domain": "Domaine",
"editTitle": "Paramétrer {{ domain }}",
"addTitle": "Ajouter un domaine"
"addTitle": "Ajouter un domaine",
"vultrToken": "Token Vultr"
},
"changeDashboardDomain": {
"description": "Cette action entraînera le déplacement du tableau de bord et du serveur de messagerie vers le sous-domaine <code>my</code> du domaine sélectionné.",
@@ -1420,7 +1584,9 @@
"accessControlDescription": "Permettre aux utilisateurs non-administrateurs d'accéder au SFTP leur permettra d'accéder aux fichiers de paramétrage des applications et aux clés secrètes. Pour certaines applications comme WordPress, ils pourront également avoir accès au mot de passe.",
"accessControl": "Contrôle d'accès",
"memoryLimitDescription": "Cloudron alloue 50% de cette valeur à la mémoire RAM et 50% au fichier d'échange SWAP.",
"title": "Paramétrer {{ name }}"
"title": "Paramétrer {{ name }}",
"recoveryModeDescription": "Si le service ne cesse de redémarrer ou s'il ne répond pas en raison d'une altération des données, activez le mode récupération. Suivez ces <a href=\"{{ docsLink }}\" target=\"_blank\">instructions</a> pour remettre le service en marche.",
"enableRecoveryMode": "Activer le mode récupération"
},
"restartActionTooltip": "Redémarrer",
"configureActionTooltip": "Paramétrer",
@@ -1460,5 +1626,15 @@
"username": "Nom d'utilisateur",
"errorIncorrectCredentials": "Nom d'utilisateur ou mot de passe incorrect",
"loginTo": "Se connecter à"
},
"newLoginEmail": {
"salutation": "Bonjour <%= user %>,",
"topic": "Nous avons détecté une nouvelle connexion à votre compte Cloudron.",
"action": "Si vous êtes à l'origine de cette nouvelle connexion, vous pouvez ignorer cet email. Si ce n'était pas vous, nous vous recommandons de changer votre mot de passe immédiatement.",
"subject": "[<%= cloudron %>] Nouvelle connexion sur votre compte",
"notice": "Nous avons identifié une connexion à votre compte Cloudron depuis un nouvel appareil."
},
"supportConfig": {
"emailNotVerified": "Veuillez d'abord confirmer l'adresse email de votre compte Cloudron.io afin de vous assurer que nous sommes en mesure de vous contacter."
}
}
-6
View File
@@ -448,17 +448,14 @@
"tabTitle": "Caselle",
"port": "Porta",
"server": "Server",
"loginHelp": "Usa <i>nomecasella</i>@{{ domain }} e la password del proprietario della casella per accedere alle caselle di questi dominio",
"sieveServerInfo": "ManageSieve",
"outgointServerInfo": "Posta in uscita (SMTP)",
"enableAction": "Abilita",
"disableAction": "Disabilita",
"description": "Cloudron <a href=\"{{ emailDocsLink }}\" target=\"_blank\">server e-mail</a> permette di ricevere posta elettronica su questo dominio. <a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> sono pre-configurati per consentire l'accesso alle e-mail gestite da Cloudorn.",
"title": "Posta in arrivo",
"incomingServerInfo": "Posta in entrata (IMAP)"
},
"config": {
"connectionDetails": "Parametri di connessione per altri client di posta elettronica",
"title": "Configurazione e-mail {{ domain }}"
},
"backAction": "Torna a E-mail",
@@ -1205,8 +1202,6 @@
"title": "Configura l'IP del Provider"
},
"dyndns": {
"saved": "Salvato",
"useLabel": "Usa DNS Dinamico",
"description": "Abilita questa opzione per mantenere sincronizzati tutti i tuoi record DNS con un indirizzo IP che cambia. Ciò è utile quando Cloudron viene eseguito in una rete con un indirizzo IP pubblico che cambia frequentemente come una connessione domestica.",
"title": "DNS Dinamico"
},
@@ -1225,7 +1220,6 @@
"interfaceDescription": "Elenca i dispositivi disponibili sul server con:",
"configure": "Configura",
"interface": "Nome Interfaccia di Rete",
"address": "Indirizzo IP",
"provider": "Provider",
"description": "Cloudron utilizza questo indirizzo IP quando è configurato questo record DNS.",
"title": "Indirizzo IP"
+285 -77
View File
@@ -18,7 +18,12 @@
"tagsFilterHeader": "Tags: {{ tags }}",
"stateFilterHeader": "Alle statussen",
"searchPlaceholder": "Zoek Apps",
"groupsFilterHeader": "Selecteer groep"
"groupsFilterHeader": "Selecteer groep",
"auth": {
"nosso": "Log in met specifiek account",
"sso": "Log in met je Cloudron aanmeldgegevens",
"email": "Log in met je e-mailadres"
}
},
"main": {
"logout": "Uitloggen",
@@ -42,7 +47,7 @@
},
"action": {
"reboot": "Herstart",
"logs": "Logs"
"logs": "Logbestanden"
},
"clipboard": {
"copied": "Gekopieerd naar klembord",
@@ -71,7 +76,14 @@
"weeksAgo": "{{ w }} weken geleden",
"monthsAgo": "{{ m }} maanden geleden",
"yearsAgo": "{{ y }} jaren geleden"
}
},
"navbar": {
"users": "Gebruikers"
},
"disableAction": "Uitschakelen",
"enableAction": "Inschakelen",
"statusEnabled": "Ingeschakeld",
"statusDisabled": "Uitgeschakeld"
},
"appstore": {
"title": "App Store",
@@ -109,7 +121,7 @@
"location": "Domein",
"locationPlaceholder": "Leeg laten om hoofddomein te gebruiken",
"userManagement": "Gebruikersbeheer",
"userManagementNone": "Deze app heeft eigen gebruikersbeheer.",
"userManagementNone": "Deze app heeft eigen gebruikersbeheer. Deze instelling bepaalt of de app zichtbaar is op het dashboard van de gebruiker.",
"userManagementLeaveToApp": "Laat het gebruikersbeheer over aan de app",
"userManagementAllUsers": "Alle gebruikers van deze Cloudron toegang geven",
"errorUserManagementSelectAtLeastOne": "Selecteer minstens één gebruiker of groep",
@@ -125,7 +137,8 @@
"manualWarning": "Voeg handmatig een A record toe voor <b>{{ location }}</b> die verwijst naar het IP van deze Cloudron",
"userManagementMailbox": "Alle gebruikers met een mailbox op deze Cloudron hebben toegang.",
"userManagementSelectUsers": "Alleen de volgende gebruikers en groepen toegang geven",
"configuredForCloudronEmail": "Deze app is voorgeconfigureerd voor gebruik met <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron E-mail</a>."
"configuredForCloudronEmail": "Deze app is voorgeconfigureerd voor gebruik met <a href=\"{{ emailDocsLink }}\" target=\"_blank\">Cloudron E-mail</a>.",
"cloudflarePortWarning": "Cloudflare proxying dient uitgeschakeld te zijn voor het app-domein voor toegang tot deze poort"
},
"accountDialog": {
"titleSignUp": "Bij Cloudron.io registreren",
@@ -151,7 +164,7 @@
"categoryLabel": "Categorie"
},
"users": {
"title": "Gebruikers",
"title": "Gebruikerslijst",
"newUserAction": "Nieuwe gebruiker",
"users": {
"user": "Gebruiker",
@@ -161,12 +174,15 @@
"usermanagerTooltip": "Deze gebruiker kan groepen en andere gebruikers beheren",
"inactiveTooltip": "Gebruiker is inactief",
"externalLdapTooltip": "Van externe LDAP adresboek",
"resetPasswordTooltip": "Wachtwoord of 2FA opnieuw instellen",
"resetPasswordTooltip": "Wachtwoord opnieuw instellen",
"editUserTooltip": "Wijzig gebruiker",
"removeUserTooltip": "Verwijder gebruiker",
"superadminTooltip": "Deze gebruiker is superadmin",
"notActivatedYetTooltip": "Gebruiker is nog niet geactiveerd",
"transferOwnershipTooltip": "Eigenaarschap overdragen"
"transferOwnershipTooltip": "Eigenaarschap overdragen",
"invitationTooltip": "Gebruiker uitnodigen",
"setGhostTooltip": "Nabootsen",
"mailmanagerTooltip": "Deze gebruiker kan gebruikers en mailboxen beheren"
},
"groups": {
"title": "Groepen",
@@ -184,7 +200,7 @@
"allowProfileEditCheckbox": "Sta gebruikers toe om hun naam en e-mail aan te passen"
},
"externalLdap": {
"title": "LDAP",
"title": "Verbind met een externe lijst",
"subscriptionRequired": "Deze functie is alleen beschikbaar voor betaalde abonnementen.",
"subscriptionRequiredAction": "Neem nu een abonnement",
"noopInfo": "LDAP authenticatie is niet geconfigureerd.",
@@ -235,7 +251,9 @@
"errorDisplayNameRequired": "Naam is verplicht",
"activeCheckbox": "Gebruiker is actief",
"errorInvalidUsername": "Dit is geen geldige gebruikersnaam",
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen"
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
"fallbackEmailPlaceholder": "Optioneel. Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding"
},
"deleteUserDialog": {
"deleteAction": "Verwijder",
@@ -267,15 +285,18 @@
"deleteAction": "Verwijder"
},
"passwordResetDialog": {
"title": "Wachtwoord of 2FA opnieuw instellen voor {{ username }}",
"title": "Wachtwoord opnieuw instellen voor {{ username }}",
"sendEmailLinkAction": "E-mail link naar gebruiker",
"description": "Gebruik onderstaande link om {{ username }}'s wachtwoord te herstellen of opnieuw uit te nodigen:",
"description": "De volgende wachtwoord herstel link is gestuurd naar {{ username }}:",
"emailSent": "Verstuurd",
"no2FASetup": "Deze gebruiker heeft geen 2FA ingesteld.",
"2FAIsSetup": "2FA van de gebruiker uit schakelen. De gebruiker kan het aanzetten via Profiel.",
"newLinkAction": "Genereer nieuwe link",
"resetLinkExplanation": "Genereer een wachtwoordherstel- of uitnodiging link. De nieuwe link maakt de vorige link ongeldig.",
"reset2FAAction": "2FA opnieuw instellen"
"newLinkAction": "Verstuur wachtwoord herstel link",
"resetLinkExplanation": "Gebruik dit om een wachtwoord herstel link te e-mailen naar het alternatieve e-mailadres - {{ email }}.",
"reset2FAAction": "2FA opnieuw instellen",
"sendAction": "Verstuur E-mail",
"descriptionLink": "Kopieer wachtwoord herstel link",
"descriptionEmail": "Stuur wachtwoord reset link"
},
"externalLdapDialog": {
"title": "Configureer LDAP"
@@ -284,13 +305,74 @@
"user": "Gebruiker",
"usermanager": "Gebruikersmanager",
"admin": "Administrator",
"owner": "Superadmin"
"owner": "Superadmin",
"mailmanager": "Gebruiker & E-mail beheerder"
},
"transferOwnershipDialog": {
"transferAction": "Eigenaarschap overdragen",
"description": "Hiermee wordt de geselecteerde gebruiker de Eigenaar en Admin van deze Cloudron, de huidige Admin verliest diens rechten.",
"title": "Weet je zeker dat je het eigenaarschap wil overdragen?",
"newOwner": "Nieuwe eigenaar"
},
"invitationDialog": {
"title": "Nodig {{ username }} uit",
"inviteLinkExplanation": "Gebruik dit om een nieuwe uitnodigingslink te genereren. De link wordt ook gestuurd naar de gebruiker en herstelt het wachtwoord.",
"newLinkAction": "Nodig gebruiker nu uit",
"description": "De volgende uitnodigingslink is gestuurd naar {{ email }}:",
"sendAction": "Verstuur E-mail",
"descriptionLink": "Kopieer uitnodigingslink",
"descriptionEmail": "Stuur uitnodigingslink"
},
"setGhostDialog": {
"description": "Stel een tijdelijk wachtwoord in namens deze gebruiker in apps of het Dashboard. Dit wachtwoord is 6 uur geldig.",
"title": "Maak een wachtwoord om {{ username }} na te bootsen",
"password": "Wachtwoord",
"setPassword": "Wachtwoord instellen",
"generatePassword": "Genereer wachtwoord"
},
"setGhost": {
"password": "Wachtwoord"
},
"invitationNotification": {
"title": "Uitnodigingslink verstuurd",
"body": "E-mail verstuurd naar {{ email }}"
},
"exposedLdap": {
"ipRestriction": {
"placeholder": "Regelgescheiden IP adres of Subnet",
"description": "De lijstserver kan beperkt worden tot specifieke IP's of bereiken.",
"label": "Beperk toegang"
},
"enabled": "Ingeschakeld",
"title": "Lijst server",
"description": "Cloudron kan ingezet worden als gebruikerslijstserver voor externe applicaties.",
"secret": {
"label": "Geheim",
"description": "Alle LDAP verzoeken moeten geauthentiseerd worden met dit geheim en de gebruiker DN <i>{{ userDN }}</i>"
}
},
"userImportDialog": {
"title": "Importeer gebruikers",
"fileInput": "Selecteer JSON of CSV bestand",
"importAction": "Importeer",
"description": "Upload een JSON of CSV bestand met schema zoals beschreven in onze <a href=\"{{ docsLink }}\" target=\"_blank\">documentatie</a>",
"usersFound": "{{ count }} gebruiker(s) gevonden om te importeren.",
"success": "{{ count }} gebruiker(s) succesvol geïmporteerd.",
"failed": "De volgende gebruikers zijn niet geïmporteerd:",
"sendInviteCheckbox": "Stuur een uitnodigingsmail naar geïmporteerde gebruikers"
},
"userExport": {
"csv": "Exporteer als CSV",
"json": "Exporteer als JSON",
"tooltip": "Exporteer gebruikers"
},
"userImport": {
"tooltip": "Importeer gebruikers"
},
"stateFilter": {
"all": "Alle gebruikers",
"active": "Actieve gebruikers",
"inactive": "Inactieve gebruikers"
}
},
"profile": {
@@ -298,7 +380,8 @@
"changeAvatar": {
"useGravatar": "Gebruik <a target=\"_blank\" href=\"{{ gravatarLink }}\">Gravatar</a>",
"title": "Verander je profiel afbeelding",
"useCustomPicture": "Gebruik eigen afbeelding"
"useCustomPicture": "Gebruik eigen afbeelding",
"noAvatar": "Geen profiel foto"
},
"primaryEmail": "Primair e-mailadres",
"passwordRecoveryEmail": "Wachtwoordherstel e-mailadres",
@@ -358,7 +441,11 @@
"changeFallbackEmail": {
"errorEmailRequired": "Een geldig e-mailadres is verplicht",
"title": "E-mailadres voor wachtwoordherstel wijzigen",
"errorEmailInvalid": "Dit e-mailadres is niet geldig"
"errorEmailInvalid": "Dit e-mailadres is niet geldig",
"email": "Nieuw wachtwoordherstel e-mailadres",
"password": "Wachtwoord ter bevestiging",
"errorWrongPassword": "Onjuist wachtwoord",
"errorPasswordRequired": "Een wachtwoord is vereist"
},
"changeDisplayName": {
"title": "Pas je weergavenaam aan",
@@ -384,7 +471,12 @@
},
"changePasswordAction": "Verander wachtwoord",
"disable2FAAction": "Twee-Factor (2FA) authenticatie uitschakelen",
"enable2FAAction": "Twee-Factor (2FA) authenticatie inschakelen"
"enable2FAAction": "Twee-Factor (2FA) authenticatie inschakelen",
"passwordResetAction": "Ik ben mijn wachtwoord vergeten",
"passwordResetNotification": {
"title": "Wachtwoordherstel succesvol",
"body": "E-mail gestuurd naar {{ email }}"
}
},
"backups": {
"title": "Backups",
@@ -396,7 +488,8 @@
"endpoint": "Eindpunt",
"configure": "Configureer",
"description": "Cloudron maakt een volledige backup van je systeem op de geconfigureerde locatie.",
"format": "Opslagformaat"
"format": "Opslagformaat",
"remount": "Her-koppel Storage"
},
"schedule": {
"title": "Planning en bewaartermijn",
@@ -407,7 +500,7 @@
},
"listing": {
"title": "Lijst met bestaande back-ups",
"noBackups": "Er zijn nog geen backups gemaakt",
"noBackups": "Er zijn nog geen backups gemaakt.",
"contents": "Inhoud",
"version": "Versie",
"noApps": "Geen apps",
@@ -446,8 +539,8 @@
"configureBackupStorage": {
"title": "Configureer backup opslag",
"provider": "Opslag aanbieder",
"mountPoint": "Mount point",
"mountPointDescription": "De mount point moet handmatig ingesteld worden. Zie <a href=\"{{ providerDocsLink }}\" target=\"_blank\">handleiding</a>.",
"mountPoint": "Koppelpunt",
"mountPointDescription": "Het koppelpunt moet handmatig ingesteld worden. Zie <a href=\"{{ providerDocsLink }}\" target=\"_blank\">handleiding</a>.",
"localDirectory": "Lokale backup map",
"ext4Label": "Backup map is een externe EXT4 schijf",
"hardlinksLabel": "Gebruik hardlinks",
@@ -481,10 +574,16 @@
"encryptionPasswordRepeat": "Herhaal wachtwoord",
"remoteDirectory": "Externe map",
"username": "Gebruikersnaam",
"setupMountDescription": "Indien aangevinkt zal Cloudron het mount point configureren op de server",
"setupMountDescription": "Indien aangevinkt zal Cloudron het koppelpunt configureren op de server",
"server": "Server IP of Hostnaam",
"password": "Wachtwoord",
"configureMount": "Specificeer de mount point configuratie"
"configureMount": "Specificeer de koppelpunt configuratie",
"port": "Poort",
"diskPath": "Schijf pad",
"user": "Gebruiker",
"privateKey": "Private sleutel",
"cifsSealSupport": "Gebruik seal encryptie. SMB v3 is hiervoor minimaal benodigd",
"chown": "Extern bestandssysteem ondersteunt chown"
},
"backupFailed": {
"title": "Backup maken niet mogelijk"
@@ -531,10 +630,12 @@
"solrRunning": "Actief",
"solrNotRunning": "Inactief",
"solrFts": "Zoek volledige tekst (Solr)",
"solrDisabled": "Uitgeschakeld"
"solrDisabled": "Uitgeschakeld",
"acl": "E-mail ACL",
"aclOverview": "{{ dnsblZonesCount }} DNSBL zone(s)"
},
"eventlog": {
"title": "Logboek",
"title": "E-mail logboek",
"time": "Tijd",
"details": "Details",
"empty": "Logboek is leeg.",
@@ -545,17 +646,20 @@
"queued": "Wachtrij",
"denied": "Geweigerd",
"spamFilterTrained": "Spam filter getraind",
"outboundInfo": "E-mail in de wachtrij geplaatst voor aflevering aan {{ rcptTo | prettyEmailAddresses }} van {{ mailFrom | prettyEmailAddresses }}",
"receivedInfo": "Opgeslagen e-mail van {{ mailFrom | prettyEmailAddresses }} in de mailbox {{ rcptTo | prettyEmailAddresses }}",
"deliveredInfo": "Afgeleverde e-mail aan {{ rcptTo | prettyEmailAddresses }} van {{ mailFrom | prettyEmailAddresses }}",
"deniedInfo": "Verbinding van {{ remote.ip }} geweigerd. {{ details.message || details.reason }}",
"outboundInfo": "In de wachtrij geplaatst voor aflevering",
"receivedInfo": "E-mail opgeslagen",
"deliveredInfo": "E-mail afgeleverd",
"deniedInfo": "Verbinding geweigerd",
"bounce": "Bounce",
"bounceInfo": "Bounce-mail gestuurd van {{ mailFrom | prettyEmailAddresses }} voor e-mail {{ rcptTo | prettyEmailAddresses }}. {{ details.message || details.reason }}",
"deferredInfo": "De aflevering van e-mails aan {{ rcptTo | prettyEmailAddresses }} is niet gelukt. {{ details.message || details.reason }}. Wordt over {{ details.delay }} seconden opnieuw geprobeerd.",
"inboundInfo": "Inkomende e-mail van {{ mailFrom | prettyEmailAddresses }} aan {{ rcptTo | prettyEmailAddresses }}. Spam: {{ details.spamStatus.indexOf('Yes,') === 0 ? 'Yes' : 'No' }}",
"bounceInfo": "Bounce-mail gestuurd",
"deferredInfo": "Aflevering niet gelukt, nieuwe poging over {{ delay }}s.",
"inboundInfo": "Ontvangen",
"spamFilterTrainedInfo": "Het spam filter wordt getraind op basis van de e-mailbox inhoud"
},
"searchPlaceholder": "Zoeken"
"searchPlaceholder": "Zoeken",
"from": "Van",
"mailFrom": "Van",
"rcptTo": "Aan"
},
"changeDomainDialog": {
"location": "Locatie",
@@ -590,7 +694,20 @@
"notEnoughMemory": "Minstens 3GB configureren voor de e-mail dienst om solr te activeren.",
"description": "Solr kan gebruikt worden voor 'volledige tekst zoeken' in e-mails. Solr werkt alleen als de <a href=\"/#/services\" target=\"_blank\">e-mail dienst</a> minstens 3GB RAM is toegewezen."
},
"typeFilterHeader": "Alle gebeurtenissen"
"typeFilterHeader": "Alle gebeurtenissen",
"aclDialog": {
"dnsblZones": "DNSBL Zones",
"dnsblZonesInfo": "Verbindend IP-adres wordt opgezocht in deze IP-blokkeerlijsten",
"dnsblZonesPlaceholder": "Regel gescheiden zone namen",
"title": "Verander E-mail ACL"
},
"mailboxSharing": {
"description": "Indien ingeschakeld kunnen gebruikers hun IMAP-mappen delen met andere gebruikers.",
"title": "Mailbox delen",
"enabled": "Mailbox delen is momenteel ingeschakeld.",
"disabled": "Mailbox delen is momenteel uitgeschakeld.",
"enableAction": "Inschakelen"
}
},
"domains": {
"domainDialog": {
@@ -628,11 +745,14 @@
"addDescription": "Met het toevoegen van een domein krijg je de mogelijkheid om apps te installeren op subdomeinen. E-mailinstellingen voor het domein gaat via het E-mail scherm.",
"domain": "Domein",
"gcdnsServiceAccountKey": "Service Account Sleutel",
"mastodonHostname": "Mastodon server domein",
"matrixHostname": "Matrix server domein",
"mastodonHostname": "Mastodon Server Locatie",
"matrixHostname": "Matrix Server Locatie",
"netcupApiKey": "API Sleutel",
"netcupCustomerNumber": "Klantnummer",
"netcupApiPassword": "API wachtwoord"
"netcupApiPassword": "API wachtwoord",
"vultrToken": "Vultr Token",
"jitsiHostname": "Jitsi Locatie",
"wellKnownDescription": "De waardes worden door Cloudron gebruikt om te reageren op <code>/.well-known/</code> URLs. Let op: de app moet bereikbaar zijn op het hoofddomein <code>{{ domain }}</code> om te kunnen werken. Lees de <a href=\"{{docsLink}}\" target=\"_blank\">documentatie</a> voor meer informatie."
},
"title": "Domeinen & Certificaten",
"addDomain": "Domein toevoegen",
@@ -668,7 +788,11 @@
"syncAction": "Sync DNS",
"description": "Hiermee worden de app en e-mail DNS records van alle domeinen opnieuw aangemaakt.",
"title": "Sync DNS"
}
},
"domainWellKnown": {
"title": "Well-Known locaties van {{ domain }}"
},
"tooltipWellKnown": "Well-Known Locaties instellen"
},
"app": {
"email": {
@@ -682,9 +806,16 @@
"description": "Dit stelt het adres in waarvandaan deze app e-mail verzendt. Deze app is al geconfigureerd om e-mail te verzenden met deze {{ domain }}'s <a href=\\\"{{ domainConfigLink }}\\\">uitgaande e-mail</a> instellingen.",
"description2": "Indien ingeschakeld, verstuurt de app e-mails via de interne mailserver met dit adres. De interne mailserver gebruikt de {{ domain }}'s <a href=\"{{ domainConfigLink }}\">Uitgaande e-mail</a> instellingen om e-mail te versturen. Indien uitgeschakeld, kun je de e-mailinstellingen bewerken in de app.",
"enable": "Verstuur e-mails via Cloudron Mail",
"disable": "Configureer geen e-mailinstellingen",
"enableDescription": "De app is geconfigureerd om e-mails te verzenden met het onderstaande adres en het {{ domain }}'s <a href=\\\"{{ domainConfigLink }}\\\">Uitgaande e-mail</a> instellingen.",
"disable": "Configureer geen app e-mail aflever instellingen",
"enableDescription": "De app is geconfigureerd om e-mails te verzenden met het onderstaande adres en {{ domain }}'s <a href=\"{{ domainConfigLink }}\">Uitgaande e-mail</a> instellingen.",
"disableDescription": "De instellingen voor e-mailaflevering zijn niet geconfigureerd. Je kunt dit nu configureren in de app zelf."
},
"inbox": {
"disable": "Configureer niet de inbox",
"disableDescription": "De app's inkomende e-mail instellingen zijn niet ingesteld. Je kunt dit zelf in de app doen. Selecteer deze optie indien het gewenste e-maildomein niet in deze Cloudron is opgenomen.",
"title": "Inkomende e-mail",
"enable": "Gebruik Cloudron E-mail om e-mails te ontvangen",
"enableDescription": "Deze app is geconfigureerd om e-mails te ontvangen met onderstaand e-mailadres. Selecteer deze optie als {{ domain }}'s e-mail is gehost op deze server."
}
},
"backAction": "Terug naar Mijn Apps",
@@ -731,11 +862,11 @@
"accessControl": {
"userManagement": {
"title": "Gebruikersbeheer",
"description": "Deze app is ingesteld voor authenticatie met het Cloudron Gebruikersadresboek.",
"description": "Deze app is ingesteld voor authenticatie met het Cloudron Gebruikersadresboek. Deze instelling bepaalt wie kan inloggen om de app te gebruiken.",
"dashboardVisibility": "Dashboard zichtbaarheid",
"sftpAccessControl": "Deze instelling regelt ook SFTP-toegang.",
"visibleForSelected": "Alleen zichtbaar voor de volgende gebruikers en groepen",
"descriptionSftp": "Regelt ook SFTP-toegang.",
"descriptionSftp": "Deze instelling regelt ook SFTP-toegang.",
"visibleForAllUsers": "Zichtbaar voor alle gebruikers op deze Cloudron"
},
"sftp": {
@@ -743,6 +874,10 @@
"server": "Server",
"port": "Poort",
"username": "Gebruikersnaam"
},
"operators": {
"title": "Operators",
"description": "Operators kunnen deze app configureren en onderhouden."
}
},
"resources": {
@@ -766,12 +901,12 @@
"description": "Standaard bevinden de gegevens van deze app zich op <code>{{ storagePath }}</code>. Als de server onvoldoende schijfruimte heeft, kunt je een externe EXT4-schijf koppelen en de gegevens van deze app daarheen verplaatsen."
},
"mounts": {
"title": "Mounts",
"title": "Koppelpunten",
"readOnly": "Alleen-lezen",
"volume": "Volume",
"noMounts": "Er zijn geen Volumes gemount.",
"noMounts": "Er zijn geen Volumes gekoppeld.",
"saveAction": "Opslaan",
"addMountAction": "Volume mount toevoegen"
"addMountAction": "Volume koppelpunt toevoegen"
}
},
"graphs": {
@@ -925,7 +1060,7 @@
"cloneDialog": {
"title": "Kloon {{ app }}",
"location": "Locatie",
"cloneAction": "Kloon",
"cloneAction": "Kloon {{ dnsOverwrite ? 'en overschrijf DNS' : '' }}",
"description": "Backup van <b>{{ creationTime }}</b> en versie <b>v{{ packageVersion }}</b> gebruiken"
},
"projectWebsiteAction": "Project Website",
@@ -937,19 +1072,37 @@
},
"stopDialog": {
"title": "Weet je zeker dat je {{ app }} wilt stoppen?"
}
},
"forumUrlAction": "Hulp nodig? Vraag het in het forum",
"eventlogTabTitle": "Gebeurtenis log",
"cron": {
"commonPattern": {
"everyDay": "Elke dag",
"everyMinute": "Elke minuut",
"everyHour": "Elk uur",
"twicePerHour": "Twee keer per uur",
"twicePerDay": "Twee keer per dag",
"everySunday": "Elke zondag"
},
"title": "Crontab",
"saveAction": "Bewaar",
"addCommonPattern": "Voeg gemeenschappelijk patroon toe",
"description": "Eigen app-specifieke cron jobs kunnen hier toegevoegd worden. Let op: standaard cron jobs voor deze applicatie zijn al geïntegreerd in de app en hoef je hier niet te configureren."
},
"sftpInfoAction": "SFTP Toegang",
"cronTabTitle": "Cron"
},
"network": {
"title": "Netwerk",
"ip": {
"title": "IP Adres",
"provider": "Aanbieder",
"address": "IP Adres",
"interface": "Naam netwerkinterface",
"configure": "Configureer",
"interfaceDescription": "Toon beschikbare apparaten op deze server met:",
"description": "Cloudron gebruikt dit IP adres tijdens het instellen van DNS records.",
"detected": "gedetecteerd"
"detected": "gedetecteerd",
"address": "IP adres"
},
"firewall": {
"title": "Firewall",
@@ -963,13 +1116,22 @@
},
"dyndns": {
"title": "Dynamische DNS",
"description": "Schakel deze optie in om je DNS records synchroon te houden met je veranderende IP adres. Dit is handig als je Cloudron opgenomen is in een netwerk waarbij het publieke IP adres steeds wisselt zoals in een thuissituatie.",
"useLabel": "Gebruik Dynamische DNS",
"saved": "Opgeslagen"
"description": "Schakel deze optie in om je DNS records synchroon te houden met je veranderende IP adres. Dit is handig als je Cloudron opgenomen is in een netwerk waarbij het publieke IP adres steeds wisselt zoals in een thuissituatie."
},
"configureIp": {
"title": "Configureer IP aanbieder",
"providerGenericDescription": "Het publieke IP adres van deze server wordt automatisch gedetecteerd."
},
"ipv4": {
"address": "IPv4 adres"
},
"ipv6": {
"address": "IPv6 adres",
"title": "IPv6",
"description": "Cloudron gebruikt dit IPv6 adres om DNS AAAA records te configureren.\n"
},
"configureIpv6": {
"title": "Configureer IPv6 aanbieder"
}
},
"services": {
@@ -985,7 +1147,9 @@
"requireAdminRoleLabel": "Admin rol benodigd voor toegang tot SFTP",
"resetToDefaults": "Terugstellen naar standaardwaarden",
"memoryLimitDescription": "Cloudron wijst 50% van deze waarde toe als RAM en 50% als swap.",
"accessControlDescription": "Indien je toestaat dat non-admins toegang hebben tot SFTP, dan kunnen zij bijv. configuratiebestanden en geheime sleutels inzien. Voor WordPress kunnen ze alle wachtwoorden vastleggen."
"accessControlDescription": "Indien je toestaat dat non-admins toegang hebben tot SFTP, dan kunnen zij bijv. configuratiebestanden en geheime sleutels inzien. Voor WordPress kunnen ze alle wachtwoorden vastleggen.",
"recoveryModeDescription": "Indien de dienst continue herstart of niet reageert vanwege datacorruptie, plaats de dienst in dan Herstelmodus. Bekijk de volgende <a href=\"{{ docsLink }}\" target=\"_blank\">instructies</a> om de dienst weer werkend te krijgen.",
"enableRecoveryMode": "Inschakelen Herstelmodus"
},
"description": "Cloudron diensten bestaan uit functionaliteiten zoals databases, e-mail en authenticatie. Hint: alle diensten dienen 'groen' te zijn, zo niet dan kun je deze herstarten of het geheugenlimiet verhogen.",
"refresh": "Ververs"
@@ -1001,7 +1165,8 @@
"subscriptionChangeAction": "Abonnement wijzigen",
"subscriptionReactivateAction": "Abonnement heractiveren",
"title": "Cloudron.io Account",
"description": "Een Cloudron.io account wordt gebruikt voor toegang tot de App Store en om je abonnement te beheren."
"description": "Een Cloudron.io account wordt gebruikt voor toegang tot de App Store en om je abonnement te beheren.",
"emailNotVerified": "E-mail is niet geverifieerd"
},
"timezone": {
"title": "Tijdzone",
@@ -1054,7 +1219,7 @@
},
"language": {
"title": "Taal",
"description": "De standaard taal van deze Cloudron kan hier ingesteld worden. Deze instelling wordt ook gebruikt voor systeem e-mails zoals gebruikersuitnodigingen en wachtwoord herstellingen. Elke gebruiker kan een eigen voorkeurtaal instellen in het Profiel."
"description": "De standaard taal van deze Cloudron kan hier ingesteld worden. Deze instelling wordt ook gebruikt voor systeem e-mails zoals gebruikersuitnodigingen en wachtwoord herstellingen. Elke gebruiker kan een eigen voorkeurstaal instellen in het Profiel."
},
"title": "Instellingen",
"registryConfig": {
@@ -1081,7 +1246,9 @@
"emailPlaceholder": "Indien nodig, geef een alternatief e-mailadres op dan hierboven is vermeld",
"subscriptionRequiredDescription": "Je kunt ook antwoorden op je vragen vinden in onze <a href=\"{{ supportViewLink }}\" target=\"_blank\">documentatie</a> of vraag op ons <a href=\"{{ forumLink }}\" target=\"_blank\">Forum</a>.",
"emailInfo": "(E-mail van het abonnement is {{ email }})",
"sshCheckbox": "Sta toe dat ondersteuningsmedewerkers toegang krijgen tot deze server middels SSH"
"sshCheckbox": "Sta toe dat ondersteuningsmedewerkers toegang krijgen tot deze server middels SSH",
"emailVerifyAction": "Verifieer nu",
"emailNotVerified": "Je cloudron.io account e-mail {{ email }} is niet geverifieerd. Verifieer het om support tickets te kunnen openen."
},
"remoteSupport": {
"title": "Ondersteuning op afstand",
@@ -1099,7 +1266,7 @@
"diskContent": "Deze {{ type }} schijf bevat",
"notAvailableYet": "Nog niet beschikbaar",
"title": "Schijfgebruik",
"mountedAt": "{{ filesystem }} <small>mounted op</small> {{ mountpoint }}"
"mountedAt": "{{ filesystem }} <small>gekoppeld op</small> {{ mountpoint }}"
},
"systemMemory": {
"title": "Systeemgeheugen",
@@ -1123,7 +1290,8 @@
"title": "Notificaties",
"dismissTooltip": "Afwijzen",
"clearAll": "Alles wissen",
"nonePending": "Alles bijgewerkt!"
"nonePending": "Alles bijgewerkt!",
"markAllAsRead": "Markeer alles als gelezen"
},
"logs": {
"title": "Logbestanden",
@@ -1247,14 +1415,13 @@
"backAction": "Terug naar e-mail",
"config": {
"title": "E-mailconfiguratie {{ domain }}",
"connectionDetails": "Verbindingsdetails voor andere e-mailprogramma's"
"clientConfiguration": "Configureren E-mail clients"
},
"incoming": {
"disableAction": "Uitschakelen",
"enableAction": "Inschakelen",
"outgointServerInfo": "Uitgaande e-mail (SMTP)",
"sieveServerInfo": "ManageSieve",
"loginHelp": "Gebruik <i>mailboxname</i>@{{ domain }} en het wachtwoord van de e-mailbox eigenaar voor toegang tot mailboxen van dit domein",
"server": "Server",
"port": "Poort",
"tabTitle": "E-mailboxen",
@@ -1265,7 +1432,13 @@
"owner": "Eigenaar",
"aliases": "Aliassen",
"usage": "Gebruik",
"title": "E-mailboxen"
"title": "E-mailboxen",
"importTooltip": "Import Mailboxen",
"exportTooltip": "Export Mailboxen",
"mailboxExport": {
"csv": "CSV",
"json": "JSON"
}
},
"mailinglists": {
"title": "E-maillijsten",
@@ -1283,7 +1456,12 @@
},
"incomingServerInfo": "Inkomende e-mail (IMAP)",
"title": "Inkomende e-mail",
"description": "Cloudron <a href=\"{{ emailDocsLink }}\" target=\"_blank\">E-mailserver</a> maakt het mogelijk e-mail te ontvangen voor dit domein. <a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> zijn voorgeconfigureerd voor Cloudron e-mail."
"incomingUserInfo": "Gebruikersnaam",
"incomingPasswordInfo": "Wachtwoord",
"incomingPasswordUsage": "Wachtwoord van de eigenaar van de mailbox",
"enabled": "Cloudron e-mailserver is geconfigureerd voor inkomende e-mails voor dit domein.",
"disabled": "Cloudron e-mailserver ontvangt geen inkomende e-mails voor dit domein.",
"howToConnectDescription": "Gebruik onderstaande gegevens om e-mail clients in te stellen."
},
"outbound": {
"tabTitle": "Uitgaand",
@@ -1396,16 +1574,28 @@
},
"mailboxboxDialog": {
"usersHeader": "Gebruikers",
"groupsHeader": "Groepen"
"groupsHeader": "Groepen",
"appsHeader": "Apps"
},
"settings": {
"tabTitle": "Instellingen"
},
"updateMailboxDialog": {
"activeCheckbox": "Mailbox is actief"
"activeCheckbox": "Mailbox is actief",
"enablePop3": "POP3 toegang inschakelen"
},
"updateMailinglistDialog": {
"activeCheckbox": "Mailing-lijst is actief"
},
"howToConnectInfoModal": "Configureren e-mail clients",
"mailboxImportDialog": {
"title": "Importeer Mailboxen",
"description": "Upload een JSON of CSV bestand met een schema zoals beschreven in onze <a href=\"{{ docsLink }}\" target=\"_blank\">documentatie</a>.",
"fileInput": "Selecteer JSON of CSV bestand",
"mailboxesFound": "{{ count }} mailbox(en) gevonden om te importeren",
"success": "{{ count }} mailbox(en) geïmporteerd.",
"failed": "De volgende mailboxen zijn niet geïmporteerd:",
"importAction": "Importeren"
}
},
"login": {
@@ -1442,12 +1632,12 @@
},
"storage": {
"mounts": {
"volumeLocation": "Volumes worden ge-mount met de Volume-naam in de <code>/media</code> map van deze app."
"volumeLocation": "Volumes worden gekoppeld met de Volume-naam in de <code>/media</code> map van deze app."
}
},
"volumes": {
"backupWarning": "Volumes worden <i>niet</i> geback-upt. Het herstellen van een app, herstelt niet de inhoud van het Volume. Zorg ervoor dat je een geschikt backup-plan hebt voor elke Volume.",
"description": "Volumes zijn mappen op de server die gedeeld kunnen worden tussen apps. Dit mogen NFS/SSHFS mounts of op deze server aangesloten externe schijven zijn.",
"description": "Volumes zijn mappen op de server die gedeeld kunnen worden tussen apps. Dit mogen NFS/SSHFS/CIFS koppelingen of op deze server aangesloten externe schijven zijn. Volumes zijn gekoppeld aan de app container onder <code>/media</code>.",
"removeVolumeDialog": {
"removeAction": "Verwijder",
"description": "Hiermee wordt het Volume <code>{{ volume }}</code> verwijderd. De gegevens in het host-pad worden niet verwijderd..",
@@ -1455,31 +1645,34 @@
},
"addVolumeDialog": {
"addAction": "Toevoegen",
"nameWarning": "Cloudron mount dit host-pad in de app's container met deze naam onder <code>/media</code>.",
"nameWarning": "App's hebben toegang to dit Volume via <code>/media</code>.",
"title": "Volume toevoegen",
"server": "Server IP of Hostnaam",
"remoteDirectory": "Externe map",
"username": "Gebruikersnaam",
"password": "Wachtwoord",
"diskPath": "Schijf pad",
"noopWarning": "Cloudron zal de server niet configureren om dit Volume te mounten",
"port": "Poort",
"user": "Gebruiker",
"privateKey": "Private SSH sleutel",
"mountTypeInfo": "Cloudron zal de server configureren om dit Volume te mounten"
"mountTypeInfo": "Cloudron zal de server configureren om dit Volume te koppelen",
"mountpointWarning": "Cloudron zal de server NIET configureren om het Volume automatisch te koppelen"
},
"hostPath": "Host-pad",
"hostPath": "Koppelpunt",
"removeVolumeActionTooltip": "Verwijder Volume",
"openFileManagerActionTooltip": "Open bestandsbeheer",
"name": "Naam",
"addVolumeAction": "Volume toevoegen",
"title": "Volumes",
"mountType": "Mount type",
"mountType": "Koppeltype",
"updateVolumeDialog": {
"title": "Update Volume {{ volume }}"
},
"tooltipEdit": "Bewerk Volume",
"mountStatus": "Mount status"
"mountStatus": "Koppel status",
"localDirectory": "Lokale map",
"type": "Type",
"remountActionTooltip": "Her-koppel Volume"
},
"lang": {
"it": "Italiaans",
@@ -1490,7 +1683,8 @@
"zh_Hans": "Chinees (vereenvoudigd)",
"vi": "Vietnamees",
"pl": "Pools",
"es": "Spaans"
"es": "Spaans",
"ru": "Russisch"
},
"passwordResetEmail": {
"subject": "[<%= cloudron %>] Wachtwoord herstellen",
@@ -1529,7 +1723,21 @@
"errorUsernameTooShort": "De gebruikersnaam is te kort",
"username": "Gebruikersnaam",
"description": "Stel je account in",
"welcomeTo": "Welkom bij"
"welcomeTo": "Welkom bij",
"noUsername": {
"title": "Account kan niet ingesteld worden",
"description": "Account kan niet ingesteld worden zonder gebruikersnaam."
}
},
"lang.ja": "Japans"
"lang.ja": "Japans",
"newLoginEmail": {
"subject": "[<%= cloudron %>] Er is vanaf een nieuwe locatie ingelogd op je account",
"topic": "We zien dat er vanaf een nieuw apparaat/locatie is ingelogd op je account.",
"salutation": "Hallo <%= user %>,",
"notice": "We zien dat er met je account is ingelogd vanaf een nieuwe locatie en/of apparaat.",
"action": "Als jij dit zelf was kun je deze mail verwijderen. Als jij dit niet was verander dan je wachtwoord onmiddellijk."
},
"supportConfig": {
"emailNotVerified": "Controleer je cloudron.io account eerst zodat we zeker zijn dat we je kunnen bereiken."
}
}
File diff suppressed because it is too large Load Diff
+1 -7
View File
@@ -550,12 +550,10 @@
"tabTitle": "Hộp thư",
"port": "Cổng",
"server": "Server",
"loginHelp": "Dùng <i>tenhopthu</i>@{{ domain }} và mật khẩu của chủ hộp thư để truy cập tất cả hộp thư trên tên miền này",
"sieveServerInfo": "Giao thức ManageSieve",
"outgointServerInfo": "Mail gửi ra (SMTP)",
"enableAction": "Bật",
"disableAction": "Tắt",
"description": "<a href=\"{{ emailDocsLink }}\" target=\"_blank\">Mail server</a> của Cloudron cho phép người dùng nhận mail về tên miền này. Các app <a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> đã được cấu hình sẵn để truy cập được vào mail trên Cloudron.",
"title": "Mail đến",
"incomingServerInfo": "Mail đến (IMAP)",
"catchall": {
@@ -566,7 +564,6 @@
}
},
"config": {
"connectionDetails": "Chi tiết kết nối cho những mail client khác",
"title": "Cấu hình email cho {{ domain }}"
},
"backAction": "Trở về mục email",
@@ -690,8 +687,6 @@
"title": "Cấu hình nhà cung cấp IP"
},
"dyndns": {
"saved": "Đã lưu",
"useLabel": "Dùng DNS động",
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
"title": "DNS động"
},
@@ -710,7 +705,6 @@
"interfaceDescription": "Liệt kê những thiết bị hiện hữu trên server với:",
"configure": "Cấu hình",
"interface": "Tên giao diện mạng",
"address": "Địa chỉ IP",
"provider": "Nhà cung cấp",
"description": "Cloudron dùng địa chỉ IP này để cài đặt các bản ghi DNS.",
"title": "Địa chỉ IP"
@@ -1274,7 +1268,7 @@
"enable": "Dùng Mail Cloudron để gửi mail",
"description2": "Khi bật, app sẽ được cấu hình để gửi mail qua mail server nội bộ bằng địa chỉ email này. Mail server nội bộ sẽ dùng phần cài đặt <a href=\"{{ domainConfigLink }}\">Mail gửi ra</a> của {{ domain }} để gửi mail. Khi tắt, bạn có thể tuỳ chỉnh cài đặt mail trong app.",
"disable": "Không cài đặt mail",
"enableDescription": "App được cấu hình để gửi mail bằng địa chỉ email sau và theo cài đặt phần <a href=\\\"{{ domainConfigLink }}\\\">Mail gửi ra</a> của {{ domain }}.",
"enableDescription": "App được cấu hình để gửi mail bằng địa chỉ email sau và theo cài đặt phần <a href=\"{{ domainConfigLink }}\">Mail gửi ra</a> của {{ domain }}.",
"disableDescription": "Các cài đặt email của app chưa được chỉnh. Bạn có thể tuỳ chỉnh trong app."
}
},
+2 -8
View File
@@ -504,7 +504,6 @@
"title": "IP 地址",
"description": "Cloudron 在设置 DNS 记录时会使用这个 IP 地址。",
"provider": "提供商",
"address": "IP 地址",
"interface": "网卡名称",
"configure": "配置",
"interfaceDescription": "这台服务器上可用的设备:",
@@ -512,8 +511,6 @@
},
"dyndns": {
"title": "动态 DNS",
"useLabel": "使用动态 DNS",
"saved": "已保存",
"description": "开启此选项以保证 IP 更换时 DNS 记录同步更新。这项功能帮助 Cloudron 在经常更换 IP 地址的网络环境下运行(如家庭网络)。"
},
"configureIp": {
@@ -971,8 +968,7 @@
"email": {
"backAction": "回到邮件",
"config": {
"title": "{{ domain }} Email 设置",
"connectionDetails": "其他邮件客户端的连接详情"
"title": "{{ domain }} Email 设置"
},
"incoming": {
"title": "入站邮件",
@@ -1000,8 +996,6 @@
},
"disableAction": "停用",
"outgointServerInfo": "发送邮件(SMTP",
"description": "Cloudron 的<a href=\"{{ emailDocsLink }}\" target=\"_blank\">邮件服务器</a>允许用户接收这个域名的邮件。<a href=\"{{ rainloopLink }}\">Rainloop</a>, <a href=\"{{ sogoLink }}\">SOGo</a>, <a href=\"{{ roundcubeLink }}\">Roundcube</a> 已经为使用 Cloudron Email 预先配置好。",
"loginHelp": "使用 <i>mailboxname</i>@{{ domain }} 和邮箱所有者的 Cloudron 密码来登录邮箱",
"incomingServerInfo": "入站邮件(IMAP",
"catchall": {
"saveAction": "保存",
@@ -1252,7 +1246,7 @@
"mailboxPlaceholder": "留空以使用默认值",
"disable": "不配置邮件选项",
"enable": "使用 Cloudron Mail 发送邮件",
"enableDescription": "这个应用被设置为使用下列地址和 {{ domain }} 的 <a href=\\\"{{ domainConfigLink }}\\\">出站邮件</a> 设置。",
"enableDescription": "这个应用被设置为使用下列地址和 {{ domain }} 的 <a href=\"{{ domainConfigLink }}\">出站邮件</a> 设置。",
"disableDescription": "没有为此应用配置邮件。你可以在应用内部的设置里设置邮件选项。",
"description2": "启用后,这个应用会使用这个地址,从内置的邮件服务器发送邮件。内置的邮件服务器会使用 {{ domain }} 的 <a href=\"{{ domainConfigLink }}\">出站邮件</a>设置来发送邮件。如果禁用此选项,你可以在应用里配置邮件选项。"
},
+390 -138
View File
@@ -23,6 +23,52 @@
</div>
</div>
<!-- Modal sftpInfo -->
<div class="modal fade" id="sftpInfoModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#ftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
</div>
<div class="modal-body">
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.accessControl.sftp.server' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ config.adminFqdn }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.accessControl.sftp.port' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>222</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.accessControl.sftp.username' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ user.username }}@{{ app.fqdn }}</span>
</div>
</div>
<br/>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal uninstall app -->
<div class="modal fade" id="uninstallModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -93,15 +139,15 @@
<p ng-bind-html="'app.repairDialog.taskError' | tr:{ task: (app.error.installationState | taskName) }"></p>
<p class="text-danger">{{ app.error.reason + ': ' + app.error.message }}</p>
</div>
<div class="form-group" ng-show="repair.location && repair.domain">
<div class="form-group" ng-show="repair.subdomain && repair.domain">
<p>{{ 'app.repairDialog.domainDescription' | tr }}</p>
<label class="control-label">{{ 'app.repairDialog.location' | tr }}</label>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="repair.location" name="location" placeholder="{{ 'Leave empty to use bare domain' }}" autofocus>
<input type="text" class="form-control" ng-model="repair.subdomain" name="location" placeholder="{{ 'Leave empty to use bare domain' }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>{{ (!repair.location ? '' : '.') + repair.domain.domain }}</span>
<span>{{ '.' + repair.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@@ -120,10 +166,10 @@
</p>
</div>
<div ng-show="repair.alternateDomains.length">
<p ng-repeat="alternateDomain in repair.alternateDomains">
<label class="control-label"><input type="checkbox" ng-model="alternateDomain.enabled">
{{ alternateDomain.subdomain + (!alternateDomain.subdomain ? '' : '.') + alternateDomain.domain.domain }}
<div ng-show="repair.redirectDomains.length">
<p ng-repeat="redirectDomain in repair.redirectDomains">
<label class="control-label"><input type="checkbox" ng-model="redirectDomain.enabled">
{{ redirectDomain.subdomain + (!redirectDomain.subdomain ? '' : '.') + redirectDomain.domain.domain }}
</label>
</p>
</div>
@@ -155,7 +201,7 @@
<p class="text-info">{{ 'app.importBackupDialog.description' | tr }}</p>
<form name="importBackupForm" role="form" novalidate ng-submit="importBackup.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="backups.error">{{ importBackup.error.generic }}</p>
<p class="has-error text-center" ng-show="importBackup.error">{{ importBackup.error.generic }}</p>
<div class="form-group">
<label class="control-label" for="storageProvider">{{ '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>
@@ -169,9 +215,9 @@
</div>
<!-- S3/Minio/SOS/GCS -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.endpoint }" ng-show="importBackup.provider === 'minio' || importBackup.provider === 'backblaze-b2' || importBackup.provider === 's3-v4-compat'">
<div class="form-group" ng-class="{ 'has-error': importBackup.error.endpoint }" ng-show="importBackup.provider === 'minio' || importBackup.provider === 'upcloud-objectstorage' || importBackup.provider === 'backblaze-b2' || importBackup.provider === 's3-v4-compat'">
<label class="control-label" for="inputimportBackupEndpoint">{{ 'backups.configureBackupStorage.s3Endpoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.endpoint" id="inputimportBackupEndpoint" name="endpoint" ng-disabled="importBackup.busy" placeholder="URL" ng-required="importBackup.provider === 'minio' || importBackup.provider === 'backblaze-b2' || importBackup.provider === 's3-v4-compat'">
<input type="text" class="form-control" ng-model="importBackup.endpoint" id="inputimportBackupEndpoint" name="endpoint" ng-disabled="importBackup.busy" placeholder="URL" ng-required="importBackup.provider === 'minio' || importBackup.provider === 'upcloud-objectstorage'|| importBackup.provider === 'backblaze-b2' || importBackup.provider === 's3-v4-compat'">
</div>
<div class="checkbox" ng-show="importBackup.provider === 'minio' || importBackup.provider === 's3-v4-compat'" >
@@ -185,10 +231,52 @@
<input type="text" class="form-control" ng-model="importBackup.bucket" id="inputImportBackupBucket" name="bucket" ng-disabled="importBackup.busy" ng-required="s3like(importBackup.provider)">
</div>
<!-- SSHFS/CIFS/NFS -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.mountPoint }" ng-show="mountlike(importBackup.provider)">
<!-- mountpoint -->
<div class="form-group" ng-class="{ 'has-error': importBackup.error.mountPoint }" ng-show="importBackup.provider === 'mountpoint'">
<label class="control-label" for="inputConfigureMountPoint">{{ 'backups.configureBackupStorage.mountPoint' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="mountlike(importBackup.provider)">
<input type="text" class="form-control" ng-model="importBackup.mountPoint" id="inputConfigureMountPoint" name="mountPoint" ng-disabled="importBackup.busy" placeholder="Folder where filesystem is mounted" ng-required="importBackup.provider === 'mountpoint'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupHost">{{ 'backups.configureBackupStorage.server' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.host" id="configureBackupHost" name="host" ng-disabled="importBackup.busy" placeholder="Server IP or hostname" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- CIFS/NFS/SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupRemoteDir">{{ 'backups.configureBackupStorage.remoteDirectory' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.remoteDir" id="configureBackupRemoteDir" name="remoteDir" ng-disabled="importBackup.busy" placeholder="/share" ng-required="importBackup.provider === 'cifs' || importBackup.provider === 'nfs' || importBackup.provider === 'sshfs'">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupUsername">{{ 'backups.configureBackupStorage.username' | tr }} ({{ importBackup.provider }})</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.username" id="configureBackupUsername" name="username" ng-disabled="importBackup.busy">
</div>
<!-- CIFS -->
<div class="form-group" ng-show="importBackup.provider === 'cifs'">
<label class="control-label" for="configureBackupPassword">{{ 'backups.configureBackupStorage.password' | tr }} ({{ importBackup.provider }})</label>
<input type="password" class="form-control" ng-model="importBackup.mountOptions.password" id="configureBackupPassword" name="password" ng-disabled="importBackup.busy" password-reveal>
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPort">{{ 'backups.configureBackupStorage.port' | tr }}</label>
<input type="number" class="form-control" ng-model="importBackup.mountOptions.port" id="configureBackupPort" name="port" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupUser">{{ 'backups.configureBackupStorage.user' | tr }}</label>
<input type="text" class="form-control" ng-model="importBackup.mountOptions.user" id="configureBackupUser" name="user" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'">
</div>
<!-- SSHFS -->
<div class="form-group" ng-show="importBackup.provider === 'sshfs'">
<label class="control-label" for="configureBackupPrivateKey">{{ 'backups.configureBackupStorage.privateKey' | tr }}</label>
<textarea class="form-control" ng-model="importBackup.mountOptions.privateKey" id="configureBackupPrivateKey" name="privateKey" ng-disabled="importBackup.busy" ng-required="importBackup.provider === 'sshfs'"></textarea>
</div>
<!-- Filesystem -->
@@ -343,7 +431,7 @@
</div>
<!-- Modal clone app -->
<div class="modal fade" id="cloneModal" tabindex="-1" role="dialog">
<div class="modal fade" id="appCloneModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
@@ -353,14 +441,14 @@
<p ng-bind-html="'app.cloneDialog.description' | tr:{ creationTime: (clone.backup.creationTime | prettyDate), packageVersion: clone.backup.packageVersion }"></p>
<form role="form" ng-submit="clone.submit()" autocomplete="off">
<fieldset>
<div class="form-group" ng-class="{ 'has-error': clone.error.location }">
<div class="form-group" ng-class="{ 'has-error': clone.error.location.fqdn === clone.subdomain + '.' + clone.domain.domain }">
<label class="control-label" for="cloneLocationInput">{{ 'app.cloneDialog.location' | tr }}</label>
<div ng-show="clone.error.location"><small>{{ clone.error.location }}</small></div>
<div ng-show="clone.error.location.fqdn === clone.subdomain + '.' + clone.domain.domain"><small>{{ clone.error.location.message }}</small></div>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="clone.location" id="cloneLocationInput" name="location" placeholder="{{ 'appstore.installDialog.locationPlaceholder' | tr }}" autofocus>
<input type="text" class="form-control" ng-model="clone.subdomain" id="cloneLocationInput" 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>{{ (!clone.location ? '' : '.') + clone.domain.domain }}</span>
<span>{{ '.' + clone.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@@ -372,8 +460,38 @@
</div>
</div>
<p class="text-small text-warning" ng-show="clone.domain.provider === 'linode'" ng-bind-html="'appstore.installDialog.linodeWarning' | tr:{ linodeDocsLink: 'https://docs.cloudron.io/domains/#linode-dns' }"></p>
<p class="text-small text-warning" ng-show="clone.location && clone.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: (appInstall.location + '.' + appInstall.domain.domain) }"></p>
<div class="has-error text-center" ng-show="clone.error.secondaryDomain">{{ clone.error.secondaryDomain }}</div>
<div ng-repeat="(env, info) in app.manifest.httpPorts">
<ng-form name="secondaryDomainInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!secondaryDomainInfo_form.itemName{{$index}}.$dirty && clone.error.secondaryDomain) || (secondaryDomainInfo_form.itemName{{$index}}.$dirty && secondaryDomainInfo_form.itemName{{$index}}.$invalid) || (clone.error.location.fqdn === clone.secondaryDomains[env].subdomain + '.' + clone.secondaryDomains[env].domain.domain) }">
<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 ng-show="clone.error.location.fqdn === clone.secondaryDomains[env].subdomain + '.' + clone.secondaryDomains[env].domain.domain"><small>{{ clone.error.location.message }}</small></div>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="clone.secondaryDomains[env].subdomain" name="location{{$index}}" placeholder="{{ 'app.location.locationPlaceholder' | tr }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>.{{ clone.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="clone.secondaryDomains[env].domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
</ng-form>
</div>
<p class="text-small text-warning" ng-show="clone.domain.provider === 'noop' || clone.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((clone.subdomain ? clone.subdomain + '.' : '') + clone.domain.domain) }"></p>
<div class="has-error text-center" ng-show="clone.error.port">{{ clone.error.port }}</div>
<div ng-repeat="(env, info) in clone.portBindingsInfo">
@@ -386,6 +504,7 @@
</sup>
</label>
<input type="number" class="form-control" ng-model="clone.portBindings[env]" ng-disabled="!clone.portBindingsEnabled[env]" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
<p class="text-small text-warning text-bold" ng-show="clone.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
</div>
@@ -395,7 +514,7 @@
<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="clone.submit()"><i class="far fa-clone" ng-hide="clone.busy"></i><i class="fa fa-circle-notch fa-spin" ng-show="clone.busy"></i> {{ 'app.cloneDialog.cloneAction' | tr }}</button>
<button type="button" class="btn btn-success" ng-click="clone.submit()"><i class="far fa-clone" ng-hide="clone.busy"></i><i class="fa fa-circle-notch fa-spin" ng-show="clone.busy"></i> {{ 'app.cloneDialog.cloneAction' | tr:{ dnsOverwrite: clone.needsOverwrite } }}</button>
</div>
</div>
</div>
@@ -424,7 +543,7 @@
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-sm btn-default" ng-href="{{ '/logs.html?appId=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.logsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-align-left"></i></a>
<a class="btn btn-sm btn-default" ng-href="{{ '/terminal.html?id=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.terminalActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fa fa-terminal"></i></a>
<a class="btn btn-sm btn-default" ng-href="{{ '/filemanager.html?appId=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a>
<a class="btn btn-sm btn-default" ng-href="{{ '/filemanager.html?type=app&id=' + app.id }}" target="_blank" uib-tooltip="{{ 'app.filemanagerActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom"><i class="fas fa-folder"></i></a>
</div>
<div class="dropdown" style="display: inline-block">
<button class="btn btn-sm btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'app.docsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom">
@@ -432,9 +551,12 @@
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li ng-class="{ 'disabled': !app.manifest.postInstallMessage }"><a href="" ng-click="postInstallMessage.show(false)">{{ 'app.firstTimeSetupAction' | tr }}</a></li>
<li ng-class="{ 'disabled': !app.manifest.documentationUrl }"><a ng-href="{{ app.manifest.documentationUrl }}" target="_blank">{{ 'app.docsAction' | tr }}</a></li>
<li ng-class="{ 'disabled': (!app.manifest.configurePath || !(app | applicationLink)) }"><a ng-href="{{ (app.manifest.configurePath && (app | applicationLink)) ? ((app | applicationLink) + app.manifest.configurePath) : ''}}" target="_blank">{{ 'app.adminPageAction' | tr }}</a></li>
<li ng-show="app.manifest.postInstallMessage"><a href="" ng-click="postInstallMessage.show(false)">{{ 'app.firstTimeSetupAction' | tr }}</a></li>
<li ng-show="app.manifest.configurePath && (app | applicationLink)"><a ng-href="{{ (app.manifest.configurePath && (app | applicationLink)) ? ((app | applicationLink) + app.manifest.configurePath) : ''}}" target="_blank">{{ 'app.adminPageAction' | tr }}</a></li>
<li ng-show="app.manifest.addons.localstorage.ftp"><a href="" ng-click="sftpInfo.show()">{{ 'app.sftpInfoAction' | tr }}</a></li>
<li role="separator" class="divider"></li>
<li ng-class="{ 'disabled': !app.manifest.forumUrl }"><a ng-href="{{ app.manifest.forumUrl }}" target="_blank">{{ 'app.forumUrlAction' | tr }}</a></li>
<li role="separator" class="divider"></li>
<li ng-class="{ 'disabled': !app.manifest.website }"><a ng-href="{{ app.manifest.website }}" target="_blank">{{ 'app.projectWebsiteAction' | tr }}</a></li>
</ul>
@@ -466,17 +588,19 @@
<div class="col-sm-2">
<div class="app-configure-links">
<div ng-click="setView('display')" ng-class="{ 'active': view === 'display' }">{{ 'app.displayTabTitle' | tr }}</div>
<div ng-click="setView('location')" ng-class="{ 'active': view === 'location' }">{{ 'app.locationTabTitle' | tr }}</div>
<div ng-click="setView('access')" ng-class="{ 'active': view === 'access' }">{{ 'app.accessControlTabTitle' | tr }}</div>
<div ng-click="setView('location')" ng-class="{ 'active': view === 'location' }" ng-show="app.accessLevel === 'admin'">{{ 'app.locationTabTitle' | tr }}</div>
<div ng-click="setView('access')" ng-class="{ 'active': view === 'access' }" ng-show="app.accessLevel === 'admin'">{{ 'app.accessControlTabTitle' | tr }}</div>
<div ng-click="setView('resources')" ng-class="{ 'active': view === 'resources' }">{{ 'app.resourcesTabTitle' | tr }}</div>
<div ng-click="setView('storage')" ng-class="{ 'active': view === 'storage' }">{{ 'app.storageTabTitle' | tr }}</div>
<div ng-click="setView('storage')" ng-class="{ 'active': view === 'storage' }" ng-show="app.accessLevel === 'admin'">{{ 'app.storageTabTitle' | tr }}</div>
<div ng-click="setView('graphs')" ng-class="{ 'active': view === 'graphs' }">{{ 'app.graphsTabTitle' | tr }}</div>
<div ng-click="setView('security')" ng-class="{ 'active': view === 'security' }">{{ 'app.securityTabTitle' | tr }}</div>
<div ng-click="setView('email')" ng-class="{ 'active': view === 'email' }" ng-show="app.manifest.addons.sendmail || app.manifest.addons.recvmail">{{ 'app.emailTabTitle' | tr }}</div>
<div ng-click="setView('email')" ng-class="{ 'active': view === 'email' }" ng-show="app.accessLevel === 'admin' && (app.manifest.addons.sendmail || app.manifest.addons.recvmail)">{{ 'app.emailTabTitle' | tr }}</div>
<div ng-click="setView('cron')" ng-class="{ 'active': view === 'cron' }">{{ 'app.cronTabTitle' | tr }}</div>
<div ng-click="setView('updates')" ng-class="{ 'active': view === 'updates' }">{{ 'app.updatesTabTitle' | tr }}</div>
<div ng-click="setView('backups')" ng-class="{ 'active': view === 'backups' }">{{ 'app.backupsTabTitle' | tr }}</div>
<div ng-click="setView('repair')" ng-class="{ 'active': view === 'repair' }">{{ 'app.repairTabTitle' | tr }}</div>
<div ng-click="setView('uninstall')" ng-class="{ 'active': view === 'uninstall' }">{{ 'app.uninstallTabTitle' | tr }}</div>
<div ng-click="setView('eventlog')" ng-class="{ 'active': view === 'eventlog' }">{{ 'app.eventlogTabTitle' | tr }}</div>
<div ng-click="setView('uninstall')" ng-class="{ 'active': view === 'uninstall' }" ng-show="app.accessLevel === 'admin'">{{ 'app.uninstallTabTitle' | tr }}</div>
</div>
</div>
<div class="col-sm-8 card-container">
@@ -527,11 +651,11 @@
<div class="has-error" ng-show="location.error.location">{{ location.error.location }}</div>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="location.location" name="location" placeholder="{{ 'app.location.locationPlaceholder' | tr }}" autofocus>
<input type="text" class="form-control" ng-model="location.subdomain" name="location" placeholder="{{ 'app.location.locationPlaceholder' | tr }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>{{ (!location.location ? '' : '.') + location.domain.domain }}</span>
<span>{{ '.' + location.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@@ -543,15 +667,45 @@
</div>
</div>
<p class="text-small text-bold text-warning" ng-show="location.location && location.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: (location.location + '.' + location.domain.domain) }"></p>
<p class="text-small text-bold text-warning" ng-show="location.domain.provider === 'noop' || location.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: ((location.subdomain ? location.subdomain + '.' : '') + location.domain.domain) }"></p>
<!-- hidden submit has to be prior to other button elements, otherwise firefox will treat them as the "enter" key action, in this case the alternate domain delete button! -->
<input class="ng-hide" type="submit" ng-disabled="locationForm.$invalid || location.busy"/>
<div class="has-error text-center" ng-show="location.error.secondaryDomain">{{ location.error.secondaryDomain }}</div>
<div ng-repeat="(env, info) in app.manifest.httpPorts">
<ng-form name="secondaryDomainInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!secondaryDomainInfo_form.itemName{{$index}}.$dirty && location.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="location.secondaryDomains[env].subdomain" name="location{{$index}}" placeholder="{{ 'app.location.locationPlaceholder' | tr }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>.{{ location.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="location.secondaryDomains[env].domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
</ng-form>
</div>
<div class="has-error text-center" ng-show="location.error.port">{{ location.error.port }}</div>
<div ng-repeat="(env, info) in location.portBindingsInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!locationForm.itemName{{$index}}.$dirty && location.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<div class="form-group" ng-class="{ 'has-error': (!portInfo_form.itemName{{$index}}.$dirty && location.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portBindingsEnabled[env]">
{{ info.title }}
<sup>
@@ -559,6 +713,7 @@
</sup>
</label>
<input type="number" class="form-control" ng-model="location.portBindings[env]" ng-disabled="!location.portBindingsEnabled[env]" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
</div>
</ng-form>
</div>
@@ -569,12 +724,12 @@
<div class="row" ng-repeat="aliasDomain in location.aliasDomains">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control" ng-model="aliasDomain.subdomain" placeholder="{{ 'app.location.aliasesPlaceholder' | tr }}">
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="aliasDomainsInput-{{ $index }}" ng-model="aliasDomain.subdomain" placeholder="{{ 'app.location.aliasesPlaceholder' | tr }}">
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>{{ (!aliasDomain.subdomain ? '' : '.') + aliasDomain.domain.domain }}</span>
<span>.{{ aliasDomain.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@@ -593,34 +748,34 @@
<div style="margin-top: 5px;"><a href="" ng-click="location.addAliasDomain($event)">{{ 'app.location.addAliasAction' | tr }}</a></div>
</div>
<div class="form-group alternate-domains">
<div class="form-group redirect-domains">
<label class="control-label">{{ 'app.location.redirections' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#redirections" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="has-error" ng-show="location.error.alternateDomains">{{ location.error.alternateDomains }}</div>
<div class="has-error" ng-show="location.error.redirectDomains">{{ location.error.redirectDomains }}</div>
<div class="row" ng-repeat="alternateDomain in location.alternateDomains">
<div class="row" ng-repeat="redirectDomain in location.redirectDomains">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control" ng-model="alternateDomain.subdomain" placeholder="{{ 'app.location.redirectionsPlaceholder' | tr }}">
<div class="input-group input-group-sm">
<input type="text" class="form-control" id="redirectDomainsInput-{{ $index }}" ng-model="redirectDomain.subdomain" placeholder="{{ 'app.location.redirectionsPlaceholder' | tr }}">
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<span>{{ (!alternateDomain.subdomain ? '' : '.') + alternateDomain.domain.domain }}</span>
<span>.{{ redirectDomain.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="alternateDomain.domain = domain">{{ domain.domain }}</a>
<a href="" ng-click="redirectDomain.domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
<div class="col col-lg-1">
<button class="btn btn-danger btn-sm" ng-click="location.delAlternateDomain($event, $index)"><i class="far fa-trash-alt"></i></button>
<button class="btn btn-danger btn-sm" ng-click="location.delRedirectDomain($event, $index)"><i class="far fa-trash-alt"></i></button>
</div>
</div>
<div ng-show="location.alternateDomains.length === 0">{{ 'app.location.noRedirections' | tr }}</div>
<div style="margin-top: 5px;"><a href="" ng-click="location.addAlternateDomain($event)">{{ 'app.location.addRedirectionAction' | tr }}</a></div>
<div ng-show="location.redirectDomains.length === 0">{{ 'app.location.noRedirections' | tr }}</div>
<div style="margin-top: 5px;"><a href="" ng-click="location.addRedirectDomain($event)">{{ 'app.location.addRedirectionAction' | tr }}</a></div>
</div>
</form>
</div>
@@ -638,98 +793,82 @@
<div class="row">
<div class="col-md-12">
<form role="form" name="accessForm" ng-submit="access.submit()" autocomplete="off">
<div class="form-group">
<div class="form-group" ng-show="app.manifest.addons.email">
<label class="control-label">{{ 'app.accessControl.userManagement.title' | 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="app.manifest.addons.email">
<label class="control-label">{{ 'app.accessControl.userManagement.title' | tr }}</label>
<p>{{ 'appstore.installDialog.userManagementMailbox' | tr }}
<span ng-bind-html="'appstore.installDialog.configuredForCloudronEmail' | tr:{ emailDocsLink: 'https://docs.cloudron.io/email/' }">
</p>
</div>
<div ng-show="access.ssoAuth && !app.manifest.addons.email">
<label class="control-label">{{ 'app.accessControl.userManagement.title' | 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>
<p>{{ 'app.accessControl.userManagement.description' | tr }}</p>
</div>
<div ng-show="!access.ssoAuth || app.manifest.addons.email">
<label class="control-label">{{ '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>
<p ng-show="!app.manifest.addons.email">{{ 'appstore.installDialog.userManagementNone' | tr }}</p>
</div>
<div ng-show="access.ssoAuth && !app.manifest.addons.email">
<label class="control-label">{{ 'app.accessControl.userManagement.title' | 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>
<p>{{ 'app.accessControl.userManagement.description' | tr }} <span class="text-small text-warning" ng-show="access.ftp">{{ 'app.accessControl.userManagement.descriptionSftp' | tr }}</span></p>
</div>
<div ng-show="!access.ssoAuth || app.manifest.addons.email">
<label class="control-label">{{ '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>
<p ng-show="!app.manifest.addons.email">{{ 'appstore.installDialog.userManagementNone' | tr }} <span ng-show="access.ftp">{{ 'app.accessControl.userManagement.sftpAccessControl' | tr }}</span></p>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="access.accessRestrictionOption" value="any">
<span ng-show="access.ssoAuth">{{ 'appstore.installDialog.userManagementAllUsers' | tr }}</span>
<span ng-show="!access.ssoAuth">{{ 'app.accessControl.userManagement.visibleForAllUsers' | tr }}</span>
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="access.accessRestrictionOption" value="groups">
<div class="radio">
<label>
<input type="radio" ng-model="access.accessRestrictionOption" value="any">
<span ng-show="access.ssoAuth">{{ 'appstore.installDialog.userManagementAllUsers' | tr }}</span>
<span ng-show="!access.ssoAuth">{{ 'app.accessControl.userManagement.visibleForAllUsers' | tr }}</span>
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="access.accessRestrictionOption" value="groups">
<span ng-show="access.ssoAuth">{{ 'appstore.installDialog.userManagementSelectUsers' | tr }}</span>
<span ng-show="!access.ssoAuth">{{ 'app.accessControl.userManagement.visibleForSelected' | tr }}</span>
<span ng-show="access.ssoAuth">{{ 'appstore.installDialog.userManagementSelectUsers' | tr }}</span>
<span ng-show="!access.ssoAuth">{{ 'app.accessControl.userManagement.visibleForSelected' | tr }}</span>
<span class="label label-danger" ng-show="access.accessRestrictionOption === 'groups' && !access.isAccessRestrictionValid()">{{ 'appstore.installDialog.errorUserManagementSelectAtLeastOne' | tr }}</span>
</label>
</div>
<div>
<div style="margin-left: 20px;">
<div class="col-md-5">
{{ 'appstore.installDialog.users' | tr }}: <multiselect name="accessUsersSelect" class="input-sm stretch" ng-model="access.accessRestriction.users" ng-disabled="access.accessRestrictionOption !== 'groups'" options="user.display for user in users" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
<span class="label label-danger" ng-show="access.accessRestrictionOption === 'groups' && !access.isAccessRestrictionValid()">{{ 'appstore.installDialog.errorUserManagementSelectAtLeastOne' | tr }}</span>
</label>
</div>
<div>
<div style="margin-left: 20px;">
<div class="col-md-5">
{{ 'appstore.installDialog.users' | tr }}: <multiselect class="input-sm stretch" ng-model="access.accessRestriction.users" ng-disabled="access.accessRestrictionOption !== 'groups'" options="user.display for user in users" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
<div class="col-md-5">
{{ 'appstore.installDialog.groups' | tr }}: <multiselect class="input-sm stretch" ng-model="access.accessRestriction.groups" ng-disabled="access.accessRestrictionOption !== 'groups'" options="group.name for group in groups" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
<div class="col-md-5">
{{ 'appstore.installDialog.groups' | tr }}: <multiselect name="accessGroupsSelect" class="input-sm stretch" ng-model="access.accessRestriction.groups" ng-disabled="access.accessRestrictionOption !== 'groups'" options="group.name for group in groups" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="(access.accessRestrictionOption === 'groups' && !access.isAccessRestrictionValid()) || accessForm.$invalid || access.busy"/>
</div>
<br/>
<br/>
<br/>
<div>
<label class="control-label">{{ 'app.accessControl.operators.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#operators" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<p>{{ 'app.accessControl.operators.description' | tr }} <span ng-show="access.ftp">{{ 'app.accessControl.userManagement.descriptionSftp' | tr }}</span></p>
</div>
<div>
<div style="margin-left: 20px;">
<div class="col-md-5">
{{ 'appstore.installDialog.users' | tr }}: <multiselect name="operatorsUsersSelect" class="input-sm stretch" ng-model="access.operators.users" options="user.display for user in users" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
<div class="col-md-5">
{{ 'appstore.installDialog.groups' | tr }}: <multiselect name="operatorsGroupsSelect" class="input-sm stretch" ng-model="access.operators.groups" options="group.name for group in groups" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="(access.accessRestrictionOption === 'groups' && !access.isAccessRestrictionValid()) || accessForm.$invalid || access.busy"/>
</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="access.submit()" ng-disabled="(access.accessRestrictionOption === 'groups' && !access.isAccessRestrictionValid()) || access.$invalid || access.busy"><i class="fa fa-circle-notch fa-spin" ng-show="access.busy"></i> {{ 'main.dialog.save' | tr }}</button>
</div>
</div>
<div class="row" ng-show="app.manifest.addons.localstorage.ftp">
<hr/>
<div class="col-md-12">
<label>{{ 'app.accessControl.sftp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#ftp-access" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<br/>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.accessControl.sftp.server' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ config.adminFqdn }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.accessControl.sftp.port' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>222</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'app.accessControl.sftp.username' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ user.username }}@{{ app.fqdn }}</span>
</div>
</div>
</div>
</div>
</div>
<div class="card" ng-show="view === 'resources'">
@@ -787,7 +926,7 @@
<div class="card" ng-show="view === 'storage'">
<div class="row">
<div class="col-md-12">
<label class="control-label">{{ 'app.storage.appdata.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/storage/#app-data-directory" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<label class="control-label">{{ 'app.storage.appdata.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#data-directory" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<p ng-bind-html="'app.storage.appdata.description' | tr:{ storagePath: ('/home/yellowtent/appsdata/' + app.id) }"></p>
<form role="form" name="storageDataDirForm" ng-submit="storage.submitDataDir()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': storageDataDirForm.$dirty && storage.error.dataDir }">
@@ -873,7 +1012,7 @@
</div>
<div class="card" ng-show="view === 'email'">
<div class="row">
<div class="row" ng-show="app.manifest.addons.sendmail">
<div class="col-md-12">
<label class="control-label">{{ 'app.email.from.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#mail-from-address" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
@@ -886,10 +1025,10 @@
<div ng-style="{ 'padding-left': app.manifest.addons.sendmail.optional ? '20px' : '0' }">
<p ng-bind-html="'app.email.from.enableDescription' | tr:{ domain: app.domain, domainConfigLink: ('/#/email/' + app.domain) }"></p>
<form role="form" name="emailForm" ng-submit="email.submit()" autocomplete="off">
<form role="form" name="emailForm" ng-submit="email.submitMailbox()" autocomplete="off">
<fieldset ng-disabled="email.enableMailbox === '0'">
<div class="form-group" ng-class="{ 'has-error': emailForm.$dirty && email.error.mailboxName }">
<div ng-show="email.error.mailboxName">{{ email.error.mailboxName }}</div>
<div class="has-error" ng-show="email.error.mailboxName">{{ email.error.mailboxName }}</div>
<div class="input-group form-inline" ng-class="{ 'has-error': !emailForm.mailboxName.$dirty && email.error.mailboxName }">
<input type="text" class="form-control" name="mailboxName" placeholder="{{ 'app.email.from.mailboxPlaceholder' | tr }}" ng-model="email.mailboxName">
@@ -909,7 +1048,7 @@
<br/>
</div>
</fieldset>
<input class="ng-hide" type="submit" ng-disabled="(email.currentMailboxDomainName === email.mailboxDomain.domain && email.currentMailboxName === email.mailboxName) || email.busy || app.error || app.taskId"/>
<input class="ng-hide" type="submit" ng-disabled="(email.currentMailboxDomainName === email.mailboxDomain.domain && email.currentMailboxName === email.mailboxName) || email.mailboxBusy || app.error || app.taskId"/>
</form>
</div>
@@ -924,14 +1063,119 @@
</div>
</div>
</div>
<div class="row">
<div class="row" ng-show="app.manifest.addons.sendmail">
<div class="col-md-12 text-right">
<br/>
<button class="btn btn-outline btn-primary pull-right" ng-click="email.submit()" ng-disabled="(app.enableMailbox === email.enableMailbox && email.currentMailboxDomainName === email.mailboxDomain.domain && email.currentMailboxName === email.mailboxName) || email.busy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="email.busy"></i> {{ 'app.email.from.saveAction' | tr }}
<button class="btn btn-outline btn-primary pull-right" ng-click="email.submitMailbox()" ng-disabled="(app.enableMailbox === email.enableMailbox && email.currentMailboxDomainName === email.mailboxDomain.domain && email.currentMailboxName === email.mailboxName) || email.mailboxBusy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="email.mailboxBusy"></i> {{ 'app.email.from.saveAction' | tr }}
</button>
</div>
</div>
<hr ng-show="app.manifest.addons.sendmail && app.manifest.addons.recvmail"/>
<div class="row" ng-show="app.manifest.addons.recvmail">
<div class="col-md-12">
<label class="control-label">{{ 'app.email.inbox.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#inbox" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<div class="radio">
<label>
<input type="radio" ng-model="email.enableInbox" ng-value="true"> {{ 'app.email.inbox.enable' | tr }}
</label>
</div>
<div ng-style="{ 'padding-left': '20px' }">
<p ng-bind-html="'app.email.inbox.enableDescription' | tr:{ domain: app.domain, domainConfigLink: ('/#/email/' + app.domain) }"></p>
<div class="form-group" ng-class="{ 'has-error': email.error.inboxName }">
<div class="has-error" ng-show="email.error.inboxName">{{ email.error.inboxName }}</div>
<multiselect name="inboxSelect" ng-model="email.inbox" ng-disabled="!email.enableInbox" options="inbox.display for inbox in email.inboxes" data-multiple="false" filter-after-rows="5" scroll-after-rows="10"></multiselect>
</div>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="email.enableInbox" ng-value="false"> {{ 'app.email.inbox.disable' | tr }}
</label>
</div>
<div style="padding-left: 20px;">
<p>{{ 'app.email.inbox.disableDescription' | tr }}</p>
</div>
</div>
</div>
<div class="row" ng-show="app.manifest.addons.recvmail">
<div class="col-md-12 text-right">
<br/>
<button class="btn btn-outline btn-primary pull-right" ng-click="email.submitInbox()" ng-disabled="(email.enableInbox && !email.inbox) || (app.enableInbox === email.enableInbox && email.currentInbox.name === email.inbox.name && email.currentInbox.domain === email.inbox.domain) || email.inboxBusy || app.error || app.taskId" tooltip-enable="app.error || app.taskId" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is busy' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="email.inboxBusy"></i> {{ 'app.email.from.saveAction' | tr }}
</button>
</div>
</div>
</div>
<div class="card" ng-show="view === 'cron'">
<div class="row">
<div class="col-md-12">
<form role="form" name="cronForm" ng-submit="cron.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': cron.error.crontab }">
<label class="control-label" style="width: 100%">{{ 'app.cron.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#cron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<div class="dropdown pull-right">
<a class="dropdown-toggle hand" style="font-weight: normal;" id="commonCronPatternDropdown" data-toggle="dropdown" aria-haspopup="true" aria-expanded="true">
{{ 'app.cron.addCommonPattern' | tr }}
<span class="caret"></span>
</a>
<ul class="dropdown-menu" aria-labelledby="commonCronPatternDropdown">
<li ng-repeat="pattern in cron.commonPatterns"><a class="hand" ng-click="cron.addCommonPattern(pattern.value)">{{ pattern.label }}</a></li>
</ul>
</div>
</label>
<p>{{ 'app.cron.description' | tr }}</p>
<div ng-show="cron.error.crontab"><small>{{ cron.error.crontab }}</small></div>
<textarea ng-trim="false" style="white-space: pre-wrap" ng-model="cron.crontab" class="form-control text-monospace" rows="10"></textarea>
</div>
<input class="ng-hide" type="submit" ng-disabled="cronForm.$invalid || cron.busy"/>
</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="cron.submit()" ng-disabled="cron.$invalid || cron.busy || app.error" tooltip-enable="app.error" uib-tooltip="App is in error state">
<i class="fa fa-circle-notch fa-spin" ng-show="cron.busy"></i> {{ 'app.cron.saveAction' | tr }}
</button>
</div>
</div>
</div>
<div class="card" ng-show="view === 'eventlog'">
<div class="row">
<div class="col-md-12">
<center ng-show="eventlog.busy"><h2><i class="fa fa-circle-notch fa-spin"></i></h2></center>
<table ng-hide="eventlog.busy" class="table table-condensed table-hover">
<thead>
<tr>
<th class="col-md-3">{{ 'eventlog.time' | tr }}</th> <!-- "minutes ago" takes space -->
<th class="col-md-7">{{ 'eventlog.details' | tr }}</th>
<th class="col-md-2" style="text-align: right;">
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showPrevPage()" ng-disabled="eventlog.busy || eventlog.currentPage <= 1"><i class="fa fa-angle-double-left"></i></button>
<button class="btn btn-xs btn-default btn-outline" ng-click="eventlog.showNextPage()" ng-disabled="eventlog.busy || eventlog.perPage > eventlog.eventLogs.length"><i class="fa fa-angle-double-right"></i></button>
</th>
</tr>
</thead>
<tbody ng-repeat="eventLog in eventlog.eventLogs">
<tr ng-click="eventlog.showDetails(eventLog)" class="hand">
<td><span uib-tooltip="{{ eventLog.raw.creationTime | prettyLongDate }}" class="arrow">{{ eventLog.raw.creationTime | prettyDate }}</span></td>
<td colspan="2" ng-bind-html="eventLog.details"></td>
</tr>
<tr ng-show="eventlog.activeEventLog === eventLog">
<td colspan="3"><pre class="eventlog-details">{{ eventLog.raw.data | json }}</pre></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card" ng-show="view === 'security'">
@@ -940,13 +1184,13 @@
<form role="form" name="securityForm" ng-submit="security.submit()" autocomplete="off">
<div class="form-group">
<label class="control-label" style="width: 100%">{{ 'app.security.robots.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#robotstxt" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> <a href="" class="pull-right" style="font-weight: normal;" ng-click="security.robotsTxt = ROBOTS_DISABLE_INDEXING_TEMPLATE">{{ 'app.security.robots.disableIndexingAction' | tr }}</a></label>
<textarea ng-trim="false" style="white-space: pre-wrap" ng-model="security.robotsTxt" placeholder="{{ 'app.security.robots.txtPlaceholder' | tr }}" class="form-control" rows="4"></textarea>
<textarea ng-trim="false" style="white-space: pre-wrap" ng-model="security.robotsTxt" placeholder="{{ 'app.security.robots.txtPlaceholder' | tr }}" class="form-control text-monospace" rows="4"></textarea>
</div>
<div class="form-group">
<label class="control-label" style="width: 100%">{{ 'app.security.csp.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/apps/#custom-csp" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> </label>
<p>{{ 'app.security.csp.description' | tr }}</p>
<textarea ng-model="security.csp" placeholder="default-src 'self'; frame-ancestors 'none';" class="form-control" rows="2"></textarea>
<textarea ng-model="security.csp" placeholder="default-src 'self'; frame-ancestors 'none';" class="form-control text-monospace" rows="2"></textarea>
</div>
<input class="ng-hide" type="submit" ng-disabled="securityForm.$invalid || security.busy"/>
@@ -1011,7 +1255,7 @@
<span ng-show="!app.appStoreId" class="text-danger pull-right">{{ 'app.updates.info.customAppUpdateInfo' | tr }}</span>
</div>
<div class="col-md-12" ng-show="config.update[app.id].manifest.version && config.update[app.id].manifest.version !== app.manifest.version && app.installationState !== 'pending_update'">
<button type="button" class="btn btn-success pull-right" ng-click="updates.askUpdate()" ng-disabled="app.taskId || app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updates.info.updateAvailableAction' | tr }}</button>
<button type="button" class="btn pull-right" ng-class="config.update[app.id].unstable ? 'btn-danger' : 'btn-success'" ng-click="updates.askUpdate()" ng-disabled="app.taskId || app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">{{ 'app.updates.info.updateAvailableAction' | tr }}</button>
</div>
</div>
<hr/>
@@ -1049,9 +1293,9 @@
<td><div>v{{ backup.packageVersion }}</div></td>
<td><div uib-tooltip="{{ backup.creationTime | prettyLongDate }}">{{ backup.creationTime | prettyDate }}</div></td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="downloadConfig(backup)" uib-tooltip="{{ 'app.backups.backups.downloadConfigTooltip' | tr }}"><i class="fas fa-file-alt"></i></button>
<button class="btn btn-xs btn-default" ng-show="app.accessLevel === 'admin'" ng-click="downloadConfig(backup)" uib-tooltip="{{ 'app.backups.backups.downloadConfigTooltip' | tr }}"><i class="fas fa-file-alt"></i></button>
<button class="btn btn-xs btn-default" ng-click="clone.show(backup)" uib-tooltip="{{ 'app.backups.backups.cloneTooltip' | tr }}"><i class="far fa-clone"></i></button>
<button class="btn btn-xs btn-default" ng-show="app.accessLevel === 'admin'" ng-click="clone.show(backup)" uib-tooltip="{{ 'app.backups.backups.cloneTooltip' | tr }}"><i class="far fa-clone"></i></button>
<button class="btn btn-xs btn-danger" ng-click="restore.show(backup)" ng-disabled="app.taskId || app.runState === 'stopped'" uib-tooltip="{{ 'app.backups.backups.restoreTooltip' | tr }}"><i class="fas fa-history"></i></button>
</td>
</tr>
@@ -1059,9 +1303,17 @@
</table>
<br/>
<button type="button" class="btn btn-primary pull-right" ng-click="backups.createBackup()" ng-disabled="app.taskId || backups.busyCreate || app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="app.installationState === 'pending_backup' || backups.busyCreate"></i> {{ 'app.backups.backups.createBackupAction' | tr }}
</button>
<div class="row">
<div class="col-md-8">
<p class="has-error" ng-show="backups.error.message">{{ backups.error.message }}</p>
</div>
<div class="col-md-4">
<button type="button" class="btn btn-primary pull-right" ng-click="backups.createBackup()" ng-disabled="app.taskId || backups.busyCreate || app.error || app.runState === 'stopped'" tooltip-enable="app.error || app.taskId || app.runState === 'stopped'" uib-tooltip="{{ app.error ? 'App is in error state' : 'App is not running' }}">
<i class="fa fa-circle-notch fa-spin" ng-show="app.installationState === 'pending_backup' || backups.busyCreate"></i> {{ 'app.backups.backups.createBackupAction' | tr }}
</button>
</div>
</div>
</div>
</div>
+431 -107
View File
@@ -10,9 +10,7 @@
/* global Clipboard */
/* global SECRET_PLACEHOLDER */
angular.module('Application').controller('AppController', ['$scope', '$location', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $timeout, $interval, $route, $routeParams, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
angular.module('Application').controller('AppController', ['$scope', '$location', '$translate', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $interval, $route, $routeParams, Client) {
// List is from http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
$scope.s3Regions = [
{ name: 'Asia Pacific (Mumbai)', value: 'ap-south-1' },
@@ -94,7 +92,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.storageProvider = [
{ name: 'Amazon S3', value: 's3' },
{ name: 'Backblaze B2 (S3 API)', value: 'backblaze-b2' },
// { name: 'CIFS Mount', value: 'cifs' },
{ name: 'CIFS Mount', value: 'cifs' },
{ name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' },
{ name: 'Exoscale SOS', value: 'exoscale-sos' },
// { name: 'EXT4', value: 'ext4' },
@@ -104,11 +102,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
{ name: 'IONOS (Profitbricks)', value: 'ionos-objectstorage' },
{ name: 'Linode Object Storage', value: 'linode-objectstorage' },
{ name: 'Minio', value: 'minio' },
// { name: 'NFS Mount', value: 'nfs' },
{ 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: 'SSHFS Mount', value: 'sshfs' },
{ name: 'UpCloud Object Storage', value: 'upcloud-objectstorage' },
{ name: 'Vultr Object Storage', value: 'vultr-objectstorage' },
// { name: 'No-op (Only for testing)', value: 'noop' },
{ name: 'Wasabi', value: 'wasabi' }
@@ -136,6 +135,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.app = null;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
// note: these variables will remain empty for operators
$scope.domains = [];
$scope.volumes = [];
$scope.groups = [];
@@ -183,6 +184,12 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
};
$scope.sftpInfo = {
show: function () {
$('#sftpInfoModal').modal('show');
}
};
$scope.display = {
busy: false,
error: {},
@@ -280,25 +287,30 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
error: {},
domainCollisions: [],
domain: null,
location: '',
alternateDomains: [],
domain: null, // object and not the string
subdomain: '',
secondaryDomains: {},
redirectDomains: [],
aliasDomains: [],
portBindings: {},
portBindingsEnabled: {},
portBindingsInfo: {},
addAlternateDomain: function (event) {
addRedirectDomain: function (event) {
event.preventDefault();
$scope.location.alternateDomains.push({
$scope.location.redirectDomains.push({
domain: $scope.domains.filter(function (d) { return d.domain === $scope.app.domain; })[0], // pre-select app's domain by default
subdomain: ''
});
setTimeout(function () {
document.getElementById('redirectDomainsInput-' + ($scope.location.redirectDomains.length-1)).focus();
}, 200);
},
delAlternateDomain: function (event, index) {
delRedirectDomain: function (event, index) {
event.preventDefault();
$scope.location.alternateDomains.splice(index, 1);
$scope.location.redirectDomains.splice(index, 1);
},
addAliasDomain: function (event) {
@@ -307,6 +319,10 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
domain: $scope.domains.filter(function (d) { return d.domain === $scope.app.domain; })[0], // pre-select app's domain by default
subdomain: ''
});
setTimeout(function () {
document.getElementById('aliasDomainsInput-' + ($scope.location.aliasDomains.length-1)).focus();
}, 200);
},
delAliasDomain: function (event, index) {
@@ -319,10 +335,29 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.location.error = {};
$scope.location.domainCollisions = [];
$scope.location.location = app.location;
$scope.location.subdomain = app.subdomain;
$scope.location.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0];
// for compat, secondary domain can be empty after an upgrade. so it may not exist in app.secondaryDomains
$scope.location.secondaryDomains = {};
var httpPorts = app.manifest.httpPorts || {};
for (var env2 in httpPorts) {
$scope.location.secondaryDomains[env2] = {
subdomain: httpPorts[env2].defaultValue || '',
domain: $scope.location.domain
};
}
// now fill secondaryDomains with real values, if it exists
app.secondaryDomains.forEach(function (sd) {
$scope.location.secondaryDomains[sd.environmentVariable] = {
subdomain: sd.subdomain,
domain: $scope.domains.filter(function (d) { return d.domain === sd.domain; })[0]
};
});
$scope.location.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
$scope.location.alternateDomains = app.alternateDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
$scope.location.redirectDomains = app.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
$scope.location.aliasDomains = app.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: $scope.domains.filter(function (d) { return d.domain === a.domain; })[0] };});
// fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port
@@ -344,6 +379,14 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.location.error = {};
$scope.location.domainCollisions = [];
var secondaryDomains = {};
for (var env2 in $scope.location.secondaryDomains) {
secondaryDomains[env2] = {
subdomain: $scope.location.secondaryDomains[env2].subdomain,
domain: $scope.location.secondaryDomains[env2].domain.domain
};
}
// only use enabled ports from portBindings
var portBindings = {};
for (var env in $scope.location.portBindings) {
@@ -354,18 +397,24 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var data = {
overwriteDns: !!overwriteDns,
location: $scope.location.location,
subdomain: $scope.location.subdomain,
domain: $scope.location.domain.domain,
portBindings: portBindings,
alternateDomains: $scope.location.alternateDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}),
secondaryDomains: secondaryDomains,
redirectDomains: $scope.location.redirectDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };}),
aliasDomains: $scope.location.aliasDomains.map(function (a) { return { subdomain: a.subdomain, domain: a.domain.domain };})
};
// pre-flight only for changed domains
var domains = [];
if ($scope.app.domain !== data.domain || $scope.app.location !== data.location) domains.push({ subdomain: data.location, domain: data.domain, type: 'main' });
data.alternateDomains.forEach(function (a) {
if ($scope.app.alternateDomains.some(function (d) { return d.domain === a.domain && d.subdomain === a.subdomain; })) return;
if ($scope.app.domain !== data.domain || $scope.app.subdomain !== data.subdomain) domains.push({ subdomain: data.subdomain, domain: data.domain, type: 'primary' });
Object.keys(data.secondaryDomains).forEach(function (env) {
var subdomain = data.secondaryDomains[env].subdomain, domain = data.secondaryDomains[env].domain;
if ($scope.app.secondaryDomains.some(function (d) { return d.domain === domain && d.subdomain === subdomain; })) return;
domains.push({ subdomain: subdomain, domain: domain, type: 'secondary' });
});
data.redirectDomains.forEach(function (a) {
if ($scope.app.redirectDomains.some(function (d) { return d.domain === a.domain && d.subdomain === a.subdomain; })) return;
domains.push({ subdomain: a.subdomain, domain: a.domain, type: 'redirect' });
});
data.aliasDomains.forEach(function (a) {
@@ -373,25 +422,28 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
domains.push({ subdomain: a.subdomain, domain: a.domain, type: 'alias' });
});
var canConfigure = true;
async.eachSeries(domains, function (domain, callback) {
if (overwriteDns) return callback();
Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) {
if (error) return callback(error);
if (result.error) {
if (domain.type === 'main') {
if (domain.type === 'primary') {
$scope.location.error.location = domain.domain + ' ' + result.error.message;
} else if (domain.type === 'alias') {
$scope.location.error.aliasDomains = domain.domain + ' ' + result.error.message;
} else {
$scope.location.error.alternateDomains = domain.domain + ' ' + result.error.message;
$scope.location.error.redirectDomains = domain.domain + ' ' + result.error.message;
}
$scope.location.busy = false;
return;
canConfigure = false;
} else if (result.needsOverwrite) {
$scope.location.domainCollisions.push(domain);
canConfigure = false;
}
if (result.needsOverwrite) $scope.location.domainCollisions.push(domain);
callback();
});
}, function (error) {
@@ -400,22 +452,29 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
return Client.error(error);
}
if ($scope.location.domainCollisions.length) {
if (!canConfigure) {
$scope.location.busy = false;
return $('#domainCollisionsModal').modal('show');
}
Client.configureApp($scope.app.id, 'location', data, function (error) {
if (error && (error.statusCode === 409 || error.statusCode === 400)) {
if ((error.subdomain && error.domain) || error.field === 'location') {
if (data.domain === error.domain && data.location === error.subdomain) { // the primary
var errorMessage = error.message.toLowerCase();
if (errorMessage.indexOf('location') !== -1) {
if (errorMessage.indexOf('primary') !== -1) {
$scope.location.error.location = error.message;
$scope.locationForm.$setPristine();
} else { // FIXME: check error in aliasDomains
$scope.location.error.alternateDomains = error.message;
} else if (errorMessage.indexOf('secondary') !== -1) {
$scope.location.error.secondaryDomain = error.message;
} else if (errorMessage.indexOf('redirect') !== -1) {
$scope.location.error.redirectDomains = error.message;
} else if (errorMessage.indexOf('alias') !== -1) {
$scope.location.error.aliasDomains = error.message;
}
} else if (error.portName || error.field === 'portBindings') {
} else if (errorMessage.indexOf('port') !== -1) {
$scope.location.error.port = error.message;
} else {
$scope.location.error.location = error.message; // fallback
}
$scope.location.busy = false;
@@ -442,8 +501,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
ftp: false,
ssoAuth: false,
accessRestrictionOption: 'any',
accessRestrictionOptionCur: 'any',
accessRestriction: { users: [], groups: [] },
operators: { users: [], groups: [] },
isAccessRestrictionValid: function () {
var tmp = $scope.access.accessRestriction;
return !!(tmp.users.length || tmp.groups.length);
@@ -456,17 +518,32 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.access.ftp = app.manifest.addons.localstorage && app.manifest.addons.localstorage.ftp;
$scope.access.ssoAuth = (app.manifest.addons['ldap'] || app.manifest.addons['proxyAuth']) && app.sso;
$scope.access.accessRestrictionOption = app.accessRestriction ? 'groups' : 'any';
$scope.access.accessRestrictionOptionCur = app.accessRestriction ? 'groups' : 'any';
$scope.access.accessRestriction = { users: [], groups: [] };
$scope.access.operators = { users: [], groups: [] };
var userSet, groupSet;
if (app.accessRestriction) {
var userSet = { };
userSet = {};
app.accessRestriction.users.forEach(function (uid) { userSet[uid] = true; });
$scope.users.forEach(function (u) { if (userSet[u.id] === true) $scope.access.accessRestriction.users.push(u); });
var groupSet = { };
app.accessRestriction.groups.forEach(function (gid) { groupSet[gid] = true; });
groupSet = {};
if (app.accessRestriction.groups) app.accessRestriction.groups.forEach(function (gid) { groupSet[gid] = true; });
$scope.groups.forEach(function (g) { if (groupSet[g.id] === true) $scope.access.accessRestriction.groups.push(g); });
}
if (app.operators) {
userSet = {};
app.operators.users.forEach(function (uid) { userSet[uid] = true; });
$scope.users.forEach(function (u) { if (userSet[u.id] === true) $scope.access.operators.users.push(u); });
groupSet = {};
if (app.operators.groups) app.operators.groups.forEach(function (gid) { groupSet[gid] = true; });
$scope.groups.forEach(function (g) { if (groupSet[g.id] === true) $scope.access.operators.groups.push(g); });
}
},
submit: function () {
@@ -480,13 +557,34 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
accessRestriction.groups = $scope.access.accessRestriction.groups.map(function (g) { return g.id; });
}
Client.configureApp($scope.app.id, 'access_restriction', { accessRestriction: accessRestriction }, function (error) {
var operators = null;
if ($scope.access.operators.users.length || $scope.access.operators.groups.length) {
operators = { users: [], groups: [] };
operators.users = $scope.access.operators.users.map(function (u) { return u.id; });
operators.groups = $scope.access.operators.groups.map(function (g) { return g.id; });
}
async.series([
function (callback) {
if ($scope.access.accessRestrictionOption === $scope.access.accessRestrictionOptionCur && !$scope.accessForm.accessUsersSelect.$dirty && !$scope.accessForm.accessGroupsSelect.$dirty) return callback();
Client.configureApp($scope.app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
},
function (callback) {
if (!$scope.accessForm.operatorsUsersSelect.$dirty && !$scope.accessForm.operatorsGroupsSelect.$dirty) return callback();
Client.configureApp($scope.app.id, 'operators', { operators: operators }, callback);
}
], function (error) {
if (error) return Client.error(error);
$scope.accessForm.$setPristine();
$scope.access.accessRestrictionOptionCur = $scope.access.accessRestrictionOption;
$timeout(function () {
$scope.access.success = true;
$scope.access.busy = false;
}, 1000);
}, 3000);
});
}
};
@@ -511,13 +609,13 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.resources.memoryLimit = $scope.resources.currentMemoryLimit;
$scope.resources.currentCpuShares = $scope.resources.cpuShares = app.cpuShares;
Client.memory(function (error, memory) {
Client.getAppLimits(app.id, function (error, limits) {
if (error) return console.error(error);
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
$scope.resources.memoryTicks = [];
var npow2 = Math.pow(2, Math.ceil(Math.log(memory.memory)/Math.log(2)));
var npow2 = Math.pow(2, Math.ceil(Math.log(limits.memory.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
if (i >= (app.manifest.memoryLimit/1024/1024 || 0)) $scope.resources.memoryTicks.push(i * 1024 * 1024);
}
@@ -740,7 +838,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var memoryQuery = 'summarize(sum(collectd.localhost.table-' + appId + '-memory.gauge-rss, collectd.localhost.table-' + appId + '-memory.gauge-swap), "' + timeBucketSize + 'min", "avg")';
Client.graphs([ memoryQuery ], '-' + timePeriod + 'min', {}, function (error, result) {
Client.graphs([ memoryQuery ], '-' + timePeriod + 'min', { appId: appId }, function (error, result) {
if (error) return console.error(error);
var currentMemoryLimit = $scope.app.memoryLimit || $scope.app.manifest.memoryLimit || (256 * 1024 * 1024);
@@ -750,35 +848,65 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
};
$scope.email = {
busy: false,
error: {},
function findInbox(inboxes, app) {
return inboxes.find(function (i) { return i.name === app.inboxName && i.domain === (app.inboxDomain || app.domain); });
}
$scope.email = {
enableMailbox: true,
mailboxName: '',
mailboxDomain: '',
mailboxDomain: null,
currentMailboxName: '',
currentMailboxDomainName: '',
mailboxError: {},
mailboxBusy: false,
inboxError: {},
inboxBusy: false,
enableInbox: true,
inboxes: [],
currentInbox: null,
inbox: null,
show: function () {
var app = $scope.app;
$scope.emailForm.$setPristine();
$scope.email.error = {};
$scope.email.mailboxError = {};
$scope.email.enableMailbox = app.enableMailbox ? '1' : '0';
$scope.email.mailboxName = app.mailboxName || '';
$scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === app.mailboxDomain; })[0];
$scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === (app.mailboxDomain || app.domain); })[0];
$scope.email.currentMailboxName = app.mailboxName || '';
$scope.email.currentMailboxDomainName = $scope.email.mailboxDomain.domain;
$scope.email.currentMailboxDomainName = $scope.email.mailboxDomain ? $scope.email.mailboxDomain.domain : '';
$scope.email.inboxError = {};
$scope.email.enableInbox = app.enableInbox ? true : false;
Client.getAllMailboxes(function (error, mailboxes) {
if (error) console.error('Failed to list mailboxes.', error);
$scope.email.inboxes = mailboxes.map(function (m) { return { display: m.name + '@' + m.domain, name: m.name, domain: m.domain }; });
$scope.email.currentInbox = findInbox($scope.email.inboxes, app);
$scope.email.inbox = findInbox($scope.email.inboxes, app);
});
},
submit: function () {
submitMailbox: function () {
$scope.email.error = {};
$scope.email.busy = true;
$scope.email.mailboxBusy = true;
Client.configureApp($scope.app.id, 'mailbox', { enable: $scope.email.enableMailbox === '1', mailboxName: $scope.email.mailboxName || null, mailboxDomain: $scope.email.mailboxDomain.domain }, function (error) {
var data = {
enable: $scope.email.enableMailbox === '1'
};
if (data.enable) {
data.mailboxName = $scope.email.mailboxName || null;
data.mailboxDomain = $scope.email.mailboxDomain.domain;
}
Client.configureApp($scope.app.id, 'mailbox', data, function (error) {
if (error && error.statusCode === 400) {
$scope.email.busy = false;
$scope.email.mailboxBusy = false;
$scope.email.error.mailboxName = error.message;
$scope.emailForm.$setPristine();
return;
@@ -793,13 +921,146 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
// when the mailboxName is 'reset', this will fill it up with the default again
$scope.email.enableMailbox = $scope.app.enableMailbox ? '1' : '0';
$scope.email.mailboxName = $scope.app.mailboxName || '';
$scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === $scope.app.mailboxDomain; })[0];
$scope.email.mailboxDomain = $scope.domains.filter(function (d) { return d.domain === ($scope.app.mailboxDomain || $scope.app.domain); })[0];
$scope.email.currentMailboxName = $scope.app.mailboxName || '';
$scope.email.currentMailboxDomainName = $scope.email.mailboxDomain.domain;
$scope.email.currentMailboxDomainName = $scope.email.mailboxDomain ? $scope.email.mailboxDomain.domain : '';
$timeout(function () { $scope.email.busy = false; }, 1000);
$timeout(function () { $scope.email.mailboxBusy = false; }, 1000);
});
});
},
submitInbox: function () {
$scope.email.error = {};
$scope.email.inboxBusy = true;
var data = {
enable: $scope.email.enableInbox
};
if (data.enable) {
data.inboxName = $scope.email.inbox.name;
data.inboxDomain = $scope.email.inbox.domain;
}
Client.configureApp($scope.app.id, 'inbox', data, function (error) {
if (error && error.statusCode === 400) {
$scope.email.inboxBusy = false;
$scope.email.error.inboxName = error.message;
return;
}
if (error) return Client.error(error);
refreshApp($scope.app.id, function (error) {
if (error) return Client.error(error);
// when the mailboxName is 'reset', this will fill it up with the default again
$scope.email.enableInbox = $scope.app.enableInbox ? true : false;
$scope.email.currentInbox = findInbox($scope.email.inboxes, $scope.app);
$scope.email.inbox = findInbox($scope.email.inboxes, $scope.app);
$timeout(function () { $scope.email.inboxBusy = false; }, 1000);
});
});
}
};
$scope.eventlog = {
busy: false,
eventLogs: [],
activeEventLog: null,
currentPage: 1,
perPage: 15,
show: function () {
$scope.eventlog.refresh();
},
refresh: function () {
$scope.eventlog.busy = true;
Client.getAppEventLog($scope.app.id, $scope.eventlog.currentPage, $scope.eventlog.perPage, function (error, result) {
if (error) return console.error('Failed to get events:', error);
$scope.eventlog.eventLogs = [];
result.forEach(function (e) {
$scope.eventlog.eventLogs.push({ raw: e, details: Client.eventLogDetails(e, $scope.app.id), source: Client.eventLogSource(e) });
});
$scope.eventlog.busy = false;
});
},
showDetails: function (eventLog) {
if ($scope.eventlog.activeEventLog === eventLog) $scope.eventlog.activeEventLog = null;
else $scope.eventlog.activeEventLog = eventLog;
},
showNextPage: function () {
$scope.eventlog.currentPage++;
$scope.eventlog.refresh();
},
showPrevPage: function () {
if ($scope.eventlog.currentPage > 1) $scope.eventlog.currentPage--;
else $scope.eventlog.currentPage = 1;
$scope.eventlog.refresh();
}
};
$scope.cron = {
busy: false,
error: {},
commonPatterns: [
{ value: '* * * * *', label: $translate.instant('app.cron.commonPattern.everyMinute') },
{ value: '0 * * * *', label: $translate.instant('app.cron.commonPattern.everyHour') },
{ value: '*/30 * * * *', label: $translate.instant('app.cron.commonPattern.twicePerHour') },
{ value: '0 0 * * *', label: $translate.instant('app.cron.commonPattern.everyDay') },
{ value: '0 */12 * * *', label: $translate.instant('app.cron.commonPattern.twicePerDay') },
{ value: '0 0 * * 0', label: $translate.instant('app.cron.commonPattern.everySunday') }
],
crontab: '',
crontabDefault: ''
+ '# +------------------------ minute (0 - 59)\n'
+ '# | +------------------- hour (0 - 23)\n'
+ '# | | +-------------- day of month (1 - 31)\n'
+ '# | | | +--------- month (1 - 12)\n'
+ '# | | | | +---- day of week (0 - 6) (Sunday=0 or 7)\n'
+ '# | | | | |\n'
+ '# * * * * * command to be executed\n\n',
show: function () {
$scope.cronForm.$setPristine();
$scope.cron.error = {};
$scope.cron.crontab = $scope.app.crontab;
if ($scope.cron.crontab === null) $scope.cron.crontab = $scope.cron.crontabDefault; // only when null, not when ''
},
submit: function () {
$scope.cron.error = {};
$scope.cron.busy = true;
Client.configureApp($scope.app.id, 'crontab', { crontab: $scope.cron.crontab }, function (error) {
if (error && error.statusCode === 400) {
$scope.cron.busy = false;
$scope.cron.error.crontab = error.message;
$scope.cronForm.$setPristine();
return;
}
if (error) return Client.error(error);
$scope.cronForm.$setPristine();
$timeout(function () { $scope.cron.busy = false; }, 1000);
});
},
addCommonPattern: function (pattern) {
$scope.cron.crontab += pattern + ' /path/to/command\n';
}
};
@@ -850,7 +1111,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var app = $scope.app;
$scope.updates.enableAutomaticUpdate = app.enableAutomaticUpdate;
$scope.updates.skipBackup = !app.enableAutomaticUpdate && !app.enableBackup;
$scope.updates.skipBackup = false;
},
toggleAutomaticUpdates: function () {
@@ -869,7 +1130,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
check: function () {
$scope.updates.busyCheck = true;
Client.checkForUpdates(function (error) {
Client.checkForAppUpdates($scope.app.id, function (error) {
if (error) Client.error(error);
$scope.updates.busyCheck = false;
@@ -931,6 +1192,14 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
if (error) return Client.error(error);
$scope.backups.backups = backups;
Client.getAppEventLog(app.id, 1, 1, function (error, result) {
if (error) return console.error('Failed to get events:', error);
if (result.length !== 0 && result[0].action == 'app.backup.finish') {
$scope.backups.error.message = result[0].data.errorMessage;
}
});
});
},
@@ -953,7 +1222,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' ||
provider === 'exoscale-sos' || provider === 'digitalocean-spaces' ||
provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2' ||
provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage';
provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage'
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage';
};
$scope.mountlike = function (provider) {
@@ -978,6 +1248,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
format: 'tgz',
backupId: '',
password: '',
mountOptions: {},
clearForm: function () {
// $scope.importBackup.provider = ''; // do not clear since we call this function on provider change
@@ -994,6 +1265,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.importBackup.acceptSelfSignedCerts = false;
$scope.importBackup.password = '';
$scope.importBackup.backupId = '';
$scope.importBackup.mountOptions = {};
},
submit: function () {
@@ -1044,6 +1316,10 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
} else if (backupConfig.provider === 'vultr-objectstorage') {
backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.importBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === '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';
}
@@ -1068,7 +1344,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
return;
}
} else if (backupConfig.provider === 'sshfs' || backupConfig.provider === 'cifs' || backupConfig.provider === 'nfs') {
backupConfig.mountPoint = $scope.importBackup.mountPoint;
backupConfig.mountOptions = $scope.importBackup.mountOptions;
backupConfig.prefix = $scope.importBackup.prefix;
} else if (backupConfig.provider === 'filesystem') {
var parts = backupId.split('/');
@@ -1241,18 +1517,33 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
error: {},
backup: null,
location: '',
subdomain: '',
domain: null,
secondaryDomains: {},
needsOverwrite: false,
overwriteDns: false,
portBindings: {},
portBindingsInfo: {},
portBindingsEnabled: {},
show: function (backup) {
var app = $scope.app;
var app = $scope.app; // FIXME: should choose "app" from the backup's manifest
$scope.clone.error = {};
$scope.clone.backup = backup;
$scope.clone.domain = $scope.domains.find(function (d) { return app.domain === d.domain; }); // pre-select the app's domain
$scope.clone.needsOverwrite = false;
$scope.clone.overwriteDns = false;
$scope.clone.secondaryDomains = {};
app.secondaryDomains.forEach(function (sd) {
$scope.clone.secondaryDomains[sd.environmentVariable] = {
subdomain: sd.subdomain,
domain: $scope.domains.filter(function (d) { return d.domain === sd.domain; })[0]
};
});
$scope.clone.portBindingsInfo = angular.extend({}, app.manifest.tcpPorts, app.manifest.udpPorts); // Portbinding map only for information
// set default ports
for (var env in $scope.clone.portBindingsInfo) {
@@ -1260,12 +1551,20 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.clone.portBindingsEnabled[env] = true;
}
$('#cloneModal').modal('show');
$('#appCloneModal').modal('show');
},
submit: function () {
$scope.clone.busy = true;
var secondaryDomains = {};
for (var env2 in $scope.clone.secondaryDomains) {
secondaryDomains[env2] = {
subdomain: $scope.clone.secondaryDomains[env2].subdomain,
domain: $scope.clone.secondaryDomains[env2].domain.domain
};
}
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.clone.portBindings) {
@@ -1275,31 +1574,50 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
}
var data = {
location: $scope.clone.location,
subdomain: $scope.clone.subdomain,
domain: $scope.clone.domain.domain,
secondaryDomains: secondaryDomains,
portBindings: finalPortBindings,
backupId: $scope.clone.backup.id
backupId: $scope.clone.backup.id,
overwriteDns: $scope.clone.overwriteDns
};
Client.checkDNSRecords(data.domain, data.location, function (error, result) {
if (error) {
Client.error(error);
$scope.clone.busy = false;
return;
}
if (result.error) {
if (result.error.reason === ERROR.ACCESS_DENIED) {
$scope.clone.error.location = 'DNS credentials for ' + data.domain + ' are invalid. Update it in Domains & Certs view';
} else {
$scope.clone.error.location = result.error.message;
var allDomains = [{ domain: data.domain, subdomain: data.subdomain }].concat(Object.keys(secondaryDomains).map(function (k) {
return {
domain: secondaryDomains[k].domain,
subdomain: secondaryDomains[k].subdomain
};
}));
async.eachSeries(allDomains, function (domain, callback) {
if ($scope.clone.overwriteDns) return callback();
Client.checkDNSRecords(domain.domain, domain.subdomain, function (error, result) {
if (error) return callback(error);
var fqdn = domain.subdomain + '.' + domain.domain;
if (result.error) {
if (result.error.reason === ERROR.ACCESS_DENIED) return callback({ type: 'provider', fqdn: fqdn, message: 'DNS credentials for ' + domain.domain + ' are invalid. Update it in Domains & Certs view' });
return callback({ type: 'provider', fqdn: fqdn, message: result.error.message });
}
$scope.clone.needsOverwrite = true;
$scope.clone.busy = false;
return;
}
if (result.needsOverwrite) {
$scope.clone.error.location = 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron';
$scope.clone.needsOverwrite = true;
if (result.needsOverwrite) {
$scope.clone.needsOverwrite = true;
$scope.clone.overwriteDns = true;
return callback({ type: 'externally_exists', fqdn: fqdn, message: 'DNS Record already exists. Confirm that the domain is not in use for services external to Cloudron' });
}
callback();
});
}, function (error) {
if (error) {
if (error.type) {
$scope.clone.error.location = error;
$scope.clone.busy = false;
} else {
Client.error(error);
}
$scope.clone.error.location = error;
$scope.clone.busy = false;
return;
}
@@ -1308,22 +1626,20 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.clone.busy = false;
if (error) {
if (error.statusCode === 409) {
if (error.portName) {
$scope.clone.error.port = error.message;
} else if (error.domain) {
$scope.clone.error.location = 'This location is already taken.';
$('#cloneLocationInput').focus();
} else {
Client.error(error);
}
var errorMessage = error.message.toLowerCase();
if (errorMessage.indexOf('port') !== -1) {
$scope.clone.error.port = error.message;
} else if (error.message.indexOf('location') !== -1 || error.message.indexOf('subdomain') !== -1) {
// TODO extract fqdn from error message, currently we just set it always to the main location
$scope.clone.error.location = { type: 'internally_exists', fqdn: data.subdomain + '.' + data.domain, message: error.message };
$('#cloneLocationInput').focus();
} else {
Client.error(error);
}
return;
}
$('#cloneModal').modal('hide');
$('#appCloneModal').modal('hide');
$location.path('/apps');
});
@@ -1335,9 +1651,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
retryBusy: false,
error: {},
location: null,
subdomain: null,
domain: null,
alternateDomains: [],
redirectDomains: [],
aliasDomains: [],
backups: [],
@@ -1349,9 +1665,9 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
confirm: function () {
$scope.repair.error = {};
$scope.repair.retryBusy = false;
$scope.repair.location = null;
$scope.repair.subdomain = null;
$scope.repair.domain = null;
$scope.repair.alternateDomains = [];
$scope.repair.redirectDomains = [];
$scope.repair.aliasDomains = [];
$scope.repair.backupId = '';
@@ -1360,7 +1676,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
var errorState = ($scope.app.error && $scope.app.error.installationState) || ISTATES.PENDING_CONFIGURE;
if (errorState === ISTATES.PENDING_LOCATION_CHANGE) {
$scope.repair.location = app.location;
$scope.repair.subdomain = app.subdomain;
$scope.repair.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0];
$scope.repair.aliasDomains = $scope.app.aliasDomains;
@@ -1372,8 +1688,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
});
$scope.repair.alternateDomains = $scope.app.alternateDomains;
$scope.repair.alternateDomains = $scope.app.alternateDomains.map(function (altDomain) {
$scope.repair.redirectDomains = $scope.app.redirectDomains;
$scope.repair.redirectDomains = $scope.app.redirectDomains.map(function (altDomain) {
return {
subdomain: altDomain.subdomain,
enabled: true,
@@ -1412,11 +1728,11 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
break;
case ISTATES.PENDING_LOCATION_CHANGE:
data.location = $scope.repair.location;
data.subdomain = $scope.repair.subdomain;
data.domain = $scope.repair.domain.domain;
data.aliasDomains = $scope.repair.aliasDomains.filter(function (a) { return a.enabled; })
.map(function (d) { return { subdomain: d.subdomain, domain: d.domain.domain }; });
data.alternateDomains = $scope.repair.alternateDomains.filter(function (a) { return a.enabled; })
data.redirectDomains = $scope.repair.redirectDomains.filter(function (a) { return a.enabled; })
.map(function (d) { return { subdomain: d.subdomain, domain: d.domain.domain }; });
data.overwriteDns = true; // always overwriteDns. user can anyway check and uncheck above
repairFunc = Client.configureApp.bind(null, $scope.app.id, 'location', data);
@@ -1511,7 +1827,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
function fetchUsers(callback) {
Client.getUsers(function (error, users) {
Client.getAllUsers(function (error, users) {
if (error) return callback(error);
// ensure we have something to work with in the access restriction dropdowns
@@ -1654,6 +1970,8 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
refreshApp(appId, function (error) {
if (error) return Client.error(error);
if ($scope.app.accessLevel !== 'admin' && $scope.app.accessLevel !== 'operator') return $location.path('/');
// skipViewShow because we don't have all the values like domains/users to init the view yet
if ($routeParams.view) { // explicit route in url bar
$scope.setView($routeParams.view, true /* skipViewShow */);
@@ -1661,6 +1979,17 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
$scope.setView($scope.app.error ? 'repair' : 'display', true /* skipViewShow */);
}
function done() {
$scope[$scope.view].show(); // initialize now that we have all the values
var refreshTimer = $interval(function () { refreshApp($scope.app.id); }, 5000); // call with inline function to avoid iteration argument passed see $interval docs
$scope.$on('$destroy', function () {
$interval.cancel(refreshTimer);
});
}
if ($scope.app.accessLevel !== 'admin') return done();
async.series([
fetchUsers,
fetchGroups,
@@ -1670,12 +1999,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
], function (error) {
if (error) return Client.error(error);
$scope[$scope.view].show(); // initialize now that we have all the values
var refreshTimer = $interval(function () { refreshApp($scope.app.id); }, 5000); // call with inline function to avoid iteration argument passed see $interval docs
$scope.$on('$destroy', function () {
$interval.cancel(refreshTimer);
});
done();
});
});
});
@@ -1692,7 +2016,7 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
};
// setup all the dialog focus handling
['appUninstallModal', 'appUpdateModal', 'appRestoreModal'].forEach(function (id) {
['appUninstallModal', 'appUpdateModal', 'appRestoreModal', 'appCloneModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find('[autofocus]:first').focus();
});
+17 -15
View File
@@ -60,9 +60,9 @@
<div class="pull-right">
<form class="form-inline">
<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="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="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="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="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>
@@ -71,10 +71,10 @@
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="app-grid">
<div class="grid-item" 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" 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="user.isAtLeastAdmin" 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="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)}">
<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;">
@@ -84,11 +84,11 @@
<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="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>
@@ -97,17 +97,19 @@
</div>
<div class="usermanagement-indicator" ng-hide="user.isAtLeastAdmin">
<i class="fas fa-user" ng-show="app.ssoAuth && !app.manifest.addons.email" uib-tooltip="Log in with Cloudron username and password" tooltip-placement="right"></i>
<i class="far fa-user" ng-show="!app.ssoAuth && !app.manifest.addons.email" uib-tooltip="Not using Cloudron's usermanagement" tooltip-placement="right"></i>
<i class="fas fa-envelope" ng-show="app.manifest.addons.email" uib-tooltip="Log in with Cloudron mailbox email and password" tooltip-placement="right"></i>
<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>
<!-- 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>
<!-- 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>
</div>
</div>
</div>
+22
View File
@@ -41,6 +41,20 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
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;
@@ -77,6 +91,10 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
$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);
@@ -96,10 +114,14 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
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) {
+36 -19
View File
@@ -20,10 +20,10 @@
<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">{{ '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="{{ 'appstore.installDialog.locationPlaceholder' | tr }}" 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.domain }}</span>
<span>{{ '.' + appInstall.domain.domain }}</span>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
@@ -36,7 +36,37 @@
<div ng-show="appInstall.error.location" class="text-small">{{ appInstall.error.location }}</div>
</div>
<p class="text-small text-warning" ng-show="appInstall.location && appInstall.domain.provider === 'manual'" ng-bind-html="'appstore.installDialog.manualWarning' | tr:{ location: (appInstall.location + '.' + appInstall.domain.domain) }"></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">
@@ -49,6 +79,7 @@
</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>
@@ -147,8 +178,8 @@
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success pull-left" ng-click="openSubscriptionSetup()" ng-show="appInstall.state === 'subscriptionRequired'">{{ 'appstore.installDialog.setupSubscriptionAction' | tr }}</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>
@@ -204,7 +235,7 @@
<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>
<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>
@@ -218,20 +249,6 @@
</div>
</div>
<div class="form-group">
<label class="control-label">{{ 'appstore.accountDialog.intendedUse' | tr }}</label>
<select class="purpose form-control" ng-model="appstoreLogin.purpose" required>
<option value="" disabled selected hidden>{{ 'appstore.accountDialog.chooseAnOption' | tr }}</option>
<option value="personal_cloud">Personal use</option>
<option value="business_cloud">Business use</option>
<option value="website_hosting">Website hosting</option>
<option value="msp">Managed Service Provider</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"><span ng-bind-html="'appstore.accountDialog.licenseCheckbox' | tr:{ licenseLink: 'https://cloudron.io/legal/license.html' }"></span>
+128 -87
View File
@@ -2,6 +2,7 @@
/* global angular:false */
/* global $:false */
/* global async */
/* global ERROR */
/* global RSTATES */
/* global moment */
@@ -93,8 +94,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
error: {},
app: {},
needsOverwrite: false,
location: '',
domain: null,
subdomain: '',
domain: null, // object and not the string
secondaryDomains: {},
portBindings: {},
mediaLinks: [],
certificateFile: null,
@@ -116,8 +118,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$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 = [];
@@ -178,6 +181,16 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$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
@@ -205,6 +218,14 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$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) {
@@ -222,8 +243,9 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
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,
@@ -231,30 +253,63 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
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;
@@ -263,17 +318,21 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
$('#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;
@@ -327,13 +386,12 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
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;
@@ -377,43 +435,12 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
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 all apps listing. this also sets $scope.apps accordingly
$scope.showCategory('');
// 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(); }, 1);
callback();
});
});
}
// TODO does not support testing apps in search
$scope.search = function () {
if (!$scope.searchString) return $scope.showCategory($scope.cachedCategory);
@@ -433,7 +460,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
if (app.manifest.tags.join().toUpperCase().indexOf(token) !== -1) return true;
if (app.manifest.description.toUpperCase().indexOf(token) !== -1) return true;
return false;
});
});
});
};
@@ -559,7 +586,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
}
function fetchUsers() {
Client.getUsers(function (error, users) {
Client.getAllUsers(function (error, users) {
if (error) {
console.error(error);
return $timeout(fetchUsers, 5000);
@@ -580,6 +607,17 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
});
}
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) {
@@ -605,43 +643,46 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$tran
});
}
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 () {
+33 -18
View File
@@ -140,28 +140,35 @@
<!-- 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'">
<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/NFS/SSHFS -->
<!-- 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'">
<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 -->
<!-- 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 -->
<!-- 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">
<input type="password" class="form-control" ng-model="configureBackup.mountOptions.password" id="configureBackupPassword" name="password" ng-disabled="configureBackup.busy" password-reveal>
</div>
<!-- EXT4 -->
<!-- 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'">
@@ -170,23 +177,23 @@
<!-- 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">
<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">
<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"></textarea>
<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 || !configureBackup.backupFolder }" ng-show="configureBackup.provider === '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>
@@ -198,10 +205,17 @@
</label>
</div>
<!-- S3/Minio/SOS/GCS -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.endpoint }" ng-show="configureBackup.provider === 'minio' || configureBackup.provider === 'backblaze-b2' || configureBackup.provider === 's3-v4-compat'">
<!-- 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 === 'backblaze-b2' || configureBackup.provider === 's3-v4-compat'">
<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'" >
@@ -316,7 +330,7 @@
<div uib-collapse="!configureBackup.advancedVisible">
<div class="form-group">
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyByteSize:'Default (400 MB)' }}</b></label>
<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>
@@ -440,8 +454,9 @@
<br/>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-show="user.role === 'owner'" ng-click="configureBackup.show()">{{ 'backups.location.configure' | tr }}</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>
@@ -471,7 +486,7 @@
<br/>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-show="user.role === 'owner'" ng-click="configureScheduleAndRetention.show()">{{ 'backups.schedule.configure' | tr }}</button>
<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>
+60 -9
View File
@@ -6,6 +6,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
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();
@@ -90,6 +91,21 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
{ 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
];
@@ -112,6 +128,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
{ 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' },
{ name: 'No-op (Only for testing)', value: 'noop' }
@@ -174,6 +191,31 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
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,
@@ -289,7 +331,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat'
|| provider === 'exoscale-sos' || provider === 'digitalocean-spaces'
|| provider === 'scaleway-objectstorage' || provider === 'wasabi' || provider === 'backblaze-b2'
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage' || provider === 'vultr-objectstorage';
|| provider === 'linode-objectstorage' || provider === 'ovh-objectstorage' || provider === 'ionos-objectstorage'
|| provider === 'vultr-objectstorage' || provider === 'upcloud-objectstorage';
};
$scope.mountlike = function (provider) {
@@ -419,13 +462,14 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
mountPoint: '',
acceptSelfSignedCerts: false,
useHardlinks: true,
chown: true,
format: 'tgz',
password: '',
passwordRepeat: '',
advancedVisible: false,
memoryTicks: [],
memoryLimit: 400 * 1024 * 1024,
memoryLimit: $scope.MIN_MEMORY_LIMIT,
uploadPartSizeTicks: [],
uploadPartSize: 50 * 1024 * 1024,
copyConcurrency: '',
@@ -438,6 +482,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
username: '',
password: '',
diskPath: '',
seal: false,
user: '',
port: 22,
privateKey: ''
@@ -456,7 +501,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.mountPoint = '';
$scope.configureBackup.acceptSelfSignedCerts = false;
$scope.configureBackup.useHardlinks = true;
$scope.configureBackup.memoryLimit = 400 * 1024 * 1024;
$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;
@@ -464,7 +510,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.syncConcurrency = $scope.configureBackup.provider === 's3' ? 20 : 10;
$scope.configureBackup.copyConcurrency = $scope.configureBackup.provider === 's3' ? 500 : 10;
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', user: '', port: 22, privateKey: '' };
$scope.configureBackup.mountOptions = { host: '', remoteDir: '', username: '', password: '', diskPath: '', seal: false, user: '', port: 22, privateKey: '' };
},
show: function () {
@@ -495,6 +541,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$scope.configureBackup.format = $scope.backupConfig.format;
$scope.configureBackup.acceptSelfSignedCerts = !!$scope.backupConfig.acceptSelfSignedCerts;
$scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks;
$scope.configureBackup.chown = $scope.backupConfig.chown;
$scope.configureBackup.memoryLimit = $scope.backupConfig.memoryLimit;
@@ -504,8 +551,8 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
$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 = [ 400 * 1024 * 1024 ];
for (var i = 512; i <= totalMemory/1024/1024; i *= 2) {
$scope.configureBackup.memoryTicks = [ $scope.MIN_MEMORY_LIMIT ];
for (var i = 1024; i <= totalMemory/1024/1024; i *= 2) {
$scope.configureBackup.memoryTicks.push(i * 1024 * 1024);
}
@@ -521,6 +568,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
username: mountOptions.username || '',
password: mountOptions.password || '',
diskPath: mountOptions.diskPath || '',
seal: mountOptions.seal,
user: mountOptions.user || '',
port: mountOptions.port || 22,
privateKey: mountOptions.privateKey || ''
@@ -580,6 +628,10 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
} else if (backupConfig.provider === 'vultr-objectstorage') {
backupConfig.region = $scope.vultrRegions.find(function (x) { return x.value === $scope.configureBackup.endpoint; }).region;
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === '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';
}
@@ -609,24 +661,23 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
backupConfig.mountOptions = {};
if (backupConfig.provider === 'cifs' || backupConfig.provider === 'sshfs' || backupConfig.provider === 'nfs') {
backupConfig.mountPoint = '/mnt/cloudronbackup'; // harcoded for ease of use
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.mountPoint = '/mnt/cloudronbackup'; // harcoded for ease of use
backupConfig.mountOptions.diskPath = $scope.configureBackup.mountOptions.diskPath;
} else if (backupConfig.provider === 'mountpoint') {
backupConfig.mountPoint = $scope.configureBackup.mountPoint;
backupConfig.chown = true;
backupConfig.chown = $scope.configureBackup.chown;
backupConfig.preserveAttributes = true;
}
} else if (backupConfig.provider === 'filesystem') {
+2 -2
View File
@@ -37,10 +37,10 @@
<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': (aboutForm.name.$dirty && aboutForm.name.$invalid) }">
<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" maxlength="64">
<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">
+44 -18
View File
@@ -156,16 +156,6 @@
<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.matrixHostname' | tr }} <sup><a ng-href="https://docs.cloudron.io/domains/#matrix-server-location" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="domainConfigure.matrixHostname" name="matrixHostname" ng-disabled="domainConfigure.busy">
</div>
<div class="form-group">
<label class="control-label">{{ 'domains.domainDialog.mastodonHostname' | tr }} <sup><a ng-href="https://docs.cloudron.io/domains/#mastodon-server-location" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<input type="text" class="form-control" ng-model="domainConfigure.mastodonHostname" name="mastodonHostname" ng-disabled="domainConfigure.busy">
</div>
<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">
@@ -215,6 +205,45 @@
</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>
</div>
<!-- Modal domain remove -->
<div class="modal fade" id="domainRemoveModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -265,6 +294,7 @@
{{ prettyProviderName(domain) }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="domainWellKnown.show(domain)" title="{{ 'domains.tooltipWellKnown' | tr }}"><i class="fa fa-atlas"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainConfigure.show(domain)" title="{{ 'domains.tooltipEdit' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="domainRemove.show(domain)" title="{{ 'domains.tooltipRemove' | tr }}" ng-disabled="domain.domain === config.adminDomain"><i class="far fa-trash-alt"></i></button>
</td>
@@ -295,15 +325,13 @@
</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">{{ 'domains.renewCerts.renewAllAction' | tr }}</button>
<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>
@@ -328,15 +356,13 @@
</div>
<div class="row">
<div class="col-md-6">
<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>
</div>
<div class="col-md-6 text-right">
<button class="btn btn-outline btn-primary" ng-click="syncDns.sync()" ng-disabled="syncDns.busy" style="margin-right: 10px">{{ 'domains.syncDns.syncAction' | tr }}</button>
<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>
+92 -32
View File
@@ -124,6 +124,95 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
}
};
$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,
@@ -156,9 +245,6 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
provider: 'route53',
zoneName: '',
mastodonHostname: '',
matrixHostname: '',
tlsConfig: {
provider: 'letsencrypt-prod-wildcard'
},
@@ -227,21 +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.matrixHostname = '';
$scope.domainConfigure.mastodonHostname = '';
try {
if (domain.wellKnown && domain.wellKnown['matrix/server']) {
$scope.domainConfigure.matrixHostname = JSON.parse(domain.wellKnown['matrix/server'])['m.server'];
}
if (domain.wellKnown && domain.wellKnown['host-meta']) {
$scope.domainConfigure.mastodonHostname = domain.wellKnown['host-meta'].match(new RegExp('template="https://(.*?)/'))[1];
}
} catch (e) {
console.error(e);
}
} else {
$scope.domainConfigure.adding = true;
}
@@ -321,21 +392,10 @@ angular.module('Application').controller('DomainsController', ['$scope', '$locat
tlsConfig.wildcard = true;
}
var wellKnown = {};
if ($scope.domainConfigure.matrixHostname) {
wellKnown['matrix/server'] = JSON.stringify({ 'm.server': $scope.domainConfigure.matrixHostname });
}
if ($scope.domainConfigure.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.domainConfigure.mastodonHostname + '/.well-known/webfinger?resource={uri}"/>\n'
+ '</XRD>';
}
// 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, wellKnown);
else func = Client.updateDomain.bind(Client, $scope.domainConfigure.domain.domain, $scope.domainConfigure.zoneName, provider, data, fallbackCertificate, tlsConfig, wellKnown);
if ($scope.domainConfigure.adding) func = Client.addDomain.bind(Client, $scope.domainConfigure.newDomain, $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;
@@ -647,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();
});
});
+438 -367
View File
@@ -26,7 +26,7 @@
<div class="modal-body">
<div ng-bind-html="'email.enableEmailDialog.description' | tr:{ domain: domain.domain, requiredPortsDocsLink: 'https://docs.cloudron.io/email/#required-ports' }"></div>
<br/>
<div ng-show="domain.provider === 'noop' || domain.provider === 'manual'" ng-bind-html="'email.enableEmailDialog.noProviderInfo' | tr"></div>
<div class="text-warning" ng-show="domain.provider === 'noop' || domain.provider === 'manual'" ng-bind-html="'email.enableEmailDialog.noProviderInfo' | tr"></div>
<div class="text-danger" ng-show="adminDomain.provider === 'cloudflare'" ng-bind-html="'email.enableEmailDialog.cloudflareInfo' | tr:{ adminDomain: config.adminDomain, mailFqdn: config.mailFqdn }"></div>
<div ng-hide="domain.provider === 'noop' || domain.provider === 'manual'">
<p>
@@ -34,7 +34,7 @@
<input type="checkbox" ng-model="incomingEmail.setupDns"> {{ 'email.enableEmailDialog.setupDnsCheckbox' | tr }}
</label>
</p>
<span ng-bind-html="'email.enableEmailDialog.setupDnsInfo' | tr:{ importEmailDocsLink: 'https://docs.cloudron.io/email/#import-email' }"></span>
<span ng-bind-html="'email.enableEmailDialog.setupDnsInfo' | tr:{ importEmailDocsLink: 'https://docs.cloudron.io/guides/import-email' }"></span>
</div>
</div>
<div class="modal-footer">
@@ -122,13 +122,13 @@
<label class="control-label">{{ 'email.editMailboxDialog.aliases' | tr }}</label>
<div class="has-error" ng-show="mailboxes.edit.error">{{ mailboxes.edit.error.message }}</div>
<div class="row" ng-repeat="alias in mailboxes.edit.aliases">
<div class="row" ng-repeat="alias in mailboxes.edit.aliases | orderBy:'reversedSortingNotation'">
<div class="col col-lg-11">
<div class="input-group">
<input type="text" class="form-control" ng-model="alias.name">
<input type="text" class="form-control input-sm" ng-model="alias.name" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
<button type="button" class="btn btn-default btn-sm dropdown-toggle" data-toggle="dropdown">
<span>@{{ alias.domain }}</span>
<span class="caret"></span>
</button>
@@ -152,6 +152,12 @@
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.enablePop3"> {{ 'email.updateMailboxDialog.enablePop3' | tr }}</input>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="mailboxes.edit.active"> {{ 'email.updateMailboxDialog.activeCheckbox' | tr }}</input>
@@ -193,6 +199,44 @@
</div>
</div>
<!-- Modal import mailboxes -->
<div class="modal fade" id="mailboxImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'email.mailboxImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-show="!mailboxImport.done">
<div ng-show="!mailboxImport.busy">
<p ng-bind-html=" 'email.mailboxImportDialog.description' | tr:{ docsLink: 'https://cloudron.io/documentation/email/#import-mailboxes' } "></p>
<input type="file" style="display: none;" id="mailboxImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="mailboxImport.openFileInput()">{{ 'email.mailboxImportDialog.fileInput' | tr }}</button>
<br/>
<br/>
<p class="text-danger" ng-show="mailboxImport.error.file">{{ mailboxImport.error.file }}</p>
<p class="text-info" ng-show="mailboxImport.mailboxes.length">{{ 'email.mailboxImportDialog.mailboxesFound' | tr:{ count: mailboxImport.mailboxes.length } }}</p>
</div>
<div ng-show="mailboxImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailboxImport.percent }}%"></div>
</div>
</div>
<div ng-show="mailboxImport.done">
<p>{{ 'email.mailboxImportDialog.success' | tr:{ count: mailboxImport.success } }}</p>
<div ng-show="mailboxImport.error.import.length">
<p class="text-danger">{{ 'email.mailboxImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in mailboxImport.error.import"><b>{{ tmp.mailbox.name }}@{{ tmp.mailbox.domain }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</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="mailboxImport.import()" ng-show="!mailboxImport.done" ng-disabled="mailboxImport.busy || !mailboxImport.mailboxes.length"><i class="fa fa-circle-notch fa-spin" ng-show="mailboxImport.busy"></i> {{ 'email.mailboxImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal add mailinglist -->
<div class="modal fade" id="mailinglistAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -246,7 +290,7 @@
<div class="form-group" ng-class="{ 'has-error': mailinglists.edit.error.members }">
<label class="control-label">{{ 'email.addMailinglistDialog.members' | tr }}</label><br/>
<div class="has-error control-label" ng-show="mailinglists.edit.error.members"><small>{{ mailinglists.edit.error.members }}</small></div>
<textarea ng-model="mailinglists.edit.membersTxt" class="form-control" rows="5"></textarea>
<textarea ng-model="mailinglists.edit.membersTxt" class="form-control" rows="5" autofocus></textarea>
<small>{{ 'email.addMailinglistDialog.membersInfo' | tr }}</small>
</div>
<div class="checkbox">
@@ -288,415 +332,398 @@
</div>
</div>
<!-- Modal how to connect -->
<div class="modal fade" id="howToConnectInfoModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4>{{ 'email.howToConnectInfoModal' | tr }}</h4>
</div>
<div class="modal-body">
<p ng-bind-html=" 'email.incoming.howToConnectDescription' | tr:{ domain: domain.domain } "></p>
<p><b>{{ 'email.incoming.incomingUserInfo' | tr }}</b><br/><i>mailboxname</i>@{{ domain.domain }}</p>
<p><b>{{ 'email.incoming.incomingPasswordInfo' | tr }}</b><br/>{{ 'email.incoming.incomingPasswordUsage' | tr }}</p>
<p><b>{{ 'email.incoming.incomingServerInfo' | tr }}</b><br/>{{ 'email.incoming.server' | tr }}: <span ng-click-select>{{config.mailFqdn}}</span><br/>{{ 'email.incoming.port' | tr }}: 993 (TLS)</p>
<p><b>{{ 'email.incoming.outgointServerInfo' | tr }}</b><br/>{{ 'email.incoming.server' | tr }}: <span ng-click-select>{{config.mailFqdn}}</span><br/>{{ 'email.incoming.port' | tr }}: 587 (STARTTLS) or 465 (TLS)</p>
<p><b>{{ 'email.incoming.sieveServerInfo' | tr }}</b><br/>{{ 'email.incoming.server' | tr }}: <span ng-click-select>{{config.mailFqdn}}</span><br/>{{ 'email.incoming.port' | tr }}: 4190 (STARTTLS)</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" 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>
</div>
<div class="content content-large" ng-show="ready">
<div class="content" ng-show="ready">
<a href="/#/email" class="back-to-view-link"><i class="fas fa-arrow-left"></i> {{ 'email.backAction' | tr }}</a>
<br/>
<div class="text-left">
<h3>{{ 'email.config.title' | tr:{ domain: domain.domain } }}</h3>
<h3>
{{ 'email.config.title' | tr:{ domain: domain.domain } }}
<div class="dropdown pull-right" style="display: inline-block">
<button class="btn btn-sm btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'app.docsActionTooltip' | tr }}" tooltip-append-to-body="true" tooltip-placement="bottom">
<i class="fas fa-book"></i>
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="https://docs.cloudron.io/email/" target="_blank">{{ 'app.docsAction' | tr }}</a></li>
<li ng-class="{ 'disabled': !domain.mailConfig.enabled }"><a href="" ng-click="howToConnectInfo.show()">{{ 'email.config.clientConfiguration' | tr }}</a></li>
</ul>
</div>
</h3>
</div>
<br/>
<uib-tabset>
<uib-tab index="0" heading="{{ 'email.incoming.tabTitle' | tr }}">
<uib-tabset active="activeTab">
<uib-tab index="'mailboxes'" select="setView('mailboxes')" heading="{{ 'email.incoming.tabTitle' | tr }}">
<div class="card card-large" style="margin-bottom: 15px;">
<h4>{{ 'email.incoming.title' | tr }}</h4>
<h4>{{ 'email.incoming.title' | tr }}</h4>
<div class="row">
<div class="col-md-10" ng-bind-html="'email.incoming.description' | tr:{ emailDocsLink: 'https://docs.cloudron.io/email/', rainloopLink: '/#/appstore/net.rainloop.cloudronapp', sogoLink: '/#/appstore/nu.sogo.cloudronapp2', roundcubeLink: '/#/appstore/net.roundcube.cloudronapp' } "></div>
<div class="col-md-2">
<button class="pull-right" ng-class="domain.mailConfig.enabled ? 'btn btn-danger' : 'btn btn-primary'" ng-click="incomingEmail.toggleEmailEnabled()" ng-disabled="incomingEmail.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="incomingEmail.busy"></i>
{{ domain.mailConfig.enabled ? ('email.incoming.disableAction' | tr) : ('email.incoming.enableAction' | tr) }}
</button>
</div>
<p ng-show="domain.mailConfig.enabled">{{ 'email.incoming.enabled' | tr }}</p>
<p ng-hide="domain.mailConfig.enabled">{{ 'email.incoming.disabled' | tr }}</p>
<div class="row">
<div class="col-md-12">
<button class="pull-right" ng-class="domain.mailConfig.enabled ? 'btn btn-danger' : 'btn btn-primary'" ng-click="incomingEmail.toggleEmailEnabled()" ng-disabled="incomingEmail.busy" ng-show="user.isAtLeastAdmin">
<i class="fa fa-circle-notch fa-spin" ng-show="incomingEmail.busy"></i>
{{ domain.mailConfig.enabled ? ('email.incoming.disableAction' | tr) : ('email.incoming.enableAction' | tr) }}
</button>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#mail_settings">{{ 'email.config.connectionDetails' | tr }}</a>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div id="mail_settings" class="panel-collapse collapse">
<br/>
<p><b>{{ 'email.incoming.incomingServerInfo' | tr }}</b><br/>{{ 'email.incoming.server' | tr }}: <span ng-click-select>{{config.mailFqdn}}</span><br/>{{ 'email.incoming.port' | tr }}: 993 (TLS)</p>
<p><b>{{ 'email.incoming.outgointServerInfo' | tr }}</b><br/>{{ 'email.incoming.server' | tr }}: <span ng-click-select>{{config.mailFqdn}}</span><br/>{{ 'email.incoming.port' | tr }}: 587 (STARTTLS)</p>
<p><b>{{ 'email.incoming.sieveServerInfo' | tr }}</b><br/>{{ 'email.incoming.server' | tr }}: <span ng-click-select>{{config.mailFqdn}}</span><br/>{{ 'email.incoming.port' | tr }}: 4190 (STARTTLS)</p>
<p ng-bind-html=" 'email.incoming.loginHelp' | tr:{ domain: domain.domain } "></p>
</div>
</div>
</div>
<br/>
</div>
</div>
<br/>
<br/>
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailboxes.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailboxes.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-inbox"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailboxes.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailboxes.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-inbox"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<div class="btn-group pull-right" style="margin-left: 5px;">
<button class="btn btn-default" ng-click="mailboxImport.show()" uib-tooltip="{{ 'email.incoming.mailboxes.importTooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'email.incoming.mailboxes.exportTooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="mailboxExport('csv')">{{ 'email.incoming.mailboxes.mailboxExport.csv' | tr }}</a></li>
<li><a href="" ng-click="mailboxExport('json')">{{ 'email.incoming.mailboxes.mailboxExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailboxes.search" ng-model-options="{ debounce: 1000 }" ng-change="mailboxes.updateFilter()" />
</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<table class="table table-hover">
<thead>
<tr>
<th>{{ 'email.incoming.mailboxes.name' | tr }}</th>
<th>{{ 'email.incoming.mailboxes.owner' | tr }}</th>
<th>{{ 'email.incoming.mailboxes.aliases' | tr }}</th>
<th>{{ 'email.incoming.mailboxes.usage' | tr }}</th>
<th class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="mailbox in mailboxes.mailboxes | filter:mailboxes.search" ng-class="{'text-muted': !mailbox.active}">
<td class="hand" ng-click="mailboxes.edit.show(mailbox)">
{{ mailbox.name }}
</td>
<td class="hand" ng-click="mailboxes.edit.show(mailbox)">
{{ mailbox.ownerDisplayName }}
</td>
<td class="hand elide-table-cell" ng-click="mailboxes.edit.show(mailbox)">
<span ng-repeat="alias in mailbox.aliases"> {{ alias.name + '@' + alias.domain }}</span>
</td>
<td class="hand no-wrap" ng-click="mailboxes.edit.show(mailbox)">
{{ mailbox.usage | prettyByteSize }}
</td>
<td class="text-right no-wrap">
<button class="btn btn-xs btn-default" ng-click="mailboxes.edit.show(mailbox)"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="mailboxes.remove.show(mailbox)"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
</table>
<div class="pull-right">
<button class="btn btn-default btn-outline" ng-click="mailboxes.showPrevPage()" ng-disabled="mailboxes.busy || mailboxes.currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="mailboxes.showNextPage()" ng-disabled="mailboxes.busy || mailboxes.perPage > mailboxes.mailboxes.length">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailboxes.search" ng-model-options="{ debounce: 1000 }" ng-change="mailboxes.updateFilter()" />
</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<table class="table table-hover">
<thead>
<tr>
<th>{{ 'email.incoming.mailboxes.name' | tr }}</th>
<th>{{ 'email.incoming.mailboxes.owner' | tr }}</th>
<th>{{ 'email.incoming.mailboxes.aliases' | tr }}</th>
<th>{{ 'email.incoming.mailboxes.usage' | tr }}</th>
<th class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="mailbox in mailboxes.mailboxes | filter:mailboxes.search" ng-class="{'text-muted': !mailbox.active}">
<td class="hand" ng-click="mailboxes.edit.show(mailbox)">
{{ mailbox.name }}
</td>
<td class="hand" ng-click="mailboxes.edit.show(mailbox)">
{{ mailbox.ownerDisplayName }}
</td>
<td class="hand" ng-click="mailboxes.edit.show(mailbox)">
<span ng-repeat="alias in mailbox.aliases"> {{ alias.name + '@' + alias.domain }}</span>
</td>
<td class="hand no-wrap" ng-click="mailboxes.edit.show(mailbox)">
{{ mailbox.usage | prettyByteSize }}
</td>
<td class="text-right no-wrap">
<button class="btn btn-xs btn-default" ng-click="mailboxes.edit.show(mailbox)"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="mailboxes.remove.show(mailbox)"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
</table>
<button class="btn btn-default btn-outline btn-xs" ng-click="mailboxes.showPrevPage()" ng-class="{ 'btn-primary': mailboxes.currentPage > 1 }" ng-disabled="mailboxes.busy || mailboxes.currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline btn-xs" ng-click="mailboxes.showNextPage()" ng-class="{ 'btn-primary': mailboxes.perPage <= mailboxes.mailboxes.length }" ng-disabled="mailboxes.busy || mailboxes.perPage > mailboxes.mailboxes.length">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
</div>
</div>
<br/>
<br/>
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailinglists.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailinglists.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-list"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<div class="pull-right">
<button class="btn btn-default btn-outline" ng-click="mailinglists.showPrevPage()" ng-disabled="mailinglists.busy || mailinglists.currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline" ng-click="mailinglists.showNextPage()" ng-disabled="mailinglists.busy || mailinglists.perPage > mailinglists.mailinglists.length">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
<div class="text-left">
<h3 style="margin-bottom: 15px;">{{ 'email.incoming.mailinglists.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="mailinglists.add.show()" ng-disabled="!domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}"><i class="fa fa-list"></i> {{ 'email.incoming.mailboxes.addAction' | tr }}</button>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailinglists.search" ng-model-options="{ debounce: 1000 }" ng-change="mailinglists.updateFilter()" />
</h3>
</div>
<input class="form-control pull-right" style="width: 200px;" placeholder="{{ 'main.searchPlaceholder' | tr }}" type="text" ng-model="mailinglists.search" ng-model-options="{ debounce: 1000 }" ng-change="mailinglists.updateFilter()" />
</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
{{ 'email.incoming.mailinglists.description' | tr }}
<br/>
<br/>
<table class="table table-hover">
<thead>
<tr>
<th style="width: 0.5%;"></th>
<th>{{ 'email.incoming.mailinglists.name' | tr }}</th>
<th>{{ 'email.incoming.mailinglists.members' | tr }}</th>
<th class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="list in mailinglists.mailinglists | filter:mailinglists.search | orderBy:'name'" ng-class="{'text-muted': !list.active}">
<td>
<i class="fas fa-door-closed" ng-show="list.membersOnly" uib-tooltip="{{ 'email.incoming.mailinglists.membersOnlyTooltip' | tr }}"></i>
<i class="fas fa-door-open" ng-show="!list.membersOnly" uib-tooltip="{{ 'email.incoming.mailinglists.everyoneTooltip' | tr }}"></i>
</td>
<td class="hand" ng-click="mailinglists.edit.show(list)">
{{ list.name }}
</td>
<td class="hand" ng-click="mailinglists.edit.show(list)">
{{ list.members.join(', ') }}
</td>
<td class="text-right no-wrap">
<button class="btn btn-xs btn-default" ng-click="mailinglists.edit.show(list)"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="mailinglists.remove.show(list)"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<br/>
<div class="text-left">
<h3>{{ 'email.incoming.catchall.title' | tr }}</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12" ng-bind-html=" 'email.incoming.catchall.description' | tr "></div>
</div>
<br/>
<div class="row" ng-hide="config.features.emailPremium">
<div class="col-md-12" ng-bind-html=" 'email.incoming.catchall.subscriptionRequired' | tr "></div>
</div>
<div class="row" ng-show="config.features.emailPremium">
<div class="col-md-6">
<multiselect ng-model="catchall.mailboxes" options="mailbox.name for mailbox in catchall.availableMailboxes" data-compare-by="name" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<button class="btn btn-outline btn-primary" ng-click="catchall.submit()" ng-disabled="catchall.busy || !domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}">
<i class="fa fa-circle-notch fa-spin" ng-show="catchall.busy"></i> {{ 'email.incoming.catchall.saveAction' | tr }}
</button>
</div>
</div>
</div>
</uib-tab>
<uib-tab index="1" heading="{{ 'email.outbound.tabTitle' | tr }}">
<div class="card card-large" style="margin-bottom: 15px;">
<h4>{{ 'email.outbound.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#relay-outbound-mails" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
<div class="row">
<div class="col-md-12" ng-bind-html=" 'email.outbound.description' | tr "></div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<select class="form-control" style="width: 50%;" ng-model="mailRelay.preset" ng-options="a.name for a in mailRelayPresets track by a.provider" ng-change="mailRelay.presetChanged()"></select>
</div>
<p class="small text-danger" ng-show="mailRelay.preset.provider === 'noop'">
<span ng-if="domain.domain === config.adminDomain">{{ 'email.outbound.noopAdminDomainWarning' | tr }}</span>
<span ng-if="domain.domain !== config.adminDomain">{{ 'email.outbound.noopNonAdminDomainWarning' | tr }}</span>
</p>
</div>
</div>
<div class="row" ng-show="usesExternalServer(mailRelay.preset.provider)">
<div class="col-md-6">
<div>
<form name="mailRelayForm" role="form" ng-submit="mailRelay.submit()" autocomplete="off" novalidate>
<div class="form-group" ng-class="{ 'has-error': (mailRelayForm.host.$dirty && mailRelayForm.host.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.host' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.host.$dirty && mailRelay.error.host) || (mailRelayForm.host.$dirty && mailRelayForm.host.$invalid)">
<small ng-show="!mailRelayForm.host.$dirty && mailRelay.error.host">{{ mailRelay.error.host }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.host" name="host" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (mailRelayForm.port.$dirty && mailRelayForm.port.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.port' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.port.$dirty && mailRelay.error.port) || (mailRelayForm.port.$dirty && mailRelayForm.port.$invalid)">
<small ng-show="!mailRelayForm.port.$dirty && mailRelay.error.port">{{ mailRelay.error.port }}</small>
</div>
<input type="number" class="form-control" ng-model="mailRelay.relay.port" name="port" required>
</div>
<div class="checkbox" ng-show="mailRelay.relay.provider === 'external-smtp' || mailRelay.relay.provider === 'external-smtp-noauth'" >
<label>
<input type="checkbox" ng-model="mailRelay.relay.acceptSelfSignedCerts">{{ 'email.outbound.mailRelay.selfsignedCheckbox' | tr }}</input>
</label>
</div>
<!-- Postmark, Sendgrid, SparkPost -->
<div ng-show="usesTokenAuth(mailRelay.relay.provider)" class="form-group" ng-class="{ 'has-error': (mailRelayForm.serverApiToken.$dirty && mailRelayForm.serverApiToken.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.apiTokenOrKey' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.serverApiToken.$dirty && mailRelay.error.serverApiToken) || (mailRelayForm.serverApiToken.$dirty && mailRelayForm.serverApiToken.$invalid)">
<small ng-show="!mailRelayForm.serverApiToken.$dirty && mailRelay.error.serverApiToken">{{ mailRelay.error.serverApiToken }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.serverApiToken" name="serverApiToken" ng-required="usesTokenAuth(mailRelay.relay.provider)">
</div>
<!-- Other -->
<div ng-show="usesPasswordAuth(mailRelay.relay.provider)" class="form-group" ng-class="{ 'has-error': (mailRelayForm.username.$dirty && mailRelayForm.username.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.username' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.username.$dirty && mailRelay.error.username) || (mailRelayForm.username.$dirty && mailRelayForm.username.$invalid)">
<small ng-show="!mailRelayForm.username.$dirty && mailRelay.error.username">{{ mailRelay.error.username }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.username" name="username" ng-required="usesPasswordAuth(mailRelay.relay.provider)">
</div>
<div ng-show="usesPasswordAuth(mailRelay.relay.provider)" class="form-group" ng-class="{ 'has-error': (mailRelayForm.password.$dirty && mailRelayForm.password.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.password' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.password.$dirty && mailRelay.error.password) || (mailRelayForm.password.$dirty && mailRelayForm.password.$invalid)">
<small ng-show="!mailRelayForm.password.$dirty && mailRelay.error.password">{{ mailRelay.error.password }}</small>
</div>
<input type="password" class="form-control" ng-model="mailRelay.relay.password" name="password" ng-required="usesPasswordAuth(mailRelay.relay.provider)">
</div>
<input class="ng-hide" type="submit" ng-disabled="mailRelayForm.$invalid"/>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<button class="btn btn-primary" ng-click="mailRelay.submit()" ng-disabled="(usesExternalServer(mailRelay.preset.provider) && (!mailRelayForm.$dirty || mailRelayForm.$invalid)) || mailRelay.busy"><i class="fa fa-circle-notch fa-spin" ng-show="mailRelay.busy"></i> {{ 'email.outbound.mailRelay.saveAction' | tr }}</button>
<span class="has-error text-center" ng-show="mailRelay.error">{{ mailRelay.error }}</span>
<span class="text-success text-center text-bold" ng-show="mailRelay.success">{{ 'email.outbound.mailRelay.saveSuccess' | tr }}</span>
</div>
</div>
<div class="row" ng-show="mailRelay.preset.spfDoc">
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
{{ 'email.incoming.mailinglists.description' | tr }}
<br/>
<div class="col-md-12">
<span class="text-info" ng-bind-html="'email.outbound.mailRelay.spfDocInfo' | tr:{ name: mailRelay.preset.name, spfDocsLink: mailRelay.preset.spfDoc }"></span>
<br/>
<table class="table table-hover">
<thead>
<tr>
<th style="width: 0.5%;"></th>
<th>{{ 'email.incoming.mailinglists.name' | tr }}</th>
<th>{{ 'email.incoming.mailinglists.members' | tr }}</th>
<th class="text-right">{{ 'main.actions' | tr }}</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="list in mailinglists.mailinglists | filter:mailinglists.search | orderBy:'name'" ng-class="{'text-muted': !list.active}">
<td>
<i class="fas fa-door-closed" ng-show="list.membersOnly" uib-tooltip="{{ 'email.incoming.mailinglists.membersOnlyTooltip' | tr }}"></i>
<i class="fas fa-door-open" ng-show="!list.membersOnly" uib-tooltip="{{ 'email.incoming.mailinglists.everyoneTooltip' | tr }}"></i>
</td>
<td class="hand" ng-click="mailinglists.edit.show(list)">
{{ list.name }}
</td>
<td class="hand" ng-click="mailinglists.edit.show(list)">
{{ list.members.join(', ') }}
</td>
<td class="text-right no-wrap">
<button class="btn btn-xs btn-default" ng-click="mailinglists.edit.show(list)"><i class="fa fa-pencil-alt"></i></button>
<button class="btn btn-xs btn-danger" ng-click="mailinglists.remove.show(list)"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
</table>
<div class="pull-right">
<button class="btn btn-default btn-outline btn-xs" ng-click="mailinglists.showPrevPage()" ng-class="{ 'btn-primary': mailinglists.currentPage > 1 }" ng-disabled="mailinglists.busy || mailinglists.currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline btn-xs" ng-click="mailinglists.showNextPage()" ng-class="{ 'btn-primary': mailinglists.perPage <= mailinglists.mailinglists.length }" ng-disabled="mailinglists.busy || mailinglists.perPage > mailinglists.mailinglists.length">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
</div>
</div>
</uib-tab>
<br/>
<uib-tab index="2" heading="{{ 'email.settings.tabTitle' | tr }}">
<div class="card card-large" style="margin-bottom: 15px;">
<h4>{{ 'email.masquerading.title' | tr }}</h4>
<div class="text-left">
<h3>{{ 'email.incoming.catchall.title' | tr }}</h3>
</div>
<div class="row">
<div class="col-md-9" ng-bind-html=" 'email.masquerading.description' | tr "></div>
<div class="col-md-3">
<button class="pull-right" ng-class="domain.mailConfig.mailFromValidation ? 'btn btn-danger' : 'btn btn-primary'" ng-disabled="mailFromValidation.busy" ng-click="mailFromValidation.submit()">
<i class="fa fa-circle-notch fa-spin" ng-show="mailFromValidation.busy"></i> {{ domain.mailConfig.mailFromValidation ? ('email.masquerading.enableAction' | tr) : ('email.masquerading.disableAction' | tr) }}
</button>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12" ng-bind-html=" 'email.incoming.catchall.description' | tr "></div>
</div>
<br/>
<div class="row" ng-hide="config.features.emailPremium">
<div class="col-md-12" ng-bind-html=" 'email.incoming.catchall.subscriptionRequired' | tr "></div>
</div>
<div class="row" ng-show="config.features.emailPremium">
<div class="col-md-6">
<multiselect ng-model="catchall.mailboxes" options="mailbox.name for mailbox in catchall.availableMailboxes" data-compare-by="name" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
<button class="btn btn-outline btn-primary" ng-click="catchall.submit()" ng-disabled="catchall.busy || !domain.mailConfig.enabled" tooltip-enable="!domain.mailConfig.enabled" uib-tooltip="{{ 'email.incoming.mailboxes.disabledTooltip' | tr }}">
<i class="fa fa-circle-notch fa-spin" ng-show="catchall.busy"></i> {{ 'email.incoming.catchall.saveAction' | tr }}
</button>
</div>
</div>
</div>
</uib-tab>
<uib-tab index="'outbound'" ng-if="user.isAtLeastAdmin" select="setView('outbound')" heading="{{ 'email.outbound.tabTitle' | tr }}">
<div class="card card-large" style="margin-bottom: 15px;">
<h4>{{ 'email.outbound.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#relay-outbound-mails" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
<div class="row">
<div class="col-md-12" ng-bind-html=" 'email.outbound.description' | tr "></div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<select class="form-control" style="width: 50%;" ng-model="mailRelay.preset" ng-options="a.name for a in mailRelayPresets track by a.provider" ng-change="mailRelay.presetChanged()"></select>
</div>
<p class="small text-danger" ng-show="mailRelay.preset.provider === 'noop'">
<span ng-if="domain.domain === config.adminDomain">{{ 'email.outbound.noopAdminDomainWarning' | tr }}</span>
<span ng-if="domain.domain !== config.adminDomain">{{ 'email.outbound.noopNonAdminDomainWarning' | tr }}</span>
</p>
</div>
</div>
<div class="card card-large">
<h4>{{ 'email.signature.title' | tr }}</h4>
<p ng-bind-html=" 'email.signature.description' | tr "></p>
<div class="row" ng-hide="config.features.emailPremium">
<div class="col-md-12" ng-bind-html=" 'email.signature.subscriptionRequired' | tr "></div>
</div>
<div class="row" ng-show="config.features.emailPremium">
<div class="col-md-12">
<form role="form" name="bannerForm" ng-submit="banner.submit()" autocomplete="off">
<fieldset>
<div class="form-group">
<label class="control-label" style="width: 100%">{{ 'email.signature.plainTextFormat' | tr }}</label>
<textarea ng-model="banner.text" class="form-control" rows="4"></textarea>
<div class="row" ng-show="usesExternalServer(mailRelay.preset.provider)">
<div class="col-md-6">
<div>
<form name="mailRelayForm" role="form" ng-submit="mailRelay.submit()" autocomplete="off" novalidate>
<div class="form-group" ng-class="{ 'has-error': (mailRelayForm.host.$dirty && mailRelayForm.host.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.host' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.host.$dirty && mailRelay.error.host) || (mailRelayForm.host.$dirty && mailRelayForm.host.$invalid)">
<small ng-show="!mailRelayForm.host.$dirty && mailRelay.error.host">{{ mailRelay.error.host }}</small>
</div>
<div class="form-group">
<label class="control-label" style="width: 100%">{{ 'email.signature.htmlFormat' | tr }}</label>
<textarea ng-model="banner.html" class="form-control" rows="4"></textarea>
<input type="text" class="form-control" ng-model="mailRelay.relay.host" name="host" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (mailRelayForm.port.$dirty && mailRelayForm.port.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.port' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.port.$dirty && mailRelay.error.port) || (mailRelayForm.port.$dirty && mailRelayForm.port.$invalid)">
<small ng-show="!mailRelayForm.port.$dirty && mailRelay.error.port">{{ mailRelay.error.port }}</small>
</div>
<input type="number" class="form-control" ng-model="mailRelay.relay.port" name="port" required>
</div>
<input class="ng-hide" type="submit" ng-disabled="banner.$invalid || banner.busy"/>
</fieldset>
<div class="checkbox" ng-show="mailRelay.relay.provider === 'external-smtp' || mailRelay.relay.provider === 'external-smtp-noauth'" >
<label>
<input type="checkbox" ng-model="mailRelay.relay.acceptSelfSignedCerts">{{ 'email.outbound.mailRelay.selfsignedCheckbox' | tr }}</input>
</label>
</div>
<!-- Postmark, Sendgrid, SparkPost -->
<div ng-show="usesTokenAuth(mailRelay.relay.provider)" class="form-group" ng-class="{ 'has-error': (mailRelayForm.serverApiToken.$dirty && mailRelayForm.serverApiToken.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.apiTokenOrKey' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.serverApiToken.$dirty && mailRelay.error.serverApiToken) || (mailRelayForm.serverApiToken.$dirty && mailRelayForm.serverApiToken.$invalid)">
<small ng-show="!mailRelayForm.serverApiToken.$dirty && mailRelay.error.serverApiToken">{{ mailRelay.error.serverApiToken }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.serverApiToken" name="serverApiToken" ng-required="usesTokenAuth(mailRelay.relay.provider)">
</div>
<!-- Other -->
<div ng-show="usesPasswordAuth(mailRelay.relay.provider)" class="form-group" ng-class="{ 'has-error': (mailRelayForm.username.$dirty && mailRelayForm.username.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.username' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.username.$dirty && mailRelay.error.username) || (mailRelayForm.username.$dirty && mailRelayForm.username.$invalid)">
<small ng-show="!mailRelayForm.username.$dirty && mailRelay.error.username">{{ mailRelay.error.username }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.username" name="username" ng-required="usesPasswordAuth(mailRelay.relay.provider)">
</div>
<div ng-show="usesPasswordAuth(mailRelay.relay.provider)" class="form-group" ng-class="{ 'has-error': (mailRelayForm.password.$dirty && mailRelayForm.password.$invalid) }">
<label class="control-label">{{ 'email.outbound.mailRelay.password' | tr }}</label>
<div class="control-label" ng-show="(!mailRelayForm.password.$dirty && mailRelay.error.password) || (mailRelayForm.password.$dirty && mailRelayForm.password.$invalid)">
<small ng-show="!mailRelayForm.password.$dirty && mailRelay.error.password">{{ mailRelay.error.password }}</small>
</div>
<input type="password" class="form-control" ng-model="mailRelay.relay.password" name="password" ng-required="usesPasswordAuth(mailRelay.relay.provider)" password-reveal>
</div>
<input class="ng-hide" type="submit" ng-disabled="mailRelayForm.$invalid"/>
</form>
</div>
</div>
<div class="row">
<div class="col-md-12 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="banner.submit()" ng-disabled="banner.$invalid || banner.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="banner.busy"></i> {{ 'email.signature.saveAction' | tr }}
</div>
<div class="row">
<div class="col-md-12">
<button class="btn btn-primary pull-right" ng-click="mailRelay.submit()" ng-disabled="(usesExternalServer(mailRelay.preset.provider) && (!mailRelayForm.$dirty || mailRelayForm.$invalid)) || mailRelay.busy"><i class="fa fa-circle-notch fa-spin" ng-show="mailRelay.busy"></i> {{ 'email.outbound.mailRelay.saveAction' | tr }}</button>
<span class="has-error text-center" ng-show="mailRelay.error">{{ mailRelay.error }}</span>
<span class="text-success text-center text-bold" ng-show="mailRelay.success">{{ 'email.outbound.mailRelay.saveSuccess' | tr }}</span>
</div>
</div>
<div class="row" ng-show="mailRelay.preset.spfDoc">
<br/>
<div class="col-md-12">
<span class="text-info" ng-bind-html="'email.outbound.mailRelay.spfDocInfo' | tr:{ name: mailRelay.preset.name, spfDocsLink: mailRelay.preset.spfDoc }"></span>
</div>
</div>
</div>
</uib-tab>
<uib-tab index="'settings'" select="setView('settings')" heading="{{ 'email.settings.tabTitle' | tr }}">
<div class="card card-large" style="margin-bottom: 15px;">
<h4>{{ 'email.masquerading.title' | tr }}</h4>
<p ng-bind-html=" 'email.masquerading.description' | tr "></p>
<div class="row">
<div class="col-md-12 text-right">
<button class="pull-right" ng-class="domain.mailConfig.mailFromValidation ? 'btn btn-danger' : 'btn btn-primary'" ng-disabled="mailFromValidation.busy" ng-click="mailFromValidation.submit()">
<i class="fa fa-circle-notch fa-spin" ng-show="mailFromValidation.busy"></i> {{ domain.mailConfig.mailFromValidation ? ('email.masquerading.enableAction' | tr) : ('email.masquerading.disableAction' | tr) }}
</button>
</div>
</div>
</div>
<div class="card card-large">
<h4>{{ 'email.signature.title' | tr }}</h4>
<p ng-bind-html=" 'email.signature.description' | tr "></p>
<div class="row" ng-hide="config.features.emailPremium">
<div class="col-md-12" ng-bind-html=" 'email.signature.subscriptionRequired' | tr "></div>
</div>
<div class="row" ng-show="config.features.emailPremium">
<div class="col-md-12">
<form role="form" name="bannerForm" ng-submit="banner.submit()" autocomplete="off">
<fieldset>
<div class="form-group">
<label class="control-label" style="width: 100%">{{ 'email.signature.plainTextFormat' | tr }}</label>
<textarea ng-model="banner.text" class="form-control" rows="4"></textarea>
</div>
<div class="form-group">
<label class="control-label" style="width: 100%">{{ 'email.signature.htmlFormat' | tr }}</label>
<textarea ng-model="banner.html" class="form-control" rows="4"></textarea>
</div>
<input class="ng-hide" type="submit" ng-disabled="banner.$invalid || banner.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="banner.submit()" ng-disabled="banner.$invalid || banner.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="banner.busy"></i> {{ 'email.signature.saveAction' | tr }}
</button>
</div>
</div>
</div>
</uib-tab>
<uib-tab index="'status'" ng-if="user.isAtLeastAdmin" select="setView('status')" heading="{{ 'email.status.tabTitle' | tr }}">
<!-- nothing to show if incoming mail is disabled and using a relay -->
<div class="card card-large" style="margin-bottom: 15px;" ng-hide="!domain.mailConfig.enabled && domain.mailConfig.relay.provider !== 'cloudron-smtp'">
<div class="row">
<div class="col-md-12">
<h4>{{ 'email.dnsStatus.title' | tr }}
<button class="btn btn-xs btn-primary btn-outline pull-right" ng-click="incomingEmail.setDnsRecords()">
<i class="fa fa-circle-notch fa-spin" ng-show="incomingEmail.setupDnsBusy"></i> {{ 'email.dnsStatus.reSetupAction' | tr }}
</button>
</div>
</div>
</div>
</uib-tab>
<uib-tab index="3" heading="{{ 'email.status.tabTitle' | tr }}">
<!-- nothing to show if incoming mail is disabled and using a relay -->
<div class="card card-large" style="margin-bottom: 15px;" ng-hide="!domain.mailConfig.enabled && domain.mailConfig.relay.provider !== 'cloudron-smtp'">
<div class="row">
<div class="col-md-12">
<h4>{{ 'email.dnsStatus.title' | tr }}
<button class="btn btn-xs btn-primary btn-outline pull-right" ng-click="incomingEmail.setDnsRecords()">
<i class="fa fa-circle-notch fa-spin" ng-show="incomingEmail.setupDnsBusy"></i> {{ 'email.dnsStatus.reSetupAction' | tr }}
</button>
</h4>
<span ng-bind-html=" 'email.dnsStatus.description' | tr "></span>
<br/>
<br/>
<div ng-repeat="record in expectedDnsRecordsTypes">
<div class="row" ng-if="expectedDnsRecords[record.value].expected">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="expectedDnsRecords[record.value].status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_dns_{{ record.value }}">{{ record.name }} record</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!expectedDnsRecords[record.value].status"><i class="fa fa-sync-alt" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_dns_{{ record.value }}" class="panel-collapse collapse">
<div class="panel-body">
<p ng-show="record.name === 'MX' && domain.provider === 'namecheap'">{{ 'email.dnsStatus.namecheapInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="record.name === 'PTR'">{{ 'email.dnsStatus.ptrInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/troubleshooting/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.hostname' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].name }}</tt></b></p>
<p ng-hide="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.domain' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].domain }}</tt></b></p>
<p>{{ 'email.dnsStatus.type' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].type }}</tt></b></p>
<p style="overflow: auto; white-space: nowrap;">{{ 'email.dnsStatus.expected' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].expected }}</tt></b></p>
<p style="overflow: auto; white-space: nowrap;">{{ 'email.dnsStatus.current' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].value ? expectedDnsRecords[record.value].value : ('['+('email.dnsStatus.recordNotSet' | tr)+']') }}</tt></b></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="card card-large" style="margin-bottom: 15px;" ng-if="domain.mailConfig.relay.provider !== 'noop'">
<div class="row">
<div class="col-md-12">
<h4>{{ 'email.smtpStatus.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/troubleshooting/#mail-smtp" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
<div class="row">
</h4>
<span ng-bind-html="'email.dnsStatus.description' | tr:{ emailDnsDocsLink:'https://docs.cloudron.io/email/#dns-records'}"></span>
<br/>
<br/>
<div ng-repeat="record in expectedDnsRecordsTypes">
<div class="row" ng-if="expectedDnsRecords[record.value].expected">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="domain.mailStatus.relay.status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_outbound_smtp">
{{ domain.mailConfig.relay.provider === 'cloudron-smtp' ? ('email.smtpStatus.outboudDirect' | tr) : ('email.smtpStatus.outboudRelay' | tr) }}
</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!domain.mailStatus.relay.status"><i class="fa fa-sync-alt" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
<i ng-hide="refreshBusy" ng-class="expectedDnsRecords[record.value].status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_dns_{{ record.value }}">{{ record.name }} record</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!expectedDnsRecords[record.value].status"><i class="fa fa-sync-alt" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_outbound_smtp" class="panel-collapse collapse">
<div id="collapse_dns_{{ record.value }}" class="panel-collapse collapse">
<div class="panel-body">
<p><b> {{ domain.mailStatus.relay.value }} </b> </p>
</div>
</div>
</div>
</div>
<div class="row" ng-show="domain.mailConfig.relay.provider === 'cloudron-smtp'">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="domain.mailStatus.rbl.status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_rbl">{{ 'email.smtpStatus.blacklistCheck' | tr }}</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!domain.mailStatus.rbl.status"><i class="fa fa-sync-alt" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_rbl" class="panel-collapse collapse">
<div class="panel-body">
<div ng-show="domain.mailStatus.rbl.servers.length" ng-bind-html="'email.smtpStatus.blacklisted' | tr:{ ip: domain.mailStatus.rbl.ip }"></div>
<div ng-hide="domain.mailStatus.rbl.servers.length" ng-bind-html="'email.smtpStatus.notBlacklisted' | tr:{ ip: domain.mailStatus.rbl.ip }"></div>
<div ng-repeat="server in domain.mailStatus.rbl.servers">
<a ng-href="{{server.site}}" target="_blank">{{ server.name }}</a>
</div>
<p ng-show="record.name === 'MX' && domain.provider === 'namecheap'">{{ 'email.dnsStatus.namecheapInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/domains/#namecheap-dns" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="record.name === 'PTR'">{{ 'email.dnsStatus.ptrInfo' | tr }} <sup><a ng-href="https://docs.cloudron.io/troubleshooting/#ptr-record" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></p>
<p ng-show="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.hostname' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].name }}</tt></b></p>
<p ng-hide="expectedDnsRecords[record.value].name">{{ 'email.dnsStatus.domain' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].domain }}</tt></b></p>
<p>{{ 'email.dnsStatus.type' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].type }}</tt></b></p>
<p style="overflow: auto; white-space: nowrap;">{{ 'email.dnsStatus.expected' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].expected }}</tt></b></p>
<p style="overflow: auto; white-space: nowrap;">{{ 'email.dnsStatus.current' | tr }}: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].value ? expectedDnsRecords[record.value].value : ('['+('email.dnsStatus.recordNotSet' | tr)+']') }}</tt></b></p>
</div>
</div>
</div>
@@ -704,7 +731,51 @@
</div>
</div>
</div>
</div>
</uib-tab>
<div class="card card-large" style="margin-bottom: 15px;" ng-if="domain.mailConfig.relay.provider !== 'noop'">
<div class="row">
<div class="col-md-12">
<h4>{{ 'email.smtpStatus.title' | tr }} <sup><a ng-href="https://docs.cloudron.io/email/#smtp-status" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></h4>
<div class="row">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="domain.mailStatus.relay.status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_outbound_smtp">
{{ domain.mailConfig.relay.provider === 'cloudron-smtp' ? ('email.smtpStatus.outboudDirect' | tr) : ('email.smtpStatus.outboudRelay' | tr) }}
</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!domain.mailStatus.relay.status"><i class="fa fa-sync-alt" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_outbound_smtp" class="panel-collapse collapse">
<div class="panel-body">
<p><b> {{ domain.mailStatus.relay.value }} </b> </p>
</div>
</div>
</div>
</div>
<div class="row" ng-show="domain.mailConfig.relay.provider === 'cloudron-smtp'">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="domain.mailStatus.rbl.status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_rbl">{{ 'email.smtpStatus.blacklistCheck' | tr }}</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!domain.mailStatus.rbl.status"><i class="fa fa-sync-alt" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_rbl" class="panel-collapse collapse">
<div class="panel-body">
<div ng-show="domain.mailStatus.rbl.servers.length" ng-bind-html="'email.smtpStatus.blacklisted' | tr:{ ip: domain.mailStatus.rbl.ip }"></div>
<div ng-hide="domain.mailStatus.rbl.servers.length" ng-bind-html="'email.smtpStatus.notBlacklisted' | tr:{ ip: domain.mailStatus.rbl.ip }"></div>
<div ng-repeat="server in domain.mailStatus.rbl.servers">
<a ng-href="{{server.site}}" target="_blank">{{ server.name }}</a>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</uib-tab>
</uib-tabset>
</div>
+220 -8
View File
@@ -4,12 +4,33 @@
/* global $ */
/* global async */
angular.module('Application').controller('EmailController', ['$scope', '$location', '$translate', '$timeout', '$routeParams', 'Client', function ($scope, $location, $translate, $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;
@@ -332,6 +353,172 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}
};
$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: '',
@@ -387,13 +574,15 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
incomingDomains: [],
aliases: [],
active: true,
enablePop3: false,
addAlias: function (event) {
event.preventDefault();
$scope.mailboxes.edit.aliases.push({
name: '',
domain: domainName
domain: domainName,
reversedSortingNotation: 'z'.repeat(100) // quick and dirty to ensure newly added are on bottom
});
},
@@ -405,8 +594,9 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
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 = angular.copy(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');
},
@@ -414,8 +604,15 @@ 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, $scope.mailboxes.edit.owner.type, $scope.mailboxes.edit.active, 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;
@@ -579,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;
@@ -675,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);
@@ -755,10 +955,16 @@ 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
@@ -775,8 +981,13 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
$scope.owners.push({ display: g.name, id: g.id, type: 'group' });
});
$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.getDomains(function (error, result) {
if (error) return console.error('Unable to get view domain.', error);
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];
@@ -792,6 +1003,7 @@ angular.module('Application').controller('EmailController', ['$scope', '$locatio
}, 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();
}]);
+100 -108
View File
@@ -71,6 +71,35 @@
</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">
@@ -80,20 +109,39 @@
</div>
<div class="modal-body">
<p ng-bind-html=" 'emails.solrConfig.description' | tr "></p>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="solrConfig.enabled">{{ 'emails.solrConfig.enableSolrCheckbox' | tr }}</input>
</label>
</div>
<!-- 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="solrConfig.submit()" ng-disabled="(!solrConfig.currentConfig.enabled && !solrConfig.enoughMemory) || solrConfig.enabled === solrConfig.currentConfig.enabled || solrConfig.busy"><i class="fa fa-circle-notch fa-spin" ng-show="solrConfig.busy"></i> {{ 'main.dialog.save' | 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>
@@ -160,11 +208,16 @@
</div>
</div>
<div class="content content-large">
<div class="content">
<div class="text-left">
<h1>
{{ 'emails.title' | tr }}
<a class="btn btn-default pull-right" href="/logs.html?id=mail" target="_blank">{{ 'main.action.logs' | tr }}</a>
<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>
@@ -172,7 +225,7 @@
<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>
@@ -192,7 +245,7 @@
<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">
@@ -216,13 +269,31 @@
</div>
</div>
<br/>
<div class="text-left" ng-show="user.isAtLeastOwner">
<h3>{{ 'emails.mailboxSharing.title' | tr }}</h3>
</div>
<div class="text-left" ng-show="user.role === 'owner'">
<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">
<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>
<div class="text-left" ng-show="user.isAtLeastOwner">
<h3>{{ 'emails.settings.title' | tr }}</h3>
</div>
<div class="card card-large" style="margin-bottom: 15px;">
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
<p ng-bind-html=" 'emails.settings.info' | tr "></p>
<div class="row">
@@ -240,6 +311,12 @@
<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>
@@ -249,6 +326,9 @@
<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 }}
@@ -263,103 +343,15 @@
<div class="row" ng-show="mailLocation.busy">
<div class="col-md-12" style="margin-top: 10px;">
{{ 'emails.settings.changeDomainProgress' | tr }}
<div class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ mailLocation.percent }}%"></div>
<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>
<br/>
<div class="text-left" ng-show="user.role === 'owner'">
<h3>{{ 'emails.eventlog.title' | tr }}</h3>
</div>
<div class="row" ng-show="user.role === 'owner'">
<div class="col-md-12">
<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()" />
<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="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 class="card card-large" style="margin-top: 10px; margin-bottom: 15px;" ng-show="user.role === 'owner'">
<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>
</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%">{{ 'emails.eventlog.time' | tr }}</th>
<th style="width: 75%">{{ 'emails.eventlog.details' | tr }}</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>
{{ 'emails.eventlog.empty' | tr }}
<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="{{ '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'" 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>
<span ng-show="eventlog.type === 'bounce'">{{ 'emails.eventlog.type.bounceInfo' | tr:eventlog }}</span>
<span ng-show="eventlog.type === 'deferred'">{{ 'emails.eventlog.type.deferredInfo' | tr:eventlog }}</span>
<span ng-show="eventlog.type === 'queued'">
<span ng-show="eventlog.direction === 'inbound'">{{ 'emails.eventlog.type.inboundInfo' | tr:eventlog }}</span>
<span ng-show="eventlog.direction === 'outbound'">{{ 'emails.eventlog.type.outboundInfo' | tr:eventlog }}</span>
</span>
<span ng-show="eventlog.type === 'received'">{{ 'emails.eventlog.type.receivedInfo' | tr:eventlog }}</span>
<span ng-show="eventlog.type === 'delivered'">{{ 'emails.eventlog.type.deliveredInfo' | tr:eventlog }}</span>
<span ng-show="eventlog.type === 'denied'">{{ 'emails.eventlog.type.deniedInfo' | tr:eventlog }}</span>
<span ng-show="eventlog.type === 'spam-learn'">{{ 'emails.eventlog.type.spamFilterTrainedInfo' | tr:eventlog }}</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>
+103 -72
View File
@@ -3,74 +3,13 @@
/* global $, angular, TASK_TYPES */
angular.module('Application').controller('EmailsController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastMailManager) $location.path('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.domains = [];
$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' },
];
$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();
}
};
// this is required because we need to rewrite the MAIL_SERVER_NAME env var
$scope.reconfigureEmailApps = function () {
var installedApps = Client.getInstalledApps();
@@ -91,10 +30,22 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
subdomain: '',
taskId: null,
percent: 0,
taskMinutesActive: 0,
message: '',
errorMessage: '',
reconfigure: false,
stopTask: function () {
Client.getLatestTaskByType(TASK_TYPES.TASK_CHANGE_MAIL_LOCATION, function (error, task) {
if (error) return console.error(error);
if (!task.id) return;
Client.stopTask(task.id, function (error) {
if (error) console.error(error);
});
});
},
refresh: function () {
Client.getMailLocation(function (error, location) {
if (error) return console.error('Failed to get max email location', error);
@@ -135,6 +86,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$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();
@@ -145,6 +97,7 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.mailLocation.busy = true;
$scope.mailLocation.percent = data.percent;
$scope.mailLocation.message = data.message;
$scope.mailLocation.taskMinutesActive = moment().diff(moment(data.creationTime), 'minutes');
window.setTimeout($scope.mailLocation.updateStatus, 1000);
});
@@ -215,6 +168,33 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
}
};
$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: {},
@@ -246,17 +226,17 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$('#solrConfigModal').modal('show');
},
submit: function () {
submit: function (newState) {
$scope.solrConfig.busy = true;
Client.setSolrConfig($scope.solrConfig.enabled, function (error) {
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 = $scope.solrConfig.enabled;
$scope.solrConfig.running = $scope.solrConfig.enabled;
$scope.solrConfig.currentConfig.enabled = newState;
$scope.solrConfig.running = newState;
$timeout(function () { $scope.solrConfig.refresh(); }, 20000); // get real values after 20 seconds
@@ -334,6 +314,55 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
}
},
$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: {},
@@ -421,12 +450,14 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
$scope.domains = domains;
$scope.ready = true;
if ($scope.user.role === 'owner') $scope.activity.refresh();
$scope.mailLocation.refresh();
$scope.maxEmailSize.refresh();
$scope.spamConfig.refresh();
$scope.solrConfig.refresh();
if ($scope.user.isAtLeastOwner) {
$scope.mailLocation.refresh();
$scope.maxEmailSize.refresh();
$scope.mailboxSharing.refresh();
$scope.spamConfig.refresh();
$scope.solrConfig.refresh();
$scope.acl.refresh();
}
refreshDomainStatuses();
});
+8 -362
View File
@@ -1,7 +1,7 @@
'use strict';
/* global angular:false */
/* global $:false */
/* 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('/'); });
@@ -17,6 +17,8 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
$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' },
@@ -56,6 +58,9 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
{ 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' },
@@ -82,365 +87,6 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
$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_IMPORT = 'app.import';
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_APP_START = 'app.start';
var ACTION_APP_STOP = 'app.stop';
var ACTION_APP_RESTART = 'app.restart';
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_LOGOUT = 'user.logout';
var ACTION_USER_REMOVE = 'user.remove';
var ACTION_USER_UPDATE = 'user.update';
var ACTION_USER_TRANSFER = 'user.transfer';
var ACTION_MAIL_LOCATION = 'mail.location';
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_VOLUME_ADD = 'volume.add';
var ACTION_VOLUME_UPDATE = 'volume.update';
var ACTION_VOLUME_REMOVE = 'volume.remove';
var ACTION_DYNDNS_UPDATE = 'dyndns.update';
var ACTION_SYSTEM_CRASH = 'system.crash';
var data = eventLog.data;
var errorMessage = data.errorMessage;
var details, app;
function appName(app) {
return (app.label || app.fqdn || app.location) + ' (' + app.manifest.title + ')';
}
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 '';
app = data.app;
var q = function (x) {
return '"' + x + '"';
};
if ('accessRestriction' in data) { // since it can be null
return 'Access restriction of ' + appName(app) + ' was changed';
} else if (data.label) {
return 'Label of ' + appName(app) + ' was set to ' + q(data.label);
} else if (data.tags) {
return 'Tags of ' + appName(app) + ' was set to ' + q(data.tags.join(','));
} else if (data.icon) {
return 'Icon of ' + appName(app) + ' was changed';
} else if (data.memoryLimit) {
return 'Memory limit of ' + appName(app) + ' was set to ' + data.memoryLimit;
} else if (data.cpuShares) {
return 'CPU shares of ' + appName(app) + ' was set to ' + Math.round((data.cpuShares * 100)/1024) + '%';
} else if (data.env) {
return 'Env vars of ' + appName(app) + ' was changed';
} else if ('debugMode' in data) { // since it can be null
if (data.debugMode) {
return appName(app) + ' was placed in repair mode';
} else {
return appName(app) + ' was taken out of repair mode';
}
} else if ('enableBackup' in data) {
return 'Automatic backups of ' + appName(app) + ' was ' + (data.enableBackup ? 'enabled' : 'disabled');
} else if ('enableAutomaticUpdate' in data) {
return 'Automatic updates of ' + appName(app) + ' was ' + (data.enableAutomaticUpdate ? 'enabled' : 'disabled');
} else if ('reverseProxyConfig' in data) {
return 'Reverse proxy configuration of ' + appName(app) + ' was updated';
} else if ('cert' in data) {
if (data.cert) {
return 'Custom certificate was set for ' + appName(app);
} else {
return 'Certificate of ' + appName(app) + ' was reset';
}
} else if (data.location) {
if (data.fqdn !== data.app.fqdn) {
return 'Location of ' + appName(app) + ' 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 ' + appName(app) + ' was ' + (altFqdns.length ? 'set to ' + altFqdns.join(', ') : 'reset');
} else if (!angular.equals(data.aliasDomains, data.app.aliasDomains)) {
var aliasDomains = data.aliasDomains.map(function (a) { return a.fqdn; });
return 'Alias domains of ' + appName(app) + ' was ' + (aliasDomains.length ? 'set to ' + aliasDomains.join(', ') : 'reset');
} else if (!angular.equals(data.portBindings, data.app.portBindings)) {
return 'Port bindings of ' + appName(app) + ' was changed';
}
} else if ('dataDir' in data) {
if (data.dataDir) {
return 'Data directory of ' + appName(app) + ' was set ' + data.dataDir;
} else {
return 'Data directory of ' + appName(app) + ' was reset';
}
} else if ('icon' in data) {
if (data.icon) {
return 'Icon of ' + appName(app) + ' was set';
} else {
return 'Icon of ' + appName(app) + ' was reset';
}
} else if (('mailboxName' in data) && data.mailboxName !== data.app.mailboxName) {
if (data.mailboxName) {
return 'Mailbox of ' + appName(app) + ' was set to ' + q(data.mailboxName);
} else {
return 'Mailbox of ' + appName(app) + ' was reset';
}
}
return appName(app) + ' was re-configured';
}
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_IMPORT:
if (!data.app) return '';
details = data.app.manifest.title + ' was imported at ' + (data.app.fqdn || data.app.location);
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 ' + appName(data.app) + ' was re-configured'; // re-configure of email apps is more common?
case ACTION_APP_LOGIN: {
app = Client.getCachedAppSync(data.appId);
if (!app) return '';
return 'App ' + app.fqdn + ' logged in';
}
case ACTION_APP_OOM:
if (!data.app) return '';
return appName(data.app) + ' ran out of memory';
case ACTION_APP_DOWN:
if (!data.app) return '';
return appName(data.app) + ' is down';
case ACTION_APP_UP:
if (!data.app) return '';
return appName(data.app) + ' is back online';
case ACTION_APP_START:
if (!data.app) return '';
return appName(data.app) + ' was started';
case ACTION_APP_STOP:
if (!data.app) return '';
return appName(data.app) + ' was stopped';
case ACTION_APP_RESTART:
if (!data.app) return '';
return appName(data.app) + ' was restarted';
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 ? data.removedBoxBackups.length : '0') + ' 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_LOCATION:
return 'Mail server location was changed to ' + data.subdomain + (data.subdomain ? '.' : '') + data.domain;
case ACTION_MAIL_ENABLED:
return 'Mail was enabled for domain ' + data.domain;
case ACTION_MAIL_DISABLED:
return '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:
if (data.errorMessage) {
return 'Cloudron update errored. Error: ' + data.errorMessage;
} else {
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_USER_LOGOUT:
return (data.user ? data.user.username : data.userId) + ' logged out';
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';
case ACTION_VOLUME_ADD:
return 'Volume "' + data.volume.name + '" was added';
case ACTION_VOLUME_UPDATE:
return 'Volme "' + data.volume.name + '" was updated';
case ACTION_VOLUME_REMOVE:
return 'Volume "' + data.volume.name + '" was removed';
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(background, callback) {
callback = callback || function (error) { if (error) console.error(error); };
background = background || false;
@@ -456,7 +102,7 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
$scope.eventLogs = [];
result.forEach(function (e) {
$scope.eventLogs.push({ raw: e, details: eventLogDetails(e), source: eventLogSource(e) });
$scope.eventLogs.push({ raw: e, details: Client.eventLogDetails(e), source: Client.eventLogSource(e) });
});
callback();
+115 -21
View File
@@ -9,19 +9,19 @@
<form name="sysinfoForm" role="form" novalidate ng-submit="sysinfo.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/#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'">
{{ 'network.configureIp.providerGenericDescription' | tr }} <sup><a ng-href="https://api.cloudron.io/api/v1/helper/public_ip" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
{{ '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">{{ 'network.ip.address' | tr }}</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 -->
@@ -70,11 +70,58 @@
</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>
</div>
<div class="content">
<div class="text-left">
<h1>{{ 'network.title' | tr }}</h1>
</div>
<!-- IPv4 -->
<div class="text-left">
<h3>{{ 'network.ip.title' | tr }}</h3>
</div>
@@ -92,7 +139,7 @@
<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>
@@ -101,8 +148,8 @@
<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 }} ({{ 'network.ip.detected' | tr }})</span>
<span ng-show="sysinfo.ipv4">{{ sysinfo.ipv4 }}</span>
<span ng-show="!sysinfo.ipv4">{{ sysinfo.serverIPv4 }} ({{ 'network.ip.detected' | tr }})</span>
</div>
</div>
@@ -124,11 +171,12 @@
</div>
</div>
<div class="text-left" ng-show="user.role === 'owner'">
<!-- Firewall -->
<div class="text-left" ng-show="user.isAtLeastOwner">
<h3>{{ 'network.firewall.title' | tr }}</h3>
</div>
<div class="card" ng-show="user.role === 'owner'">
<div class="card" ng-show="user.isAtLeastOwner">
<div class="row">
<div class="col-xs-6">
<span class="text-muted">{{ 'network.firewall.blockedIpRanges' | tr }}</span>
@@ -139,6 +187,57 @@
</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>{{ 'network.dyndns.title' | tr }}</h3>
</div>
@@ -148,21 +247,16 @@
<div class="col-md-12">
<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; {{ 'network.dyndns.useLabel' | tr }}
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<span class="text-success text-bold" ng-show="dyndnsConfigure.success">{{ 'network.dyndns.saved' | tr }}</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> {{ 'main.dialog.save' | tr }}</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>
+115 -25
View File
@@ -16,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';
@@ -27,31 +35,112 @@ 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');
});
}
};
@@ -98,21 +187,21 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
$('#blocklistModal').modal('hide');
});
}
},
};
$scope.sysinfo = {
busy: false,
error: {},
serverIp: '',
serverIPv4: '',
provider: '',
ip: '',
ipv4: '',
ifname: '',
// configure dialog
newProvider: '',
newIp: '',
newIPv4: '',
newIfname: '',
refresh: function () {
@@ -120,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;
});
});
},
@@ -134,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');
@@ -149,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;
@@ -182,7 +271,8 @@ angular.module('Application').controller('NetworkController', ['$scope', '$locat
$scope.sysinfo.refresh();
$scope.dyndnsConfigure.refresh();
if ($scope.user.role === 'owner') $scope.blocklist.refresh();
$scope.ipv6Configure.refresh();
if ($scope.user.isAtLeastOwner) $scope.blocklist.refresh();
});
$('.modal-backdrop').remove();
+9 -10
View File
@@ -3,9 +3,11 @@
<div class="text-left">
<h1>{{ 'notifications.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" 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>
<button class="btn btn-default btn-outline pull-right" 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-default btn-outline pull-right" ng-click="showPrevPage()" ng-disabled="busy || currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<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>
@@ -21,14 +23,11 @@
</div>
</div>
<div class="card notification-item" ng-repeat="notification in 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 }">
<span ng-class="{'text-bold': !notification.acknowledged }">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
<!-- hidden for now since it seems overkill to have "unread" -->
<!-- <button class="btn btn-xs btn-default pull-right" ng-show="notification.acknowledged" ng-click="ack(notification, false, $event)" uib-tooltip="{{ 'notifications.dismissTooltip' | tr }}"><i class="fa fa-asterisk"></i></button> -->
<div uib-collapse="notification.isCollapsed" expanding="ack(notification, true)">
<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>
+9 -9
View File
@@ -3,7 +3,9 @@
/* global async */
/* global angular */
angular.module('Application').controller('NotificationsController', ['$scope', '$timeout', '$translate', '$interval', 'Client', function ($scope, $timeout, $translate, $interval, 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.notifications = [];
@@ -46,16 +48,14 @@ angular.module('Application').controller('NotificationsController', ['$scope', '
$scope.refresh();
};
$scope.ack = function (notification, acked, event) {
if (event) event.stopPropagation();
$scope.ack = function (notification) {
if (notification.acknowledged) return;
if (notification.acknowledged === acked) return;
Client.ackNotification(notification.id, acked, function (error) {
Client.ackNotification(notification.id, true, function (error) {
if (error) console.error(error);
notification.acknowledged = acked;
$scope.$parent.notificationAcknowledged(acked);
notification.acknowledged = true;
$scope.$parent.notificationAcknowledged();
});
};
@@ -70,7 +70,7 @@ angular.module('Application').controller('NotificationsController', ['$scope', '
console.error(error);
} else {
notification.acknowledged = true;
$scope.$parent.notificationAcknowledged(true);
$scope.$parent.notificationAcknowledged();
}
callback();
+42 -20
View File
@@ -7,32 +7,39 @@
<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">
<span ng-bind-html="'profile.changeAvatar.useGravatar' | tr:{ gravatarLink: 'https://gravatar.com/' }"></span>
</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="">
{{ 'profile.changeAvatar.useCustomPicture' | tr }}
</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/*"/>
</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.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> {{ 'main.dialog.save' | 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">
@@ -49,7 +56,7 @@
<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>
<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>
@@ -57,7 +64,7 @@
<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>
<input type="password" class="form-control" ng-model="passwordchange.newPassword" id="inputPasswordChangeNewPassword" name="newPassword" ng-minlength="8" ng-maxlength="256" required autofocus>
<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>
@@ -65,7 +72,7 @@
<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>
<input type="password" class="form-control" ng-model="passwordchange.newPasswordRepeat" id="inputPasswordChangeNewPasswordRepeat" name="newPasswordRepeat" required autofocus>
<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>
@@ -115,14 +122,24 @@
</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)}">
<input type="email" class="form-control" ng-model="fallbackEmailChange.email" id="inputfallbackEmailChangeEmail" name="email" required autofocus>
<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>
<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>
<input class="ng-hide" type="submit" ng-disabled="fallbackEmailChangeForm.$invalid"/>
</form>
</div>
@@ -214,7 +231,7 @@
<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>
@@ -354,6 +371,11 @@
{{ 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>
@@ -363,7 +385,7 @@
</tr>
<tr>
<td class="text-right" colspan="2" style="vertical-align: top;">
<br/>
<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>
+47 -15
View File
@@ -17,6 +17,14 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$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,
@@ -122,8 +130,8 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
busy: false,
error: {},
avatar: null,
useGravatar: '',
useGravatarOrig: '',
type: '',
typeOrig: '',
pictureChanged: false,
getBlobFromImg: function (img, callback) {
@@ -176,13 +184,13 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
});
}
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);
}
},
@@ -194,13 +202,19 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
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;
},
@@ -323,13 +337,19 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
$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();
@@ -342,16 +362,28 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
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();
@@ -612,7 +644,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
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; });
});
});
+20 -10
View File
@@ -20,15 +20,14 @@
</div>
</div>
<div class="form-group" ng-show="serviceConfigure.service.name === 'sftp'">
<div class="form-group">
<br>
<label class="control-label">{{ 'services.configure.accessControl' | tr }}</label>
<p class="small">{{ 'services.configure.accessControlDescription' | tr }}</p>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="serviceConfigure.requireAdmin"> {{ 'services.configure.requireAdminRoleLabel' | tr }}</input>
<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"/>
@@ -37,7 +36,7 @@
</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(serviceConfigure.memoryLimit)" ng-disabled="serviceConfigureForm.$invalid || serviceConfigure.busy"><i class="fa fa-circle-notch fa-spin" ng-show="serviceConfigure.busy"></i> {{ 'main.dialog.save' | 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>
@@ -77,7 +76,7 @@
</thead>
<tbody>
<tr>
<td><i class="fa fa-circle" uib-tooltip="active" style="color: #27CE65"></i></td>
<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>
@@ -87,7 +86,18 @@
</tr>
<tr ng-repeat="service in services | filter:{ isRedis: false } | orderBy:'name'">
<td>
<i class="fa fa-circle" uib-tooltip="{{ service.status }}" ng-style="{ color: service.status === 'active' ? '#27CE65' : (service.status === 'starting' ? '#f0ad4e' : '#d9534f') }" ng-show="service.status"></i>
<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">
@@ -103,7 +113,7 @@
</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' }"></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>
@@ -115,7 +125,7 @@
</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-style="{ color: service.status === 'active' ? '#27CE65' : (service.status === 'starting' ? '#f0ad4e' : '#d9534f') }" ng-show="service.status"></i>
<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">
@@ -131,7 +141,7 @@
</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' }"></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>
+21 -18
View File
@@ -20,7 +20,7 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
if (error) return console.log('Error getting status of ' + serviceName + ':' + error.message);
var service = $scope.services.find(function (s) { return s.name === serviceName; });
if (!service) $scope.services.find(function (s) { return s.name === serviceName; });
if (!service) $scope.services[serviceName] = service;
service.status = result.status;
service.config = result.config;
@@ -31,15 +31,15 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
});
}
function waitForActive(serviceName) {
refresh(serviceName, function (error, result) {
if (result.status === 'active') return;
setTimeout(function () { waitForActive(serviceName); }, 3000);
});
}
$scope.restartService = function (serviceName) {
function waitForActive(serviceName) {
refresh(serviceName, function (error, result) {
if (result.status === 'active') return;
setTimeout(function () { waitForActive(serviceName); }, 3000);
});
}
$scope.services.find(function (s) { return s.name === serviceName; }).status = 'starting';
Client.restartService(serviceName, function (error) {
@@ -69,16 +69,15 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
memoryLimit: 0,
memoryTicks: [],
// sftp only
requireAdmin: true,
recoveryMode: false,
show: function (service) {
$scope.serviceConfigure.reset();
$scope.serviceConfigure.service = service;
$scope.serviceConfigure.memoryLimit = service.config.memoryLimit;
$scope.serviceConfigure.recoveryMode = !!service.config.recoveryMode;
// TODO improve those
$scope.serviceConfigure.memoryTicks = [];
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
@@ -89,17 +88,17 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
$scope.serviceConfigure.memoryTicks.push(i * 1024 * 1024);
}
if (service.name === 'sftp') $scope.serviceConfigure.requireAdmin = !!service.config.requireAdmin;
$('#serviceConfigureModal').modal('show');
},
submit: function (memoryLimit) {
submit: function () {
$scope.serviceConfigure.busy = true;
$scope.serviceConfigure.error = null;
var data = { memoryLimit: memoryLimit };
if ($scope.serviceConfigure.service.name === 'sftp') data.requireAdmin = $scope.serviceConfigure.requireAdmin;
var data = {
memoryLimit: $scope.serviceConfigure.memoryLimit,
recoveryMode: $scope.serviceConfigure.recoveryMode
};
Client.configureService($scope.serviceConfigure.service.name, data, function (error) {
$scope.serviceConfigure.busy = false;
@@ -108,7 +107,11 @@ angular.module('Application').controller('ServicesController', ['$scope', '$loca
return;
}
refresh($scope.serviceConfigure.service.name);
if ($scope.serviceConfigure.recoveryMode === true) {
refresh($scope.serviceConfigure.service.name);
} else {
waitForActive($scope.serviceConfigure.service.name);
}
$('#serviceConfigureModal').modal('hide');
$scope.serviceConfigure.reset();
+3 -3
View File
@@ -10,7 +10,7 @@
<div ng-hide="installedApps | readyToUpdate">
<p>{{ 'settings.updateDialog.blockingApps' | tr }}</p>
<ul>
<li ng-repeat="app in installedApps | inProgressApps">{{app.location}}</li>
<li ng-repeat="app in installedApps | inProgressApps">{{app.fqdn}}</li>
</ul>
<span>{{ 'settings.updateDialog.blockingAppsInfo' | tr }}</span>
<br/>
@@ -124,7 +124,7 @@
<div class="form-group">
<label class="control-label" for="registryConfigPassword">{{ 'settings.privateDockerRegistryDialog.passwordToken' | tr }}</label>
<input type="password" class="form-control" ng-model="registryConfig.password" id="registryConfigPassword" name="password" ng-disabled="registryConfig.busy" ng-required>
<input type="password" class="form-control" ng-model="registryConfig.password" id="registryConfigPassword" name="password" ng-disabled="registryConfig.busy" ng-required password-reveal>
</div>
</fieldset>
</form>
@@ -171,7 +171,7 @@
<span class="text-muted">{{ 'settings.appstoreAccount.email' | tr }}</span>
</div>
<div class="col-xs-6 text-right">
<a ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?email=' + subscription.emailEncoded }}" target="_blank">{{ subscription.email }}</a>
<a ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?email=' + subscription.emailEncoded }}" target="_blank">{{ subscription.email }} <i ng-show="!subscription.emailVerified" class="fas fa-exclamation-triangle text-danger" uib-tooltip="{{ 'settings.appstoreAccount.emailNotVerified' | tr }}"></i></a>
</div>
</div>
<div class="row" ng-show="subscription">
+1 -1
View File
@@ -422,7 +422,7 @@ angular.module('Application').controller('SettingsController', ['$scope', '$loca
display: $translate.instant('lang.'+l, {}, undefined, 'en'),
id: l
};
});
}).sort(function (a, b) { return a.display.localeCompare(b.display); });
$scope.language.currentLanguage = $scope.language.availableLanguages.find(function (l) { return l.id === usedLang; });
$scope.language.language = $scope.language.currentLanguage;
});
+34 -14
View File
@@ -10,17 +10,33 @@
<div class="card">
<div class="grid-item-top">
<div class="row" ng-hide="config.features.support">
<p class="text-bold">{{ 'support.ticket.subscriptionRequired' | tr }}</p>
<p ng-bind-html=" 'support.ticket.subscriptionRequiredDescription' | tr:{ supportViewLink: 'https://docs.cloudron.io/apps/?support_view', forumLink: 'https://forum.cloudron.io/' } "></p>
<div class="row" ng-hide="ready">
<h2 class="text-center"><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="row" ng-show="config.features.support">
<div class="row" ng-show="ready && !config.features.support">
<div class="col-lg-12">
<p class="text-bold">{{ 'support.ticket.subscriptionRequired' | tr }}</p>
<p ng-bind-html=" 'support.ticket.subscriptionRequiredDescription' | tr:{ supportViewLink: 'https://docs.cloudron.io/apps/?support_view', forumLink: 'https://forum.cloudron.io/' } "></p>
</div>
</div>
<div class="row" ng-show="ready && config.features.support">
<div class="col-lg-12">
<div ng-show="subscription && !subscription.emailVerified" style="margin-bottom: 30px;">
<p class="text-bold">
{{ 'support.ticket.emailNotVerified' | tr:{ email: subscription.email } }}
<br/>
<center>
<a ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?email=' + subscription.emailEncoded }}" target="_blank" class="btn btn-success">{{ 'support.ticket.emailVerifyAction' | tr }}</a>
</center>
</p>
</div>
<div ng-bind-html="supportConfig.ticketFormBody | markdown2html"></div>
<form ng-show="supportConfig.submitTickets" name="feedbackForm" ng-submit="submitFeedback()">
<div class="form-group">
<label>{{ 'support.ticket.type' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required ng-disabled="!subscription.emailVerified">
<option value="app_error">{{ 'support.ticket.typeApp' | tr }}</option>
<option value="ticket">{{ 'support.ticket.typeBug' | tr }}</option>
<option value="email_error">{{ 'support.ticket.typeEmail' | tr }}</option>
@@ -28,28 +44,28 @@
</div>
<div class="form-group" ng-show="feedback.type === 'app_error'">
<label>{{ 'support.ticket.selectApp' | tr }}</label>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.appId" ng-required="feedback.type === 'app_error'">
<option ng-repeat="app in apps" value="{{ app.id }}">{{ app.fqdn }}</option>
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.appId" ng-required="feedback.type === 'app_error'" ng-disabled="!subscription.emailVerified">
<option ng-repeat="app in apps" value="{{ app.id }}">{{ app.fqdn }}</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
<label>{{ 'support.ticket.topic' | tr }}</label>
<input type="text" class="form-control" name="subject" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required>
<input type="text" class="form-control" name="subject" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.description.$dirty && feedbackForm.description.$invalid) }">
<label>{{ 'support.ticket.report' | tr }}</label>
<textarea class="form-control" name="description" rows="3" placeholder="{{ 'support.ticket.reportPlaceholder' | tr }}" ng-model="feedback.description" ng-minlength="1" required></textarea>
<textarea class="form-control" name="description" rows="3" placeholder="{{ 'support.ticket.reportPlaceholder' | tr }}" ng-model="feedback.description" ng-minlength="1" required ng-disabled="!subscription.emailVerified"></textarea>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.email.$dirty && feedbackForm.email.$invalid) }">
<label>{{ 'support.ticket.email' | tr }}</label> <small>{{ 'support.ticket.emailInfo' | tr:{ email: subscription.email } }}</small>
<input type="text" class="form-control" name="email" placeholder="{{ 'support.ticket.emailPlaceholder' | tr }}" ng-model="feedback.altEmail" ng-maxlength="512" ng-minlength="1" ng-required="feedback.type === 'email_error'">
<input type="text" class="form-control" name="email" placeholder="{{ 'support.ticket.emailPlaceholder' | tr }}" ng-model="feedback.altEmail" ng-maxlength="512" ng-minlength="1" ng-required="feedback.type === 'email_error'" ng-disabled="!subscription.emailVerified">
</div>
<div class="form-group">
<label class="control-label">
<input type="checkbox" ng-model="feedback.enableSshSupport"> {{ 'support.ticket.sshCheckbox' | tr }}
<input type="checkbox" ng-model="feedback.enableSshSupport" ng-disabled="!subscription.emailVerified"> {{ 'support.ticket.sshCheckbox' | tr }}
</label>
</div>
<button type="submit" class="btn btn-primary pull-right" ng-disabled="feedbackForm.$invalid || feedback.busy"><i class="fa fa-circle-notch fa-spin" ng-show="feedback.busy"></i> {{ 'support.ticket.submitAction' | tr }}</button>
<button type="submit" class="btn btn-primary pull-right" ng-disabled="!subscription.emailVerified || feedbackForm.$invalid || feedback.busy"><i class="fa fa-circle-notch fa-spin" ng-show="feedback.busy"></i> {{ 'support.ticket.submitAction' | tr }}</button>
<span ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</span>
<span ng-show="feedback.result" class="text-success text-bold">{{feedback.result.message}}</span>
</form>
@@ -64,7 +80,10 @@
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="row" ng-hide="ready">
<h2 class="text-center"><i class="fa fa-circle-notch fa-spin"></i></h2>
</div>
<div class="row" ng-show="ready">
<div class="col-lg-12">
<p ng-hide="config.features.support" class="text-bold">{{ 'support.remoteSupport.subscriptionRequired' | tr }}</p>
<p>{{ 'support.remoteSupport.description' | tr }}</p>
@@ -72,7 +91,8 @@
<b>{{ 'support.remoteSupport.warning' | tr }}</b>
<br/>
<br/>
<button class="btn" ng-class="!sshSupportEnabled ? 'btn-danger pull-right' : 'btn-primary pull-right'" ng-click="toggleSshSupport()">{{ sshSupportEnabled ? ('support.remoteSupport.disableAction' | tr) : ('support.remoteSupport.enableAction' | tr) }}</button>
<b class="pull-left text-danger text-bold" ng-show="toggleSshSupportError">{{ toggleSshSupportError }}</b>
<button class="btn pull-right" ng-class="!sshSupportEnabled ? 'btn-danger' : 'btn-primary'" ng-click="toggleSshSupport()">{{ sshSupportEnabled ? ('support.remoteSupport.disableAction' | tr) : ('support.remoteSupport.enableAction' | tr) }}</button>
</div>
</div>
</div>
+23 -15
View File
@@ -6,6 +6,7 @@
angular.module('Application').controller('SupportController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
$scope.ready = false;
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.apps = Client.getInstalledApps();
@@ -23,6 +24,7 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
altEmail: ''
};
$scope.toggleSshSupportError = '';
$scope.sshSupportEnabled = false;
$scope.subscription = null;
@@ -72,34 +74,40 @@ angular.module('Application').controller('SupportController', ['$scope', '$locat
};
$scope.toggleSshSupport = function () {
$scope.toggleSshSupportError = '';
Client.enableRemoteSupport(!$scope.sshSupportEnabled, function (error) {
if (error) return $scope.error(error);
if (error) {
if (error.statusCode === 412 || error.statusCode === 417) $scope.toggleSshSupportError = error.message;
else console.error(error);
return;
}
$scope.sshSupportEnabled = !$scope.sshSupportEnabled;
});
};
function getSubscription() {
Client.onReady(function () {
Client.getSubscription(function (error, result) {
if (error && error.statusCode === 412) return; // not yet registered
if (error && error.statusCode === 402) {
$scope.ready = true;
return; // not yet registered
}
if (error) return console.error(error);
$scope.subscription = result;
});
}
Client.onReady(function () {
getSubscription();
Client.getSupportConfig(function (error, supportConfig) {
if (error) return console.error(error);
$scope.supportConfig = supportConfig;
Client.getRemoteSupport(function (error, enabled) {
Client.getSupportConfig(function (error, supportConfig) {
if (error) return console.error(error);
$scope.sshSupportEnabled = enabled;
$scope.supportConfig = supportConfig;
Client.getRemoteSupport(function (error, enabled) {
if (error) return console.error(error);
$scope.sshSupportEnabled = enabled;
$scope.ready = true;
});
});
});
});
+273 -82
View File
@@ -45,31 +45,41 @@
<form name="useradd_form" role="form" ng-submit="useradd.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (useradd_form.displayName.$dirty && useradd_form.displayName.$invalid) || (!useradd_form.displayName.$dirty && useradd.error.displayName) }">
<label class="control-label">{{ 'users.user.fullName' | tr }}</label>
<input type="text" class="form-control" ng-model="useradd.displayName" name="displayName" id="inputUserAddDisplayName" autofocus autocomplete="off" placeholder="{{ 'users.user.displayNamePlaceholder' | tr }}">
<div class="control-label" ng-show="(!useradd_form.displayName.$dirty && useradd.error.displayName) || (useradd_form.displayName.$dirty && useradd_form.displayName.$invalid) || (!useradd_form.displayName.$dirty && useradd.error.displayName)">
<small ng-show="useradd_form.displayName.$error.displayName">{{ 'users.user.errorNotValidFullName' | tr }}</small>
<small ng-show="!useradd_form.displayName.$dirty && useradd.error.displayName">{{ useradd.error.displayName }}</small>
</div>
<input type="text" class="form-control" ng-model="useradd.displayName" name="displayName" id="inputUserAddDisplayName" ng-required autofocus autocomplete="off">
</div>
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (useradd_form.email.$dirty && useradd_form.email.$invalid) || (!useradd_form.email.$dirty && useradd.error.email) }">
<label class="control-label">{{ 'users.user.email' | tr }}</label>
<label class="control-label">{{ 'users.user.primaryEmail' | tr }}</label>
<input type="email" class="form-control" ng-model="useradd.email" name="email" id="inputUserAddEmail" required>
<div class="control-label" ng-show="(!useradd_form.email.$dirty && useradd.error.email) || (useradd_form.email.$dirty && useradd_form.email.$invalid) || (!useradd_form.email.$dirty && useradd.error.email)">
<small ng-show="useradd_form.email.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
<small ng-show="useradd_form.email.$error.email">{{ 'users.user.errorInvalidEmail' | tr }}</small>
<small ng-show="!useradd_form.email.$dirty && useradd.error.email">{{ useradd.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="useradd.email" name="email" id="inputUserAddEmail" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (useradd_form.fallbackEmail.$dirty && useradd_form.fallbackEmail.$invalid) || (!useradd_form.fallbackEmail.$dirty && useradd.error.fallbackEmail) }">
<label class="control-label">{{ 'users.user.recoveryEmail' | tr }}</label>
<input type="email" class="form-control" ng-model="useradd.fallbackEmail" name="fallbackEmail" id="inputUserAddFallbackEmail" placeholder="{{ 'users.user.fallbackEmailPlaceholder' | tr }}">
<div class="control-label" ng-show="(!useradd_form.fallbackEmail.$dirty && useradd.error.fallbackEmail) || (useradd_form.fallbackEmail.$dirty && useradd_form.fallbackEmail.$invalid) || (!useradd_form.fallbackEmail.$dirty && useradd.error.fallbackEmail)">
<small ng-show="useradd_form.fallbackEmail.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
<small ng-show="useradd_form.fallbackEmail.$error.fallbackEmail">{{ 'users.user.errorInvalidEmail' | tr }}</small>
<small ng-show="!useradd_form.fallbackEmail.$dirty && useradd.error.fallbackEmail">{{ useradd.error.fallbackEmail }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (useradd_form.username.$dirty && useradd_form.username.$invalid) || (!useradd_form.username.$dirty && useradd.error.username) }">
<label class="control-label">{{ 'users.user.username' | tr }}</label>
<input type="text" class="form-control" ng-model="useradd.username" name="username" id="inputUserAddUsername" ng-required="config.profileLocked" placeholder="{{ config.profileLocked ? '' : ('users.user.usernamePlaceholder' | tr) }}">
<div class="control-label" ng-show="(!useradd_form.username.$dirty && useradd.error.username) || (useradd_form.username.$dirty && useradd_form.username.$invalid) || (!useradd_form.username.$dirty && useradd.error.username)">
<small ng-show="useradd_form.username.$error.username">{{ 'users.user.errorInvalidUsername' | tr }}</small>
<small ng-show="!useradd_form.username.$dirty && useradd.error.username">{{ useradd.error.username }}</small>
</div>
<input type="text" class="form-control" ng-model="useradd.username" name="username" id="inputUserAddUsername" placeholder="{{ 'users.user.usernamePlaceholder' | tr }}">
</div>
<div class="form-group" ng-show="userInfo.isAtLeastAdmin">
@@ -92,6 +102,7 @@
<input type="checkbox" ng-model="useradd.sendInvite" id="inputUserAddSendInvite"> {{ 'users.addUserDialog.sendInviteCheckbox' | tr }}
</label>
</div>
<input class="ng-hide" type="submit" ng-disabled="useradd_form.$invalid || useradd.busy"/>
</form>
</div>
@@ -164,6 +175,14 @@
<form name="useredit_form" role="form" ng-submit="useredit.submit()" autocomplete="off">
<p class="has-error text-center" ng-show="useredit.error.generic">{{ useredit.error.generic }}</p>
<!-- when user profiles are locked, this provides a way for the admin to set the username -->
<div class="form-group" ng-hide="useredit.source || useredit.userInfo.username" ng-class="{ 'has-error': (useredit_form.username.$dirty && useredit_form.username.$invalid) || (!useredit_form.username.$dirty && useredit.error.username) }">
<label class="control-label">{{ 'users.user.username' | tr }}</label>
<div class="control-label" ng-show="(!useredit_form.username.$dirty && useredit.error.username) || (useredit_form.username.$dirty && useredit_form.username.$invalid) || (!useredit_form.username.$dirty && useredit.error.username)">
<small ng-show="!useredit_form.username.$dirty && useredit.error.username">{{ useredit.error.username }}</small>
</div>
<input type="text" class="form-control" ng-model="useredit.username" name="username" autocomplete="off">
</div>
<div class="form-group" ng-hide="useredit.source" ng-class="{ 'has-error': (useredit_form.displayName.$dirty && useredit_form.displayName.$invalid) || (!useredit_form.displayName.$dirty && useredit.error.displayName) }">
<label class="control-label">{{ 'users.user.displayName' | tr }}</label>
<div class="control-label" ng-show="(!useredit_form.displayName.$dirty && useredit.error.displayName) || (useredit_form.displayName.$dirty && useredit_form.displayName.$invalid) || (!useredit_form.displayName.$dirty && useredit.error.displayName)">
@@ -184,11 +203,10 @@
<div class="form-group" ng-hide="useredit.source" ng-class="{ 'has-error': (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail) }">
<label class="control-label">{{ 'users.user.recoveryEmail' | tr }}</label>
<div class="control-label" ng-show="(!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail) || (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail)">
<small ng-show="useredit_form.fallbackEmail.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
<small ng-show="useredit_form.fallbackEmail.$error.fallbackEmail">{{ 'users.user.errorInvalidEmail' | tr }}</small>
<small ng-show="!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail">{{ useredit.error.fallbackEmail }}</small>
</div>
<input type="fallbackEmail" class="form-control" ng-model="useredit.fallbackEmail" name="fallbackEmail" required>
<input type="fallbackEmail" class="form-control" ng-model="useredit.fallbackEmail" name="fallbackEmail">
</div>
<div class="form-group" ng-show="!isMe(useredit.userInfo) && userInfo.isAtLeastAdmin">
<label class="control-label">{{ 'users.user.role' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#roles" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
@@ -212,6 +230,12 @@
</div>
<input class="hide" type="submit" ng-disabled="useredit_form.$invalid || useredit.busy"/>
</form>
<hr/>
<div>
<p ng-hide="useredit.userInfo.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.no2FASetup' | tr }}</p>
<p ng-show="useredit.userInfo.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.2FAIsSetup' | tr }}</p>
<button type="button" class="btn btn-danger" ng-click="useredit.reset2FA()" ng-disabled="!useredit.userInfo.twoFactorAuthenticationEnabled || useredit.reset2FABusy"><i class="fa fa-circle-notch fa-spin" ng-show="useredit.reset2FABusy"></i> {{ 'users.passwordResetDialog.reset2FAAction' | tr }}</button>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
@@ -317,38 +341,148 @@
</div>
</div>
<!-- Modal invite/reset -->
<div class="modal fade" id="invitationModal" tabindex="-1" role="dialog">
<!-- Modal user import -->
<div class="modal fade" id="userImportModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.passwordResetDialog.title' | tr:{ username: (invitation.user.username || invitation.user.email) } }}</h4>
<h4 class="modal-title">{{ 'users.userImportDialog.title' | tr }}</h4>
</div>
<div class="modal-body">
<div ng-hide="invitation.setupLink">
<p>{{ 'users.passwordResetDialog.resetLinkExplanation' | tr }}</p>
<button type="button" class="btn btn-primary" ng-click="invitation.generateNewLink()" ng-disabled="invitation.busyNew"><i class="fa fa-circle-notch fa-spin" ng-show="invitation.busyNew"></i> {{ 'users.passwordResetDialog.newLinkAction' | tr }}</button>
</div>
<div ng-show="invitation.setupLink">
<p>{{ 'users.passwordResetDialog.description' | tr:{ username: (invitation.user.username || invitation.user.email) } }}</p>
<div class="input-group" style="margin-bottom: 10px">
<input type="text" id="setupLinkInput" class="form-control" ng-value="invitation.setupLink" readonly/>
<span class="input-group-btn">
<button class="btn btn-default" id="setupLinkButton" type="button" data-clipboard-target="#setupLinkInput"><i class="fa fa-clipboard"></i></button>
</span>
<div ng-show="!userImport.done">
<div ng-show="!userImport.busy">
<p ng-bind-html=" 'users.userImportDialog.description' | tr:{ docsLink: 'https://docs.cloudron.io/users/#import-users' } "></p>
<input type="file" style="display: none;" id="userImportFileInput" accept="application/json,text/csv"/>
<button class="btn btn-primary" ng-click="userImport.openFileInput()">{{ 'users.userImportDialog.fileInput' | tr }}</button>
<br/>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userImport.sendInvite" id="inputUserImportSendInvite"> {{ 'users.userImportDialog.sendInviteCheckbox' | tr }}
</label>
</div>
<p class="text-danger" ng-show="userImport.error.file">{{ userImport.error.file }}</p>
<p class="text-info" ng-show="userImport.users.length">{{ 'users.userImportDialog.usersFound' | tr:{ count: userImport.users.length } }}</p>
</div>
<div ng-show="userImport.busy" class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ userImport.percent }}%"></div>
</div>
<button type="button" class="btn btn-success" ng-click="invitation.email()" ng-hide="invitation.successSend" ng-disabled="invitation.busySend"><i class="fa fa-circle-notch fa-spin" ng-show="invitation.busySend"></i> {{ 'users.passwordResetDialog.sendEmailLinkAction' | tr }}</button>
<b class="text-success" ng-show="invitation.successSend">{{ 'users.passwordResetDialog.emailSent' | tr }}</b>
</div>
<hr/>
<div>
<p ng-hide="invitation.user.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.no2FASetup' | tr }}</p>
<p ng-show="invitation.user.twoFactorAuthenticationEnabled">{{ 'users.passwordResetDialog.2FAIsSetup' | tr }}</p>
<button type="button" class="btn btn-danger" ng-click="invitation.reset2FA()" ng-disabled="!invitation.user.twoFactorAuthenticationEnabled || invitation.reset2FABusy"><i class="fa fa-circle-notch fa-spin" ng-show="invitation.reset2FABusy"></i> {{ 'users.passwordResetDialog.reset2FAAction' | tr }}</button>
<div ng-show="userImport.done">
<p>{{ 'users.userImportDialog.success' | tr:{ count: userImport.success } }}</p>
<div ng-show="userImport.error.import.length">
<p class="text-danger">{{ 'users.userImportDialog.failed' | tr }}</p>
<div ng-repeat="tmp in userImport.error.import"><b>{{ tmp.user.email }}:</b> {{ tmp.error.message }}</div>
</div>
</div>
</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="userImport.import()" ng-show="!userImport.done" ng-disabled="userImport.busy || !userImport.users.length"><i class="fa fa-circle-notch fa-spin" ng-show="userImport.busy"></i> {{ 'users.userImportDialog.importAction' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal password reset -->
<div class="modal fade" id="passwordResetModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.passwordResetDialog.title' | tr:{ username: (passwordReset.user.username || passwordReset.user.email) } }}</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label class="control-label">{{ 'users.passwordResetDialog.descriptionLink' | tr }}</label>
<div class="input-group">
<input type="text" id="passwordResetLinkInput" class="form-control" ng-value="passwordReset.resetLink" readonly/>
<span class="input-group-btn">
<button class="btn btn-primary" id="passwordResetLinkClipboardButton" type="button" data-clipboard-target="#passwordResetLinkInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<br/>
<div class="form-group">
<label class="control-label">{{ 'users.passwordResetDialog.descriptionEmail' | tr }}</label>
<div class="input-group">
<input type="email" class="form-control" ng-model="passwordReset.email"/>
<span class="input-group-btn">
<button type="button" class="btn btn-primary" ng-click="passwordReset.sendEmail()" ng-disabled="passwordReset.busy"><i class="fa fa-circle-notch fa-spin" ng-show="passwordReset.busy"></i> {{ 'users.passwordResetDialog.sendAction' | tr }}</button>
</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal invitation -->
<div class="modal fade" id="invitationModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.invitationDialog.title' | tr:{ username: (invitation.user.username || invitation.user.email) } }}</h4>
</div>
<div class="modal-body">
<div class="form-group">
<label class="control-label">{{ 'users.invitationDialog.descriptionLink' | tr }}</label>
<div class="input-group">
<input type="text" id="invitationLinkInput" class="form-control" ng-value="invitation.inviteLink" readonly/>
<span class="input-group-btn">
<button class="btn btn-primary" id="invitationLinkClipboardButton" type="button" data-clipboard-target="#invitationLinkInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<br/>
<div class="form-group">
<label class="control-label">{{ 'users.invitationDialog.descriptionEmail' | tr }}</label>
<div class="input-group">
<input type="email" class="form-control" ng-model="invitation.email"/>
<span class="input-group-btn">
<button type="button" class="btn btn-primary" ng-click="invitation.sendEmail()" ng-disabled="invitation.busy"><i class="fa fa-circle-notch fa-spin" ng-show="invitation.busy"></i> {{ 'users.invitationDialog.sendAction' | tr }}</button>
</span>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
</div>
</div>
</div>
</div>
<!-- Modal set ghost -->
<div class="modal fade" id="setGhostModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">{{ 'users.setGhostDialog.title' | tr: { username: setGhost.user.username} }}</h4>
</div>
<div class="modal-body">
<p>{{ 'users.setGhostDialog.description' | tr }}</p>
<form name="setGhostForm" role="form" novalidate ng-submit="setGhost.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': setGhost.error }">
<label class="control-label" for="setGhostPassword">{{ 'users.setGhostDialog.password' | tr }}</label>
<div class="control-label" ng-show="setGhost.error">
<small ng-show="setGhost.error">{{ setGhost.error }}</small>
</div>
<div class="input-group">
<input type="text" id="setGhostPassword" class="form-control" name="password" ng-model="setGhost.password" required ng-readonly="setGhost.success"/>
<span class="input-group-btn">
<button class="btn btn-default" ng-hide="setGhost.success" type="button" uib-tooltip="{{ 'users.setGhostDialog.generatePassword' | tr }}Generate Password" ng-click="setGhost.generatePassword()"><i class="fa fa-key"></i></button>
<button class="btn btn-default" ng-show="setGhost.success" type="button" id="setGhostClipboardButton" data-clipboard-target="#setGhostPassword"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<input class="hide" type="submit" ng-disabled="setGhostForm.$invalid || setGhost.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-show="setGhost.success" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
<button type="button" class="btn btn-default" ng-hide="setGhost.success" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
<button type="button" class="btn btn-success" ng-hide="setGhost.success" ng-click="setGhost.submit()" ng-disabled="setGhostForm.$invalid || setGhost.busy"><i class="fa fa-circle-notch fa-spin" ng-show="setGhost.busy"></i> {{ 'users.setGhostDialog.setPassword' | tr }}</button>
</div>
</div>
</div>
@@ -389,17 +523,17 @@
<p class="has-error" ng-show="externalLdap.error.acceptSelfSignedCerts">{{ 'users.externalLdap.errorSelfSignedCert' | tr }}</p>
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.baseDn }">
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.baseDn }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigBaseDn">{{ 'users.externalLdap.baseDn' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.baseDn" id="inputExternalLdapConfigBaseDn" name="baseDn" ng-disabled="externalLdap.busy" placeholder="ou=users,dc=example,dc=com" required>
<input type="text" class="form-control" ng-model="externalLdap.baseDn" id="inputExternalLdapConfigBaseDn" name="baseDn" ng-disabled="externalLdap.busy" placeholder="ou=users,dc=example,dc=com" ng-required="externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.filter }">
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.filter }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigFilter">{{ 'users.externalLdap.filter' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.filter" id="inputExternalLdapConfigFilter" name="filter" ng-disabled="externalLdap.busy" placeholder="(objectClass=inetOrgPerson)" required>
<input type="text" class="form-control" ng-model="externalLdap.filter" id="inputExternalLdapConfigFilter" name="filter" ng-disabled="externalLdap.busy" placeholder="(objectClass=inetOrgPerson)" ng-required="externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.usernameField }">
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.usernameField }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigUsernameField">{{ 'users.externalLdap.usernameField' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.usernameField" id="inputExternalLdapConfigUsernameField" name="usernameField" ng-disabled="externalLdap.busy" placeholder="uid or sAMAcountName">
</div>
@@ -412,29 +546,29 @@
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupBaseDn }" ng-show="externalLdap.syncGroups">
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupBaseDn }" ng-show="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigGroupBaseDn">{{ 'users.externalLdap.groupBaseDn' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.groupBaseDn" id="inputExternalLdapConfigGroupBaseDn" name="groupBaseDn" ng-disabled="externalLdap.busy" placeholder="ou=groups,dc=example,dc=com" ng-required="externalLdap.syncGroups">
<input type="text" class="form-control" ng-model="externalLdap.groupBaseDn" id="inputExternalLdapConfigGroupBaseDn" name="groupBaseDn" ng-disabled="externalLdap.busy" placeholder="ou=groups,dc=example,dc=com" ng-required="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupFilter }" ng-show="externalLdap.syncGroups">
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupFilter }" ng-show="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigGroupFilter">{{ 'users.externalLdap.groupFilter' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.groupFilter" id="inputExternalLdapConfigGroupFilter" name="groupFilter" ng-disabled="externalLdap.busy" placeholder="(objectClass=groupOfNames)" ng-required="externalLdap.syncGroups">
<input type="text" class="form-control" ng-model="externalLdap.groupFilter" id="inputExternalLdapConfigGroupFilter" name="groupFilter" ng-disabled="externalLdap.busy" placeholder="(objectClass=groupOfNames)" ng-required="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupnameField }" ng-show="externalLdap.syncGroups">
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.groupnameField }" ng-show="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigGroupnameField">{{ 'users.externalLdap.groupnameField' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.groupnameField" id="inputExternalLdapConfigGroupnameField" name="groupnameField" ng-disabled="externalLdap.busy" placeholder="cn" ng-required="externalLdap.syncGroups">
<input type="text" class="form-control" ng-model="externalLdap.groupnameField" id="inputExternalLdapConfigGroupnameField" name="groupnameField" ng-disabled="externalLdap.busy" placeholder="cn" ng-required="externalLdap.syncGroups && externalLdap.provider !== 'cloudron'">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.credentials }">
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.credentials }" ng-show="externalLdap.provider !== 'cloudron'">
<label class="control-label" for="inputExternalLdapConfigBindDn">{{ 'users.externalLdap.bindUsername' | tr }}</label>
<input type="text" class="form-control" ng-model="externalLdap.bindDn" id="inputExternalLdapConfigBindDn" name="bindDn" ng-disabled="externalLdap.busy" placeholder="uid=admin,ou=Users,dc=example,dc=com">
</div>
<div class="form-group" ng-class="{ 'has-error': externalLdap.error.credentials }">
<label class="control-label" for="inputExternalLdapConfigBindPassword">{{ 'users.externalLdap.bindPassword' | tr }}</label>
<input type="password" class="form-control" ng-model="externalLdap.bindPassword" id="inputExternalLdapConfigBindPassword" name="bindPassword" ng-disabled="externalLdap.busy" placeholder="">
<input type="password" class="form-control" ng-model="externalLdap.bindPassword" id="inputExternalLdapConfigBindPassword" name="bindPassword" ng-disabled="externalLdap.busy" placeholder="" password-reveal>
</div>
<div class="form-group">
@@ -461,28 +595,32 @@
<div class="content content-large">
<div class="text-left">
<h2>
<h1>
{{ 'users.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="useradd.show()">
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
</button>
</h2>
</div>
<div class="row">
<div class="col-lg-12">
<div class="users-filter">
<input type="text" class="form-control" style="min-width: 350px;" ng-model="userSearchString" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
<select class="form-control" ng-model="pageItems" ng-options="a.name for a in pageItemCount" ng-change="updateFilter(true)"></select>
</div>
<div class="pagination pull-right">
<button class="btn btn-default btn-outline" ng-click="showPrevPage()" ng-disabled="userRefreshBusy || 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="userRefreshBusy || users.length < pageItems.value">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
</h1>
</div>
<div>
<div class="users-toolbar">
<input type="text" id="userSearchInput" class="form-control" style="max-width: 350px;" ng-model="userSearchString" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="{{ 'main.searchPlaceholder' | tr }}"/>
<multiselect ng-model="userStateFilter" ms-header="{{ 'apps.stateFilterHeader' | tr }}" ms-selected="{{ userStateFilter }}" options="state.label for state in userStates" data-multiple="false"></multiselect>
<div style="flex-grow: 1;"></div>
<div class="btn-group">
<button class="btn btn-default" ng-click="userImport.show()" uib-tooltip="{{ 'users.userImport.tooltip' | tr }}" tooltip-append-to-body="true"><i class="fas fa-download"></i></button>
<div class="btn-group" role="group">
<button class="btn btn-default dropdown-toggle" type="button" data-toggle="dropdown" uib-tooltip="{{ 'users.userExport.tooltip' | tr }}" tooltip-append-to-body="true">
<i class="fas fa-upload"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li><a href="" ng-click="userExport('csv')">{{ 'users.userExport.csv' | tr }}</a></li>
<li><a href="" ng-click="userExport('json')">{{ 'users.userExport.json' | tr }}</a></li>
</ul>
</div>
</div>
<button class="btn btn-primary btn-outline" ng-click="useradd.show()">
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
</button>
</div>
<div class="card card-large">
<div class="grid-item-top">
<div class="row ng-hide" ng-show="userRefreshBusy">
@@ -506,17 +644,18 @@
<td colspan="5" class="text-center text-muted">{{ 'users.users.empty' | tr }}</td>
</tr>
<tr ng-repeat="user in users" ng-class="{'text-muted': !user.active}">
<td>
<td style="min-width: 33.5px;">
<i class="fas fa-crown arrow" ng-show="user.active && user.role === 'owner'" uib-tooltip="{{ 'users.users.superadminTooltip' | tr }}"></i>
<i class="fa fa-user-tie arrow" ng-show="user.active && user.role === 'admin'" uib-tooltip="{{ 'users.users.adminTooltip' | tr }}"></i>
<i class="fas fa-users-cog arrow" ng-show="user.active && user.role === 'usermanager'" uib-tooltip="{{ 'users.users.usermanagerTooltip' | tr }}"></i>
<i class="fas fa-mail-bulk arrow" ng-show="user.active && user.role === 'mailmanager'" uib-tooltip="{{ 'users.users.mailmanagerTooltip' | tr }}"></i>
<i class="fa fa-ban" ng-show="!user.active" uib-tooltip="{{ 'users.users.inactiveTooltip' | tr }}"></i>
</td>
<td class="hand elide-table-cell" ng-click="canEdit(user) && useredit.show(user)" ng-show="user.username">
{{ user.displayName }} &nbsp; <span class="text-muted">{{ user.username }}</span> &nbsp; <i ng-show="user.source" class="far fa-address-book" uib-tooltip="{{ 'users.users.externalLdapTooltip' | tr }}"></i>
</td>
<td class="hand elide-table-cell" ng-click="canEdit(user) && useredit.show(user)" ng-hide="user.username">
<span class="text-muted" uib-tooltip="{{ 'users.users.notActivatedYetTooltip' | tr }}">{{ user.fallbackEmail }}</span>
<span class="text-muted" uib-tooltip="{{ 'users.users.notActivatedYetTooltip' | tr }}">{{ user.email }}</span>
</td>
<td class="text-left hand elide-table-cell hidden-xs hidden-sm" ng-click="canEdit(user) && useredit.show(user)">
<span class="group-badge" ng-repeat="groupId in user.groupIds">
@@ -525,14 +664,21 @@
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button ng-show="isMe(user) && userInfo.role === 'owner' && user.role === 'owner' && !config.features.userRoles" class="btn btn-xs btn-default" ng-click="transferOwnership.show()" uib-tooltip="{{ 'users.users.transferOwnershipTooltip' | tr }}"><i class="fas fa-random"></i></button>
<button ng-disabled="!canEdit(user) || isMe(user) || user.source" class="btn btn-xs btn-default" ng-click="invitation.show(user)" uib-tooltip="{{ 'users.users.resetPasswordTooltip' | tr }}"><i class="fa fa-redo"></i></button>
<button ng-show="isMe(user) && userInfo.isAtLeastOwner && user.isAtLeastOwner && !config.features.userRoles" class="btn btn-xs btn-default" ng-click="transferOwnership.show()" uib-tooltip="{{ 'users.users.transferOwnershipTooltip' | tr }}"><i class="fas fa-random"></i></button>
<button ng-disabled="!canEdit(user)" ng-show="!user.inviteAccepted && !isMe(user)" class="btn btn-xs btn-default" ng-click="invitation.show(user)" uib-tooltip="{{ 'users.users.invitationTooltip' | tr }}"><i class="fas fa-paper-plane"></i></button>
<button ng-disabled="!canEdit(user) || user.source" ng-show="user.inviteAccepted" class="btn btn-xs btn-default" ng-click="passwordReset.show(user)" uib-tooltip="{{ 'users.users.resetPasswordTooltip' | tr }}"><i class="fas fa-key"></i></button>
<button ng-disabled="!userInfo.isAtLeastAdmin || !user.username" class="btn btn-xs btn-default" ng-click="setGhost.show(user)" uib-tooltip="{{ 'users.users.setGhostTooltip' | tr }}"><i class="fas fa-user-secret"></i></button>
<button ng-disabled="!canEdit(user)" class="btn btn-xs btn-default" ng-click="useredit.show(user)" uib-tooltip="{{ 'users.users.editUserTooltip' | tr }}"><i class="fa fa-pencil-alt"></i></button>
<button ng-disabled="!canEdit(user) || isMe(user)" class="btn btn-xs btn-danger" ng-click="userremove.show(user)" uib-tooltip="{{ 'users.users.removeUserTooltip' | tr }}"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
</tbody>
</table>
<br/>
<div class="pull-right">
<button class="btn btn-default btn-outline btn-xs" ng-click="showPrevPage()" ng-class="{ 'btn-primary': currentPage > 1 }" ng-disabled="userRefreshBusy || currentPage <= 1"><i class="fa fa-angle-double-left"></i> {{ 'main.pagination.prev' | tr }}</button>
<button class="btn btn-default btn-outline btn-xs" ng-click="showNextPage()" ng-class="{ 'btn-primary': users.length > pageItems }" ng-disabled="userRefreshBusy || users.length < pageItems">{{ 'main.pagination.next' | tr }} <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
</div>
</div>
@@ -542,12 +688,12 @@
<br/>
<div class="text-left">
<h2>
<h3 style="margin-bottom: 15px;">
{{ 'users.groups.title' | tr }}
<button class="btn btn-primary btn-outline pull-right" ng-click="groupAdd.show()">
<i class="fa fa-plus"></i> {{ 'users.groups.newGroupAction' | tr }}
</button>
</h2>
</h3>
</div>
<div class="card card-large">
@@ -588,20 +734,20 @@
</div>
<div class="text-left" style="margin-top: 50px;" ng-show="user.isAtLeastAdmin">
<h2>{{ 'users.settings.title' | tr }}</h2>
<h3>{{ 'users.settings.title' | tr }}</h3>
</div>
<div class="card card-large" ng-show="user.isAtLeastAdmin">
<form name="directoryConfigForm" role="form" novalidate ng-submit="directoryConfig.submit()" autocomplete="off">
<fieldset ng-disabled="directoryConfig.busy || !config.features.directoryConfig">
<form name="profileConfigForm" role="form" novalidate ng-submit="profileConfig.submit()" autocomplete="off">
<fieldset ng-disabled="profileConfig.busy || !config.features.profileConfig">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="directoryConfig.editableUserProfiles"> {{ 'users.settings.allowProfileEditCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#lock-profile" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<input type="checkbox" ng-model="profileConfig.editableUserProfiles"> {{ 'users.settings.allowProfileEditCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#lock-profile" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="directoryConfig.mandatory2FA"> {{ 'users.settings.require2FACheckbox' | tr }}
<input type="checkbox" ng-model="profileConfig.mandatory2FA"> {{ 'users.settings.require2FACheckbox' | tr }}
</label>
</div>
</fieldset>
@@ -609,18 +755,18 @@
<br/>
<div class="row" ng-hide="config.features.directoryConfig">
<div class="row" ng-hide="config.features.profileConfig">
<div class="col-md-12">
<span>{{ 'users.settings.subscriptionRequired' | tr }} <a href="" class="pull-right" ng-click="openSubscriptionSetup()">{{ 'users.settings.subscriptionRequiredAction' | tr }}</a></span>
</div>
</div>
<div class="row" ng-show="config.features.directoryConfig">
<div class="row" ng-show="config.features.profileConfig">
<div class="col-md-12">
<span class="has-error" ng-show="directoryConfig.errorMessage">{{ directoryConfig.errorMessage }}</span>
<span class="has-error" ng-show="profileConfig.errorMessage">{{ profileConfig.errorMessage }}</span>
<button class="btn btn-outline btn-primary pull-right" ng-click="directoryConfig.submit()" ng-disabled="!directoryConfigForm.$dirty || directoryConfig.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="directoryConfig.busy"></i> {{ 'users.settings.saveAction' | tr }}
<button class="btn btn-outline btn-primary pull-right" ng-click="profileConfig.submit()" ng-disabled="!profileConfigForm.$dirty || profileConfig.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="profileConfig.busy"></i> {{ 'users.settings.saveAction' | tr }}
</button>
</div>
</div>
@@ -628,7 +774,7 @@
</div>
<div class="text-left" style="margin-top: 50px;" ng-show="user.isAtLeastAdmin">
<h2>{{ 'users.externalLdap.title' | tr }}</h2>
<h3>{{ 'users.externalLdap.title' | tr }}</h3>
</div>
<div class="card card-large" ng-show="user.isAtLeastAdmin">
@@ -679,7 +825,7 @@
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.baseDn' | tr }}</span>
</div>
@@ -688,7 +834,7 @@
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.filter' | tr }}</span>
</div>
@@ -697,7 +843,7 @@
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.usernameField' | tr }}</span>
</div>
@@ -715,7 +861,7 @@
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.syncGroups">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupBaseDn' | tr }}</span>
</div>
@@ -724,7 +870,7 @@
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.syncGroups">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupFilter' | tr }}</span>
</div>
@@ -733,7 +879,7 @@
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.syncGroups">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.groupnameField' | tr }}</span>
</div>
@@ -742,7 +888,7 @@
</div>
</div>
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
<div class="col-xs-6">
<span class="text-muted">{{ 'users.externalLdap.auth' | tr }}</span>
</div>
@@ -784,6 +930,51 @@
</div>
</div>
</div>
</div>
<div class="text-left" style="margin-top: 50px;" ng-show="user.isAtLeastAdmin">
<h3>{{ 'users.exposedLdap.title' | tr }}</h3>
</div>
<div class="card card-large" ng-show="user.isAtLeastAdmin">
<div class="row">
<div class="col-md-12">
<div>{{ 'users.exposedLdap.description' | tr }}</div>
<br/>
<form name="userDirectoryConfigForm" role="form" novalidate ng-submit="userDirectoryConfig.submit()" autocomplete="off">
<fieldset>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="userDirectoryConfig.enabled" ng-disabled="userDirectoryConfig.busy"> {{ 'users.exposedLdap.enabled' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#directory-server" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
</label>
</div>
<div class="form-group">
<label class="control-label">{{ 'users.exposedLdap.secret.label' | tr }}</label>
<p class="small" ng-bind-html=" 'users.exposedLdap.secret.description' | tr:{ userDN: 'cn=admin,ou=system,dc=cloudron' }"></p>
<input type="password" ng-model="userDirectoryConfig.secret" ng-disabled="!userDirectoryConfig.enabled || userDirectoryConfig.busy" name="secret" class="form-control" ng-class="{ 'has-error': !userDirectoryConfigForm.secret.$dirty && userDirectoryConfig.error.secret }" password-reveal/>
<div class="has-error" ng-show="userDirectoryConfig.error.secret">{{ userDirectoryConfig.error.secret }}</div>
</div>
<div class="form-group">
<label class="control-label">{{ 'users.exposedLdap.ipRestriction.label' | tr }}</label>
<p class="small">{{ 'users.exposedLdap.ipRestriction.description' | tr }}</p>
<textarea ng-model="userDirectoryConfig.allowlist" ng-disabled="!userDirectoryConfig.enabled || userDirectoryConfig.busy" placeholder="{{ 'users.exposedLdap.ipRestriction.placeholder' | tr }}" name="allowlist" class="form-control" ng-class="{ 'has-error': !userDirectoryConfigForm.allowlist.$dirty && userDirectoryConfig.error.allowlist }" rows="4"></textarea>
<div class="has-error" ng-show="userDirectoryConfig.error.allowlist">{{ userDirectoryConfig.error.allowlist }}</div>
</div>
</fieldset>
</form>
<br/>
<div>
<span class="has-error" ng-show="userDirectoryConfig.error.generic">{{ userDirectoryConfig.error.generic }}</span>
<button class="btn btn-outline btn-primary pull-right" ng-click="userDirectoryConfig.submit()" ng-disabled="!userDirectoryConfigForm.$dirty || userDirectoryConfig.busy">
<i class="fa fa-circle-notch fa-spin" ng-show="userDirectoryConfig.busy"></i> {{ 'users.settings.saveAction' | tr }}
</button>
</div>
</div>
</div>
</div>
</div>
+450 -90
View File
@@ -10,6 +10,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.ldapProvider = [
{ name: 'Active Directory', value: 'ad' },
{ name: 'Cloudron', value: 'cloudron' },
{ name: 'Jumpcloud', value: 'jumpcloud' },
{ name: 'Okta', value: 'okta' },
{ name: 'Univention Corporate Server (UCS)', value: 'univention' },
@@ -39,14 +40,26 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.userSearchString = '';
$scope.currentPage = 1;
$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.pageItems = $scope.pageItemCount[0];
$scope.pageItems = 15;
$scope.userRefreshBusy = true;
$scope.userStates = [
{ state: 'ALL', value: null, label: 'All Users' },
{ state: 'ACTIVE', value: true, label: 'Active Users' },
{ state: 'INACTIVE', value: false, label: 'Inactive Users' }
];
$translate(['users.stateFilter.all', 'users.stateFilter.active', 'users.stateFilter.inactive']).then(function (tr) {
if (tr['users.stateFilter.all']) $scope.userStates.find(function (a) { return a.state === 'ALL'; }).label = tr['users.stateFilter.all'];
if (tr['users.stateFilter.active']) $scope.userStates.find(function (a) { return a.state === 'ACTIVE'; }).label = tr['users.stateFilter.active'];
if (tr['users.stateFilter.inactive']) $scope.userStates.find(function (a) { return a.state === 'INACTIVE'; }).label = tr['users.stateFilter.inactive'];
});
$scope.userStateFilter = $scope.userStates[0];
$scope.$watch('userStateFilter', function (newVal, oldVal) {
if (newVal === oldVal) return;
$scope.updateFilter();
});
$scope.groupMembers = function (group) {
return group.userIds.filter(function (uid) { return !!$scope.allUsersById[uid]; }).map(function (uid) { return $scope.allUsersById[uid].username || $scope.allUsersById[uid].email; }).join(' ');
};
@@ -90,6 +103,171 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
};
$scope.userImport = {
busy: false,
done: false,
error: null,
percent: 0,
success: 0,
users: [],
sendInvite: false,
reset: function () {
$scope.userImport.busy = false;
$scope.userImport.error = null;
$scope.userImport.users = [];
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.sendInvite = false;
},
handleFileChanged: function () {
$scope.userImport.reset();
var fileInput = document.getElementById('userImportFileInput');
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.userImport.users = [];
var users = [];
if (file.type === 'text/csv') {
var lines = reader.result.split('\n');
if (lines.length === 0) return $scope.userImport.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 !== 5) {
$scope.userImport.error = { file: 'Line ' + (i+1) + ' has wrong column count. Expecting 5' };
return;
}
users.push({
username: items[0].trim(),
email: items[1].trim(),
fallbackEmail: items[2].trim(),
displayName: items[3].trim(),
role: items[4].trim()
});
}
} else {
try {
users = JSON.parse(reader.result).map(function (user) {
return {
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role
};
});
} catch (e) {
console.error('Failed to parse users.', e);
$scope.userImport.error = { file: 'Imported file is not valid JSON:' + e.message };
}
}
$scope.userImport.users = users;
});
}, false);
reader.readAsText(file);
},
show: function () {
$scope.userImport.reset();
// named so no duplactes
document.getElementById('userImportFileInput').addEventListener('change', $scope.userImport.handleFileChanged);
$('#userImportModal').modal('show');
},
openFileInput: function () {
$('#userImportFileInput').click();
},
import: function () {
$scope.userImport.percent = 0;
$scope.userImport.success = 0;
$scope.userImport.done = false;
$scope.userImport.error = { import: [] };
$scope.userImport.busy = true;
var processed = 0;
async.eachSeries($scope.userImport.users, function (user, callback) {
Client.addUser(user, function (error, userId) {
if (error) $scope.userImport.error.import.push({ error: error, user: user });
else ++$scope.userImport.success;
++processed;
$scope.userImport.percent = 100 * processed / $scope.userImport.users.length;
if (!error && $scope.userImport.sendInvite) {
console.log('sending', userId, user.email);
Client.sendInviteEmail(userId, user.email, function (error) {
if (error) console.error('Failed to send invite.', error);
});
}
callback();
});
}, function (error) {
if (error) return console.error(error);
$scope.userImport.busy = false;
$scope.userImport.done = true;
if ($scope.userImport.success) {
refresh();
refreshAllUsers();
}
});
}
};
// supported types are 'json' and 'csv'
$scope.userExport = function (type) {
Client.getAllUsers(function (error, result) {
if (error) {
Client.error('Failed to list users. Full error in the webinspector.');
return console.error('Failed to list users.', error);
}
var content = '';
if (type === 'json') {
content = JSON.stringify(result.map(function (user) {
return {
id: user.id,
username: user.username,
email: user.email,
fallbackEmail: user.fallbackEmail,
displayName: user.displayName,
role: user.role,
active: user.active
};
}), null, 2);
} else if (type === 'csv') {
content = result.map(function (user) {
return [ user.id, user.username, user.email, user.fallbackEmail, user.displayName, user.role, user.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 = type === 'json' ? 'users.json' : 'users.csv';
document.body.appendChild(a);
a.click();
});
};
$scope.userremove = {
busy: false,
error: null,
@@ -126,11 +304,12 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
alreadyTaken: false,
error: {},
email: '',
fallbackEmail: '',
username: '',
displayName: '',
sendInvite: true,
selectedGroups: [],
role: 'user',
sendInvite: false,
show: function () {
if ($scope.config.features.userMaxCount && $scope.config.features.userMaxCount <= $scope.allUsers.length) {
@@ -140,10 +319,12 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.useradd.error = {};
$scope.useradd.email = '';
$scope.useradd.fallbackEmail = '';
$scope.useradd.username = '';
$scope.useradd.displayName = '';
$scope.useradd.selectedGroups = [];
$scope.useradd.role = 'user';
$scope.useradd.sendInvite = false;
$scope.useradd_form.$setUntouched();
$scope.useradd_form.$setPristine();
@@ -156,17 +337,19 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.useradd.alreadyTaken = false;
$scope.useradd.error.email = null;
$scope.useradd.error.fallbackEmail = null;
$scope.useradd.error.username = null;
$scope.useradd.error.displayName = null;
var user = {
username: $scope.useradd.username || null,
email: $scope.useradd.email,
fallbackEmail: $scope.useradd.fallbackEmail,
displayName: $scope.useradd.displayName,
role: $scope.useradd.role
};
Client.createUser(user, function (error, newUserInfo) {
Client.addUser(user, function (error, userId) {
if (error) {
$scope.useradd.busy = false;
@@ -204,17 +387,14 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
}
var groupIds = $scope.useradd.selectedGroups.map(function (g) { return g.id; });
var NOOP = function (next) { next(); };
async.series([
Client.setGroups.bind(Client, newUserInfo.id, groupIds),
$scope.useradd.sendInvite ? Client.createInvite.bind(Client, newUserInfo.id) : NOOP,
$scope.useradd.sendInvite ? Client.sendInvite.bind(Client, newUserInfo.id) : NOOP
], function (error) {
Client.setGroups(userId, groupIds, function (error) {
$scope.useradd.busy = false;
if (error) return console.error(error);
if ($scope.useradd.sendInvite) Client.sendInviteEmail(userId, user.email, function (error) { if (error) console.error('Failed to send invite.', error); });
refresh();
refreshAllUsers();
@@ -226,10 +406,12 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.useredit = {
busy: false,
reset2FABusy: false,
error: {},
userInfo: {},
// form fields
username: '',
email: '',
fallbackEmail: '',
aliases: {},
@@ -241,6 +423,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
show: function (userInfo) {
$scope.useredit.error = {};
$scope.useredit.username = userInfo.username;
$scope.useredit.email = userInfo.email;
$scope.useredit.displayName = userInfo.displayName;
$scope.useredit.fallbackEmail = userInfo.fallbackEmail;
@@ -273,6 +456,8 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
// only change those if it is a local user
if (!$scope.useredit.source) {
// username is settable only if it was empty previously. it's editable for the "lock" profiles feature
if (!$scope.useredit.userInfo.username) data.username = $scope.useredit.username;
data.email = $scope.useredit.email;
data.displayName = $scope.useredit.displayName;
data.fallbackEmail = $scope.useredit.fallbackEmail;
@@ -283,7 +468,11 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.useredit.busy = false;
if (error.statusCode === 409) {
$scope.useredit.error.email = 'Email already taken';
if (error.message.toLowerCase().indexOf('email') !== -1) {
$scope.useredit.error.email = 'Email already taken';
} else if (error.message.toLowerCase().indexOf('username') !== -1) {
$scope.useredit.error.username = 'Username already taken';
}
$scope.useredit_form.email.$setPristine();
$('#inputUserEditEmail').focus();
} else {
@@ -306,6 +495,19 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$('#userEditModal').modal('hide');
});
});
},
reset2FA: function () {
$scope.useredit.reset2FABusy = true;
Client.disableTwoFactorAuthenticationByUserId($scope.useredit.userInfo.id, function (error) {
if (error) return console.error(error);
$timeout(function () {
$scope.useredit.userInfo.twoFactorAuthenticationEnabled = false;
$scope.useredit.reset2FABusy = false;
}, 3000);
});
}
};
@@ -523,101 +725,207 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
return user.username === Client.getUserInfo().username;
};
$scope.invitation = {
$scope.passwordReset = {
busy: false,
reset2FABusy: false,
setupLink: '',
resetLink: '',
user: null,
successSend: false,
email: '',
show: function (user) {
$scope.invitation.user = user;
$scope.invitation.setupLink = '';
$scope.invitation.busy = false;
$scope.invitation.reset2FABusy = false;
$scope.invitation.successSend = false;
$scope.passwordReset.busy = false;
$scope.passwordReset.resetLink = '';
$scope.passwordReset.user = user;
$scope.passwordReset.email = user.fallbackEmail || user.email;
$('#invitationModal').modal('show');
},
Client.getPasswordResetLink(user.id, function (error, result) {
if (error) return console.error('Failed to get password reset link.', error);
generateNewLink: function () {
$scope.invitation.busyNew = true;
$scope.passwordReset.resetLink = result.passwordResetLink;
Client.createInvite($scope.invitation.user.id, function (error, result) {
$scope.invitation.busyNew = false;
if (error) return console.error(error);
$scope.invitation.setupLink = result.inviteLink;
$('#passwordResetModal').modal('show');
});
},
email: function () {
$scope.invitation.busySend = true;
sendEmail: function () {
$scope.passwordReset.busy = true;
Client.sendInvite($scope.invitation.user.id, function (error) {
$scope.invitation.busySend = false;
if (error) return console.error(error);
Client.sendPasswordResetEmail($scope.passwordReset.user.id, $scope.passwordReset.email, function (error) {
if (error) return console.error('Failed to send password reset email.', error);
$scope.invitation.successSend = true;
$scope.passwordReset.busy = false;
$timeout(function () { $scope.invitation.successSend = false; }, 3000);
});
},
reset2FA: function () {
$scope.invitation.reset2FABusy = true;
Client.disableTwoFactorAuthenticationByUserId($scope.invitation.user.id, function (error) {
if (error) return console.error(error);
$timeout(function () {
$scope.invitation.reset2FABusy = false;
$scope.invitation.user.twoFactorAuthenticationEnabled = false;
}, 3000);
refreshUsers();
Client.notify($translate.instant('profile.passwordResetNotification.title'), $translate.instant('profile.passwordResetNotification.body', { email: $scope.passwordReset.email }), false, 'success');
});
}
};
$scope.directoryConfig = {
$scope.invitation = {
busy: false,
inviteLink: '',
user: null,
email: '',
show: function (user) {
$scope.invitation.busy = false;
$scope.invitation.inviteLink = '';
$scope.invitation.user = user;
$scope.invitation.email = user.email;
Client.getInviteLink(user.id, function (error, result) {
if (error) return console.error('Failed to get invite link.', error);
$scope.invitation.inviteLink = result.inviteLink;
$('#invitationModal').modal('show');
});
},
sendEmail: function () {
$scope.invitation.busy = true;
Client.sendInviteEmail($scope.invitation.user.id, $scope.invitation.email, function (error) {
if (error) return console.error('Failed to send invite email.', error);
$scope.invitation.busy = false;
Client.notify($translate.instant('users.invitationNotification.title'), $translate.instant('users.invitationNotification.body', { email: $scope.invitation.email }), false, 'success');
});
}
};
// https://stackoverflow.com/questions/1497481/javascript-password-generator
function generatePassword() {
var length = 12,
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789",
retVal = "";
for (var i = 0, n = charset.length; i < length; ++i) {
retVal += charset.charAt(Math.floor(Math.random() * n));
}
return retVal;
}
$scope.setGhost = {
busy: false,
error: null,
success: false,
user: null,
password: '',
show: function (user) {
$scope.setGhost.busy = false;
$scope.setGhost.success = false;
$scope.setGhost.error = null;
$scope.setGhost.user = user;
$scope.setGhost.password = '';
$('#setGhostModal').modal('show');
},
generatePassword: function () {
$scope.setGhost.password = generatePassword();
},
submit: function () {
$scope.setGhost.busy = true;
Client.setGhost($scope.setGhost.user.id, $scope.setGhost.password, null, function (error) {
$scope.setGhost.busy = false;
if (error) {
$scope.setGhost.error = error.message;
return console.error(error);
}
$scope.setGhost.success = true;
});
}
};
$scope.profileConfig = {
editableUserProfiles: true,
mandatory2FA: false,
errorMessage: '',
loadDirectoryConfig: function () {
Client.getDirectoryConfig(function (error, result) {
refresh: function () {
Client.getProfileConfig(function (error, result) {
if (error) return console.error('Unable to get directory config.', error);
$scope.directoryConfig.editableUserProfiles = !result.lockUserProfiles;
$scope.directoryConfig.mandatory2FA = !!result.mandatory2FA;
$scope.profileConfig.editableUserProfiles = !result.lockUserProfiles;
$scope.profileConfig.mandatory2FA = !!result.mandatory2FA;
});
},
submit: function () {
$scope.directoryConfig.error = '';
$scope.directoryConfig.busy = true;
$scope.directoryConfig.success = false;
$scope.profileConfig.error = '';
$scope.profileConfig.busy = true;
$scope.profileConfig.success = false;
var data = {
lockUserProfiles: !$scope.directoryConfig.editableUserProfiles,
mandatory2FA: $scope.directoryConfig.mandatory2FA
lockUserProfiles: !$scope.profileConfig.editableUserProfiles,
mandatory2FA: $scope.profileConfig.mandatory2FA
};
Client.setDirectoryConfig(data, function (error) {
if (error) $scope.directoryConfig.errorMessage = error.message;
Client.setProfileConfig(data, function (error) {
if (error) $scope.profileConfig.errorMessage = error.message;
$scope.directoryConfig.success = true;
$scope.profileConfig.success = true;
$scope.directoryConfigForm.$setUntouched();
$scope.directoryConfigForm.$setPristine();
$scope.profileConfigForm.$setUntouched();
$scope.profileConfigForm.$setPristine();
Client.refreshConfig(); // refresh the $scope.config
$timeout(function () {
$scope.directoryConfig.busy = false;
$scope.profileConfig.busy = false;
}, 3000);
});
}
};
$scope.userDirectoryConfig = {
enabled: false,
secret: '',
allowlist: '',
error: null,
refresh: function () {
Client.getUserDirectoryConfig(function (error, result) {
if (error) return console.error('Unable to get exposed ldap config.', error);
$scope.userDirectoryConfig.enabled = !!result.enabled;
$scope.userDirectoryConfig.allowlist = result.allowlist;
$scope.userDirectoryConfig.secret = result.secret;
});
},
submit: function () {
$scope.userDirectoryConfig.error = null;
$scope.userDirectoryConfig.busy = true;
$scope.userDirectoryConfig.success = false;
var data = {
enabled: $scope.userDirectoryConfig.enabled,
secret: $scope.userDirectoryConfig.secret,
allowlist: $scope.userDirectoryConfig.allowlist
};
Client.setUserDirectoryConfig(data, function (error) {
$scope.userDirectoryConfig.busy = false;
if (error && error.statusCode === 400) {
if (error.message.indexOf('secret') !== -1) return $scope.userDirectoryConfig.error = { secret: error.message };
else return $scope.userDirectoryConfig.error = { allowlist: error.message };
}
if (error) return $scope.userDirectoryConfig.error = { generic: error.message };
$scope.userDirectoryConfigForm.$setUntouched();
$scope.userDirectoryConfigForm.$setPristine();
$scope.userDirectoryConfig.success = true;
});
}
};
$scope.externalLdap = {
busy: false,
percent: 0,
@@ -717,7 +1025,22 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
provider: $scope.externalLdap.provider
};
if ($scope.externalLdap.provider !== 'noop') {
if ($scope.externalLdap.provider === 'cloudron') {
config.url = $scope.externalLdap.url;
config.acceptSelfSignedCerts = $scope.externalLdap.acceptSelfSignedCerts;
config.autoCreate = $scope.externalLdap.autoCreate;
config.syncGroups = $scope.externalLdap.syncGroups;
config.bindPassword = $scope.externalLdap.bindPassword;
// those values are known and thus overwritten
config.baseDn = 'ou=users,dc=cloudron';
config.filter = '(objectClass=inetOrgPerson)';
config.usernameField = 'username';
config.groupBaseDn = 'ou=groups,dc=cloudron';
config.groupFilter = '(objectClass=group)';
config.groupnameField = 'cn';
config.bindDn = 'cn=admin,ou=system,dc=cloudron';
} else if ($scope.externalLdap.provider !== 'noop') {
config.url = $scope.externalLdap.url;
config.acceptSelfSignedCerts = $scope.externalLdap.acceptSelfSignedCerts;
config.baseDn = $scope.externalLdap.baseDn;
@@ -772,10 +1095,10 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
function getUsers(callback) {
var users = [];
Client.getUsers($scope.userSearchString, $scope.currentPage, $scope.pageItems.value, function (error, results) {
Client.getUsers($scope.userSearchString, $scope.userStateFilter.value, $scope.currentPage, $scope.pageItems, function (error, results) {
if (error) return console.error(error);
async.eachOfLimit(results, 20, function (result, index, iteratorDone) {
async.eachOf(results, function (result, index, iteratorDone) {
Client.getUser(result.id, function (error, user) {
if (error) return iteratorDone(error);
@@ -836,13 +1159,12 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
refreshUsers();
};
$scope.updateFilter = function (fresh) {
if (fresh) $scope.currentPage = 1;
$scope.updateFilter = function () {
refreshUsers();
};
function refreshAllUsers() { // this loads all users on Cloudron, not just current page
Client.getUsers(function (error, results) {
Client.getAllUsers(function (error, results) {
if (error) return console.error(error);
$scope.allUsers = results;
@@ -854,18 +1176,24 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
});
}
Client.onReady(refresh);
Client.onReady(function () { if ($scope.user.isAtLeastAdmin) loadExternalLdapConfig(); });
Client.onReady(function () { if ($scope.user.isAtLeastAdmin) $scope.directoryConfig.loadDirectoryConfig(); });
Client.onReady(refreshAllUsers);
Client.onReady(function () {
refresh();
if ($scope.user.isAtLeastAdmin) loadExternalLdapConfig();
if ($scope.user.isAtLeastAdmin) $scope.profileConfig.refresh();
if ($scope.user.isAtLeastAdmin) $scope.userDirectoryConfig.refresh();
refreshAllUsers();
// Order matters for permissions used in canEdit
$scope.roles = [
{ id: 'user', name: $translate.instant('users.role.user'), disabled: false },
{ id: 'usermanager', name: $translate.instant('users.role.usermanager'), disabled: false },
{ id: 'mailmanager', name: $translate.instant('users.role.mailmanager'), disabled: false },
{ id: 'admin', name: $translate.instant('users.role.admin'), disabled: !$scope.user.isAtLeastAdmin },
{ id: 'owner', name: $translate.instant('users.role.owner'), disabled: !$scope.user.isAtLeastOwner }
];
// give search the initial focus
setTimeout(function () { $('#userSearchInput').focus(); }, 1);
});
// setup all the dialog focus handling
@@ -875,26 +1203,58 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
});
});
var clipboard = new Clipboard('#setupLinkButton');
clipboard.on('success', function(e) {
$('#setupLinkButton').tooltip({
new Clipboard('#passwordResetLinkClipboardButton').on('success', function(e) {
$('#passwordResetLinkClipboardButton').tooltip({
title: 'Copied!',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#setupLinkButton').tooltip('hide'); }, 2000);
$timeout(function () { $('#passwordResetLinkClipboardButton').tooltip('hide'); }, 2000);
e.clearSelection();
});
clipboard.on('error', function(/*e*/) {
$('#setupLinkButton').tooltip({
}).on('error', function(/*e*/) {
$('#passwordResetLinkClipboardButton').tooltip({
title: 'Press Ctrl+C to copy',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#setupLinkButton').tooltip('hide'); }, 2000);
$timeout(function () { $('#passwordResetLinkClipboardButton').tooltip('hide'); }, 2000);
});
new Clipboard('#invitationLinkClipboardButton').on('success', function(e) {
$('#invitationLinkClipboardButton').tooltip({
title: 'Copied!',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#invitationLinkClipboardButton').tooltip('hide'); }, 2000);
e.clearSelection();
}).on('error', function(/*e*/) {
$('#invitationLinkClipboardButton').tooltip({
title: 'Press Ctrl+C to copy',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#invitationLinkClipboardButton').tooltip('hide'); }, 2000);
});
new Clipboard('#setGhostClipboardButton').on('success', function(e) {
$('#setGhostClipboardButton').tooltip({
title: 'Copied!',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#setGhostClipboardButton').tooltip('hide'); }, 2000);
e.clearSelection();
}).on('error', function(/*e*/) {
$('#setGhostClipboardButton').tooltip({
title: 'Press Ctrl+C to copy',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#setGhostClipboardButton').tooltip('hide'); }, 2000);
});
$('.modal-backdrop').remove();
+26 -14
View File
@@ -19,16 +19,21 @@
<div class="form-group">
<label class="control-label" for="mountType">{{ 'volumes.mountType' | tr }}</label>
<select class="form-control" id="mountType" ng-model="volumeAdd.mountType" ng-options="a.value as a.name for a in mountTypes"></select>
<p class="small text-info" ng-show="volumeAdd.mountType === 'noop'" ng-bind-html="'volumes.addVolumeDialog.noopWarning' | tr"></p>
<p class="small text-info" ng-hide="volumeAdd.mountType === 'noop'" ng-bind-html="'volumes.addVolumeDialog.mountTypeInfo' | tr"></p>
<p class="small text-warning" ng-show="volumeAdd.mountType === 'mountpoint'" ng-bind-html="'volumes.addVolumeDialog.mountpointWarning' | tr"></p>
<p class="small text-info" ng-hide="volumeAdd.mountType === 'mountpoint' || volumeAdd.mountType === 'filesystem'" ng-bind-html="'volumes.addVolumeDialog.mountTypeInfo' | tr"></p>
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'noop'">
<div class="form-group" ng-show="volumeAdd.mountType === 'filesystem'">
<label class="control-label">{{ 'volumes.localDirectory' | tr }}</label>
<input type="text" class="form-control" ng-model="volumeAdd.hostPath" name="hostPath" ng-disabled="volumeAdd.busy" placeholder="/srv/shared" autofocus>
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'mountpoint'">
<label class="control-label">{{ 'volumes.hostPath' | tr }}</label>
<input type="text" class="form-control" ng-model="volumeAdd.hostPath" name="hostPath" ng-disabled="volumeAdd.busy" placeholder="/mnt/data" autofocus>
</div>
<div uib-collapse="volumeAdd.mountType === 'noop'">
<div uib-collapse="volumeAdd.mountType === 'mountpoint' || volumeAdd.mountType === 'filesystem'">
<div class="form-group" ng-show="volumeAdd.mountType === 'ext4'">
<label class="control-label" for="volumeAddHost">{{ 'volumes.addVolumeDialog.diskPath' | tr }}</label>
<input type="text" class="form-control" ng-model="volumeAdd.diskPath" id="volumeAddDiskPath" name="diskPath" ng-disabled="volumeAdd.busy" placeholder="/dev/disk/by-uuid/uuid">
@@ -39,6 +44,17 @@
<input type="text" class="form-control" ng-model="volumeAdd.host" id="volumeAddHost" name="host" ng-disabled="volumeAdd.busy" placeholder="Server IP or hostname">
</div>
<div class="checkbox" ng-show="volumeAdd.mountType === 'cifs'">
<label>
<input type="checkbox" ng-model="volumeAdd.seal">{{ 'backups.configureBackupStorage.cifsSealSupport' | tr }}</input>
</label>
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'sshfs'">
<label class="control-label" for="volumeAddPort">{{ 'volumes.addVolumeDialog.port' | tr }}</label>
<input type="number" class="form-control" ng-model="volumeAdd.port" id="volumeAddPort" name="port" ng-disabled="volumeAdd.busy">
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'cifs' || volumeAdd.mountType === 'nfs' || volumeAdd.mountType === 'sshfs'">
<label class="control-label" for="volumeAddRemoteDir">{{ 'volumes.addVolumeDialog.remoteDirectory' | tr }}</label>
<input type="text" class="form-control" ng-model="volumeAdd.remoteDir" id="volumeAddRemoteDir" name="remoteDir" ng-disabled="volumeAdd.busy" placeholder="/share">
@@ -51,12 +67,7 @@
<div class="form-group" ng-show="volumeAdd.mountType === 'cifs'">
<label class="control-label" for="volumeAddPassword">{{ 'volumes.addVolumeDialog.password' | tr }}</label>
<input type="password" class="form-control" ng-model="volumeAdd.password" id="volumeAddPassword" name="password" ng-disabled="volumeAdd.busy">
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'sshfs'">
<label class="control-label" for="volumeAddPort">{{ 'volumes.addVolumeDialog.port' | tr }}</label>
<input type="number" class="form-control" ng-model="volumeAdd.port" id="volumeAddPort" name="port" ng-disabled="volumeAdd.busy">
<input type="password" class="form-control" ng-model="volumeAdd.password" id="volumeAddPassword" name="password" ng-disabled="volumeAdd.busy" password-reveal>
</div>
<div class="form-group" ng-show="volumeAdd.mountType === 'sshfs'">
@@ -140,11 +151,12 @@
{{ volume.mountType }}
</td>
<td class="text-left elide-table-cell hidden-xs hidden-sm">
<span ng-show="volume.mountType !== 'noop'">{{ volume.mountOptions.host || volume.mountOptions.diskPath || volume.hostPath }}{{ volume.mountOptions.remoteDir }}</span>
<span ng-show="volume.mountType === 'noop'">{{ volume.hostPath }}</span>
<span ng-show="volume.mountType !== 'mountpoint' && volume.mountType !== 'filesystem'">{{ volume.mountOptions.host || volume.mountOptions.diskPath || volume.hostPath }}{{ volume.mountOptions.remoteDir }}</span>
<span ng-show="volume.mountType === 'mountpoint' || volume.mountType === 'filesystem'">{{ volume.hostPath }}</span>
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<a class="btn btn-xs btn-default" ng-href="{{ '/filemanager.html?volumeId=' + volume.id }}" target="_blank" uib-tooltip="{{ 'volumes.openFileManagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
<button class="btn btn-xs btn-default" ng-click="remount(volume)" ng-show="isMountProvider(volume.mountType)" ng-disabled="volume.remounting" uib-tooltip="{{ 'volumes.remountActionTooltip' | tr }}"><i class="fa fa-sync-alt" ng-class="{ 'fa-spin': volume.remounting }"></i></button>
<a class="btn btn-xs btn-default" ng-href="{{ '/filemanager.html?type=volume&id=' + volume.id }}" target="_blank" uib-tooltip="{{ 'volumes.openFileManagerActionTooltip' | tr }}"><i class="fas fa-folder"></i></a>
<button class="btn btn-xs btn-danger" ng-click="volumeRemove.show(volume)" uib-tooltip="{{ 'volumes.removeVolumeActionTooltip' | tr }}"><i class="far fa-trash-alt"></i></button>
</td>
</tr>
@@ -153,4 +165,4 @@
</div>
</div>
</div>
</div>
</div>
+31 -8
View File
@@ -16,9 +16,10 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
$scope.mountTypes = [
{ name: 'CIFS', value: 'cifs' },
{ name: 'EXT4', value: 'ext4' },
{ name: 'Filesystem', value: 'filesystem' },
{ name: 'Filesystem (Mountpoint)', value: 'mountpoint' },
{ name: 'NFS', value: 'nfs' },
{ name: 'SSHFS', value: 'sshfs' },
{ name: 'No-op', value: 'noop' }
];
function refreshVolumes(callback) {
@@ -54,6 +55,25 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
});
}
// same as box/mounts.js
$scope.isMountProvider = function (provider) {
return provider === 'sshfs' || provider === 'cifs' || provider === 'nfs' || provider === 'ext4';
};
$scope.remount = function (volume) {
volume.remounting = true;
Client.remountVolume(volume.id, function (error) {
if (error) console.error(error);
// give the backend some time
$timeout(function () {
volume.remounting = false;
refreshVolumes(function (error) { if (error) console.error('Failed to refresh volume states.', error); });
}, 2000);
});
};
$scope.volumeAdd = {
error: null,
busy: false,
@@ -61,13 +81,14 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
name: '',
hostPath: '',
mountType: 'noop',
mountType: 'mountpoint',
host: '',
remoteDir: '',
username: '',
password: '',
diskPath: '',
user: '',
seal: false,
port: 22,
privateKey: '',
@@ -76,13 +97,14 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
$scope.volumeAdd.busy = false;
$scope.volumeAdd.name = '';
$scope.volumeAdd.hostPath = '';
$scope.volumeAdd.mountType = 'noop';
$scope.volumeAdd.mountType = 'mountpoint';
$scope.volumeAdd.host = '';
$scope.volumeAdd.remoteDir = '';
$scope.volumeAdd.username = '';
$scope.volumeAdd.password = '';
$scope.volumeAdd.diskPath = '';
$scope.volumeAdd.user = '';
$scope.volumeAdd.seal = false;
$scope.volumeAdd.port = 22;
$scope.volumeAdd.privateKey = '';
@@ -107,7 +129,8 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
host: $scope.volumeAdd.host,
remoteDir: $scope.volumeAdd.remoteDir,
username: $scope.volumeAdd.username,
password: $scope.volumeAdd.password
password: $scope.volumeAdd.password,
seal: $scope.volumeAdd.seal
};
} else if ($scope.volumeAdd.mountType === 'nfs') {
mountOptions = {
@@ -129,13 +152,13 @@ angular.module('Application').controller('VolumesController', ['$scope', '$locat
}
var hostPath;
if ($scope.volumeAdd.mountType === 'noop') {
hostPath = $scope.volumeAdd.hostPath; // settable by user
if ($scope.volumeAdd.mountType === 'mountpoint' || $scope.volumeAdd.mountType === 'filesystem') {
hostPath = $scope.volumeAdd.hostPath;
} else {
hostPath = '/mnt/volumes/' + $scope.volumeAdd.name; // hardcoded in UI for ease of use
hostPath = null;
}
Client.addVolume($scope.volumeAdd.name, hostPath, $scope.volumeAdd.mountType, mountOptions, function (error) {
Client.addVolume($scope.volumeAdd.name, $scope.volumeAdd.mountType, hostPath, mountOptions, function (error) {
$scope.volumeAdd.busy = false;
if (error) {
$scope.volumeAdd.error = error.message;