Compare commits
221 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 09e00e6d58 | |||
| 09d2a63cbb | |||
| 8c062a8828 | |||
| c707daf946 | |||
| 0a91e6b2c0 | |||
| 90c8348c9c | |||
| 1426cbec81 | |||
| 7047915995 | |||
| 49b514054f | |||
| bf27374dcc | |||
| 3de1c6e499 | |||
| d77285f2c4 | |||
| 96eeb70076 | |||
| 6a39e442ac | |||
| 91e030be44 | |||
| 405e20e18e | |||
| 138f770630 | |||
| eadc4fda30 | |||
| 35c5f19eac | |||
| 6d8ae180b3 | |||
| 0fea30969f | |||
| 3ff8f5cb33 | |||
| b6162a3bef | |||
| 09ca67f408 | |||
| cadb1ad674 | |||
| dec7bc3ca3 | |||
| d87460a3cd | |||
| f076711ad3 | |||
| 6149a5ac12 | |||
| 44c61f7bd7 | |||
| 4ea47da269 | |||
| 35f2c0ec7d | |||
| 3316dd1f42 | |||
| 07527fe2b1 | |||
| 03207f62ba | |||
| bcc78d81a6 | |||
| 0d38e443d1 | |||
| 50a069a7fa | |||
| 7455490074 | |||
| 64bb53abc3 | |||
| 18a680a85b | |||
| e26f71b603 | |||
| f98fe43843 | |||
| 26dad82cd3 | |||
| 73d1860995 | |||
| aca5c254d2 | |||
| 3521815646 | |||
| aecc16af5d | |||
| 5927f397a3 | |||
| 1e85c86e74 | |||
| 6640929b01 | |||
| 7a333ace11 | |||
| 32bce25ad5 | |||
| 5dc023d801 | |||
| e3f31e6560 | |||
| e582e147cb | |||
| 6525504923 | |||
| 6d6107161e | |||
| 3196864f0d | |||
| d7596beaf3 | |||
| 23de5b5a61 | |||
| d98b09f802 | |||
| 97c012b3df | |||
| 867b8e0253 | |||
| 80400db92a | |||
| 72ff84be47 | |||
| 13e62bc738 | |||
| 0e83658aa3 | |||
| 8e4506382d | |||
| 7a0b74d79b | |||
| 1026728ab7 | |||
| 909fe5dc15 | |||
| aed9801501 | |||
| 41f92c52e9 | |||
| d0dc104ede | |||
| ce42680888 | |||
| 4ebff09f73 | |||
| 8fd7daade6 | |||
| e6aef755e3 | |||
| c4b8d3b832 | |||
| c38457b48d | |||
| 60994f9ed1 | |||
| a6f078330f | |||
| cfd5c0f82b | |||
| 14c9260ab0 | |||
| 23cac99fe9 | |||
| 2237d2bbb7 | |||
| 62ca0487dc | |||
| 0e858dc333 | |||
| fa3e908afc | |||
| c1bb4de6a3 | |||
| 9b94cf18d0 | |||
| b51071155a | |||
| 1128edc23e | |||
| df9c7010e2 | |||
| 54c7757e38 | |||
| 3da3ccedcb | |||
| 26eb739b46 | |||
| 7ce5b53753 | |||
| 298d446e5f | |||
| 450dd70ea2 | |||
| 1d1a7af48e | |||
| 003bc457bf | |||
| bfafcea0b9 | |||
| 66da8dd4dc | |||
| 307a3ee015 | |||
| 95be147eb4 | |||
| 2bf711f1f7 | |||
| c3d2c7bcde | |||
| 38e32942cb | |||
| febd24b203 | |||
| d1afa3fdca | |||
| a82d1ea832 | |||
| 7d9e8da660 | |||
| ec990bd16a | |||
| fb12c0e499 | |||
| 3d1a4f8802 | |||
| c978e3b7ea | |||
| 0b201cee71 | |||
| 8b7c5a65d6 | |||
| 8a63f0368e | |||
| ce4bf7e10c | |||
| 479946173f | |||
| 176baa075f | |||
| bfbc41d5a7 | |||
| d2b303ffd6 | |||
| 00bbb4242d | |||
| 0a4b0688a8 | |||
| 9efe399399 | |||
| b03240ccb8 | |||
| 35eb17a922 | |||
| c8b997f732 | |||
| 80e83e0c05 | |||
| 9491b5aa39 | |||
| 243a254f3e | |||
| 2d1e0ec890 | |||
| 793ee38f79 | |||
| 5240068f2f | |||
| b8be174610 | |||
| b923925a6c | |||
| 61f5669d76 | |||
| cf707ba657 | |||
| 660260336c | |||
| 0447086882 | |||
| 29a96e5df1 | |||
| c95bb248fb | |||
| d3551826c1 | |||
| d2c21627de | |||
| 81e21effa4 | |||
| 2d03941745 | |||
| 2401c9cee7 | |||
| 4f0bbcc73b | |||
| 5b9700e099 | |||
| d7dda61775 | |||
| 3220721f84 | |||
| 0ed144fe81 | |||
| 13b9bed48b | |||
| c99c24b3bd | |||
| bd1ab000f3 | |||
| a1fd5bb996 | |||
| 9ef29343b3 | |||
| 8bdcdd7810 | |||
| a1217e52c8 | |||
| a8d37b917a | |||
| 06ce351d82 | |||
| f43a601e86 | |||
| 0dfadc5922 | |||
| c8cd67258a | |||
| 7499aa9201 | |||
| 0f4ea17f29 | |||
| b7631689b0 | |||
| afe670b49c | |||
| ee43dff35f | |||
| 1faf83afe4 | |||
| ce0b66db7d | |||
| 01d33c45bd | |||
| 63766dd10f | |||
| 8771158f10 | |||
| 46a589f794 | |||
| a007a8e40c | |||
| 6e42cf4ec5 | |||
| 257dc4e271 | |||
| 4136272382 | |||
| 4f9e43859c | |||
| b57ad9b8c1 | |||
| b8c297b178 | |||
| a389b863f9 | |||
| 40c82b3e48 | |||
| 2ca94f3159 | |||
| 33a97d0e50 | |||
| cef0b6d0d8 | |||
| 7a5e990ad4 | |||
| ca31dc8d78 | |||
| 5b7667fa4d | |||
| 6cdb448f62 | |||
| 053f81a53e | |||
| c842d02d6f | |||
| 4ddcd547ba | |||
| 7bb68ea6b5 | |||
| e13f427267 | |||
| c422e2d570 | |||
| b3f91c4868 | |||
| 19dd56c160 | |||
| c577d3d91f | |||
| 4f57bed03a | |||
| 29663a1229 | |||
| d9d4798f69 | |||
| 32d3c0b920 | |||
| 2224ccab7c | |||
| 8d3d3ba875 | |||
| 4ad2b2829b | |||
| 1ca46a064c | |||
| e42579521c | |||
| 96be06188b | |||
| 10172e0211 | |||
| 70c8a5a6be | |||
| af42f150f2 | |||
| ba16fdaf60 | |||
| c5480bfcc1 | |||
| 79448e9ff9 | |||
| e49398eb47 |
+1
-1
@@ -5,7 +5,7 @@
|
||||
},
|
||||
"extends": "eslint:recommended",
|
||||
"parserOptions": {
|
||||
"ecmaVersion": 2020
|
||||
"ecmaVersion": 13
|
||||
},
|
||||
"rules": {
|
||||
"linebreak-style": [
|
||||
|
||||
@@ -2724,3 +2724,35 @@
|
||||
* postgres: do not clear search_path for restore
|
||||
* route53: retry on rate limit errors
|
||||
* update: continue with app update if box update does not start
|
||||
|
||||
[7.6.4]
|
||||
* mail: update limit plugin
|
||||
* ldap: fix error messages to show proper error messages in the external LDAP connector
|
||||
* dashboard: fix various UI elements hidden for admin user
|
||||
* directoryserver: fix totp validation
|
||||
* email: improve loading of the mail usage to not block other views from loading
|
||||
* eventlog: add events for directory server and exernal directory configuration
|
||||
* externalldap: available regardless of subscription
|
||||
* externalldap: show syncer log history
|
||||
* externalldap: sync is now run periodically (every 4 hours)
|
||||
* profile: changing email now requires password
|
||||
|
||||
[7.7.0]
|
||||
* OIDC avatar support via picture claim
|
||||
* backupcleaner: fix bug where preserved backups were removed incorrectly
|
||||
* directoryserver: cloudflare warning
|
||||
* oidc/ldap: fix display name parsing to send anything after first name as the last name
|
||||
* mail: Update haraka to 3.0.3
|
||||
* mongodb: Update mongodb to 6.0
|
||||
* acme: use secp256r1 curve for max compatibility
|
||||
* add port range support
|
||||
* docker: disable userland proxy
|
||||
* oidc: always re-setup oidc client record
|
||||
* mail: update solr to 8.11.3
|
||||
* mail: spam acl should allow underscore and question mark
|
||||
* Fix streaming of logs with `logPaths`
|
||||
* profile: store user language setting in the database
|
||||
|
||||
[7.7.1]
|
||||
* postgresql: fix bug in loading of contrib extensions
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
const constants = require('./src/constants.js'),
|
||||
fs = require('fs'),
|
||||
ldap = require('./src/ldap.js'),
|
||||
ldapServer = require('./src/ldapserver.js'),
|
||||
oidc = require('./src/oidc.js'),
|
||||
paths = require('./src/paths.js'),
|
||||
proxyAuth = require('./src/proxyauth.js'),
|
||||
@@ -37,7 +37,7 @@ async function startServers() {
|
||||
await setupLogging();
|
||||
await server.start(); // do this first since it also inits the database
|
||||
await proxyAuth.start();
|
||||
await ldap.start();
|
||||
await ldapServer.start();
|
||||
|
||||
const conf = await directoryServer.getConfig();
|
||||
if (conf.enabled) await directoryServer.start();
|
||||
@@ -62,7 +62,7 @@ async function main() {
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldap.stop();
|
||||
await ldapServer.stop();
|
||||
await oidc.stop();
|
||||
setTimeout(process.exit.bind(process), 3000);
|
||||
});
|
||||
@@ -73,7 +73,7 @@ async function main() {
|
||||
await proxyAuth.stop();
|
||||
await server.stop();
|
||||
await directoryServer.stop();
|
||||
await ldap.stop();
|
||||
await ldapServer.stop();
|
||||
await oidc.stop();
|
||||
setTimeout(process.exit.bind(process), 3000);
|
||||
});
|
||||
|
||||
BIN
Binary file not shown.
@@ -177,7 +177,7 @@
|
||||
<li><a href="#/profile"><i class="fa fa-user fa-fw"></i> {{ 'profile.title' | tr }}</a></li>
|
||||
<li ng-show="user.isAtLeastMailManager" class="divider"></li>
|
||||
<li ng-show="user.isAtLeastAdmin"><a href="#/backups"><i class="fa fa-archive fa-fw"></i> {{ 'backups.title' | tr }}</a></li>
|
||||
<li ng-show="user.isAtLeastOwner"><a href="#/branding"><i class="fa fa-passport fa-fw"></i> {{ 'branding.title' | tr }}</a></li>
|
||||
<li ng-show="user.isAtLeastAdmin"><a href="#/branding"><i class="fa fa-passport fa-fw"></i> {{ 'branding.title' | tr }}</a></li>
|
||||
<li ng-show="user.isAtLeastAdmin"><a href="#/domains"><i class="fa fa-globe fa-fw"></i> {{ 'domains.title' | tr }}</a></li>
|
||||
<li ng-show="user.isAtLeastMailManager"><a href="#/email"><i class="fa fa-envelope fa-fw"></i> {{ 'emails.title' | tr }}</a></li>
|
||||
<li ng-show="user.isAtLeastAdmin"><a href="#/eventlog"><i class="fa fa-list-alt fa-fw"></i> {{ 'eventlog.title' | tr }}</a></li>
|
||||
|
||||
+89
-38
@@ -453,7 +453,7 @@ angular.module('Application').filter('tr', translateFilterFactory);
|
||||
// Cloudron REST API wrapper
|
||||
// ----------------------------------------------
|
||||
|
||||
angular.module('Application').service('Client', ['$http', '$interval', '$timeout', 'md5', 'Notification', function ($http, $interval, $timeout, md5, Notification) {
|
||||
angular.module('Application').service('Client', ['$http', '$interval', '$timeout', 'md5', 'Notification', '$translate', function ($http, $interval, $timeout, md5, Notification, $translate) {
|
||||
var client = null;
|
||||
|
||||
// variable available only here to avoid this._property pattern
|
||||
@@ -618,6 +618,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
twoFactorAuthenticationEnabled: false,
|
||||
source: null,
|
||||
avatarUrl: null,
|
||||
avatarType: null,
|
||||
hasBackgroundImage: false
|
||||
};
|
||||
this._config = {
|
||||
@@ -746,7 +747,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
this._userInfo.twoFactorAuthenticationEnabled = userInfo.twoFactorAuthenticationEnabled;
|
||||
this._userInfo.role = userInfo.role;
|
||||
this._userInfo.source = userInfo.source;
|
||||
this._userInfo.avatarUrl = userInfo.avatarUrl + '?s=128&default=mp&ts=' + Date.now(); // we add the timestamp to avoid caching
|
||||
this._userInfo.avatarUrl = userInfo.avatarUrl + '?ts=' + Date.now(); // we add the timestamp to avoid caching
|
||||
this._userInfo.avatarType = userInfo.avatarType;
|
||||
this._userInfo.hasBackgroundImage = userInfo.hasBackgroundImage;
|
||||
this._userInfo.isAtLeastOwner = [ ROLES.OWNER ].indexOf(userInfo.role) !== -1;
|
||||
this._userInfo.isAtLeastAdmin = [ ROLES.OWNER, ROLES.ADMIN ].indexOf(userInfo.role) !== -1;
|
||||
@@ -763,9 +765,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
this._config.consoleServerOrigin = '<%= appstore.consoleOrigin %>';
|
||||
<% } -%>
|
||||
|
||||
// => This is just for easier testing
|
||||
// this._config.features.externalLdap = false;
|
||||
|
||||
this._configListener.forEach(function (callback) {
|
||||
callback(that._config);
|
||||
});
|
||||
@@ -818,7 +817,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.userInfo = function (callback) {
|
||||
Client.prototype.getProfile = function (callback) {
|
||||
get('/api/v1/profile', null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 200 || typeof data !== 'object') return callback(new ClientError(status, data));
|
||||
@@ -1735,8 +1734,8 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.setGroups = function (userId, groupIds, callback) {
|
||||
put('/api/v1/users/' + userId + '/groups', { groupIds: groupIds }, null, function (error, data, status) {
|
||||
Client.prototype.setLocalGroups = function (userId, localGroupIds, callback) {
|
||||
put('/api/v1/users/' + userId + '/groups', { groupIds: localGroupIds }, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
@@ -1766,12 +1765,12 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.updateGroup = function (id, name, callback) {
|
||||
Client.prototype.setGroupName = function (id, name, callback) {
|
||||
var data = {
|
||||
name: name
|
||||
};
|
||||
|
||||
post('/api/v1/groups/' + id, data, null, function (error, data, status) {
|
||||
put('/api/v1/groups/' + id + '/name', data, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 200) return callback(new ClientError(status, data));
|
||||
|
||||
@@ -2115,8 +2114,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
if (error) return callback(error);
|
||||
if (status !== 200) return callback(new ClientError(status, data));
|
||||
|
||||
console.log(data)
|
||||
|
||||
callback(null, data.info);
|
||||
});
|
||||
};
|
||||
@@ -2219,7 +2216,7 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
// amend properties to mimick full app
|
||||
data.applinks.forEach(function (applink) {
|
||||
applink.type = APP_TYPES.LINK;
|
||||
applink.fqdn = applink.upstreamUri; // this fqdn may contain the protocol!
|
||||
applink.fqdn = new URL(applink.upstreamUri).hostname;
|
||||
applink.manifest = { addons: {}};
|
||||
applink.installationState = ISTATES.INSTALLED;
|
||||
applink.runState = RSTATES.RUNNING;
|
||||
@@ -2278,17 +2275,26 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.updateUser = function (user, callback) {
|
||||
var data = {
|
||||
email: user.email,
|
||||
displayName: user.displayName,
|
||||
fallbackEmail: user.fallbackEmail,
|
||||
active: user.active,
|
||||
role: user.role
|
||||
};
|
||||
if (user.username) data.username = user.username;
|
||||
Client.prototype.updateUserProfile = function (userId, data, callback) {
|
||||
post('/api/v1/users/' + userId + '/profile', data, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
post('/api/v1/users/' + user.id, data, null, function (error, data, status) {
|
||||
callback(null);
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.setRole = function (userId, role, callback) {
|
||||
put('/api/v1/users/' + userId + '/role', { role: role }, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
callback(null);
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.setActive = function (userId, active, callback) {
|
||||
put('/api/v1/users/' + userId + '/active', { active: active }, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
@@ -2312,8 +2318,35 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.updateProfile = function (data, callback) {
|
||||
post('/api/v1/profile', data, null, function (error, data, status) {
|
||||
Client.prototype.setProfileDisplayName = function (displayName, callback) {
|
||||
post('/api/v1/profile/display_name', { displayName: displayName }, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
callback(null, data);
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.setProfileLanguage = function (language, callback) {
|
||||
post('/api/v1/profile/language', { language: language }, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
callback(null, data);
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.setProfileEmail = function (email, password, callback) {
|
||||
post('/api/v1/profile/email', { email: email, password: password }, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
callback(null, data);
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.setProfileFallbackEmail = function (fallbackEmail, password, callback) {
|
||||
post('/api/v1/profile/fallback_email', { fallbackEmail: fallbackEmail, password: password }, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
@@ -2367,15 +2400,6 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.makeUserLocal = function (userId, callback) {
|
||||
post('/api/v1/users/' + userId + '/make_local', {}, null, function (error, data, status) {
|
||||
if (error) return callback(error);
|
||||
if (status !== 204) return callback(new ClientError(status, data));
|
||||
|
||||
callback(null);
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.changePassword = function (currentPassword, newPassword, callback) {
|
||||
var data = {
|
||||
password: currentPassword,
|
||||
@@ -2507,14 +2531,19 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
});
|
||||
};
|
||||
|
||||
Client.prototype.refreshUserInfo = function (callback) {
|
||||
Client.prototype.refreshProfile = function (callback) {
|
||||
var that = this;
|
||||
|
||||
callback = typeof callback === 'function' ? callback : function () {};
|
||||
|
||||
this.userInfo(function (error, result) {
|
||||
this.getProfile(function (error, result) {
|
||||
if (error) return callback(error);
|
||||
|
||||
if (result.language !== '' && $translate.use() !== result.language) {
|
||||
console.log('Changing users language from ' + $translate.use() + ' to ', result.language);
|
||||
$translate.use(result.language);
|
||||
}
|
||||
|
||||
that.setUserInfo(result);
|
||||
callback(null);
|
||||
});
|
||||
@@ -3574,10 +3603,14 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
|
||||
var ACTION_DASHBOARD_DOMAIN_UPDATE = 'dashboard.domain.update';
|
||||
|
||||
var ACTION_DIRECTORY_SERVER_CONFIGURE = 'directoryserver.configure';
|
||||
|
||||
var ACTION_DOMAIN_ADD = 'domain.add';
|
||||
var ACTION_DOMAIN_UPDATE = 'domain.update';
|
||||
var ACTION_DOMAIN_REMOVE = 'domain.remove';
|
||||
|
||||
var ACTION_EXTERNAL_LDAP_CONFIGURE = 'externalldap.configure';
|
||||
|
||||
var ACTION_INSTALL_FINISH = 'cloudron.install.finish';
|
||||
|
||||
var ACTION_START = 'cloudron.start';
|
||||
@@ -3828,6 +3861,13 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
case ACTION_DASHBOARD_DOMAIN_UPDATE:
|
||||
return 'Dashboard domain set to ' + data.fqdn || (data.subdomain + '.' + data.domain);
|
||||
|
||||
case ACTION_DIRECTORY_SERVER_CONFIGURE:
|
||||
if (data.fromEnabled !== data.toEnabled) {
|
||||
return 'Directory server was ' + (data.toEnabled ? 'enabled' : 'disabled');
|
||||
} else {
|
||||
return 'Directory server configuration was changed';
|
||||
}
|
||||
|
||||
case ACTION_DOMAIN_ADD:
|
||||
return 'Domain ' + data.domain + ' with ' + data.provider + ' provider was added';
|
||||
|
||||
@@ -3837,6 +3877,13 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
case ACTION_DOMAIN_REMOVE:
|
||||
return 'Domain ' + data.domain + ' was removed';
|
||||
|
||||
case ACTION_EXTERNAL_LDAP_CONFIGURE:
|
||||
if (data.config.provider === 'noop') {
|
||||
return 'External Directory disabled';
|
||||
} else {
|
||||
return 'External Directory set to ' + data.config.url + ' (' + data.config.provider + ')';
|
||||
}
|
||||
|
||||
case ACTION_INSTALL_FINISH:
|
||||
return 'Cloudron version ' + data.version + ' installed';
|
||||
|
||||
@@ -3906,8 +3953,12 @@ angular.module('Application').service('Client', ['$http', '$interval', '$timeout
|
||||
return 'Apps of ' + data.oldOwnerId + ' was transferred to ' + data.newOwnerId;
|
||||
|
||||
case ACTION_USER_LOGIN:
|
||||
app = this.getCachedAppSync(data.appId);
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to ' + (app ? app.fqdn : data.appId);
|
||||
if (data.mailboxId) {
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to mailbox ' + data.mailboxId;
|
||||
} else {
|
||||
app = this.getCachedAppSync(data.appId);
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' logged in to ' + (app ? app.fqdn : data.appId);
|
||||
}
|
||||
|
||||
case ACTION_USER_LOGIN_GHOST:
|
||||
return 'User ' + (data.user ? data.user.username : data.userId) + ' was impersonated';
|
||||
|
||||
+26
-32
@@ -2,6 +2,7 @@
|
||||
|
||||
/* global angular:false */
|
||||
/* global $:false */
|
||||
/* global async */
|
||||
/* global ERROR,ISTATES,HSTATES,RSTATES,APP_TYPES,NOTIFICATION_TYPES */
|
||||
|
||||
// deal with accessToken in the query, this is passed for example on password reset and account setup upon invite
|
||||
@@ -118,7 +119,7 @@ app.config(['$routeProvider', function ($routeProvider) {
|
||||
}).otherwise({ redirectTo: '/'});
|
||||
}]);
|
||||
|
||||
app.filter('notificadtionTypeToColor', function () {
|
||||
app.filter('notificationTypeToColor', function () {
|
||||
return function (n) {
|
||||
switch (n.type) {
|
||||
case NOTIFICATION_TYPES.ALERT_REBOOT:
|
||||
@@ -785,47 +786,40 @@ app.controller('MainController', ['$scope', '$route', '$timeout', '$location', '
|
||||
console.log('Running dashboard version ', localStorage.version);
|
||||
|
||||
// get user profile as the first thing. this populates the "scope" and affects subsequent API calls
|
||||
Client.refreshUserInfo(function (error) {
|
||||
async.series([
|
||||
Client.refreshProfile.bind(Client),
|
||||
Client.refreshConfig.bind(Client),
|
||||
Client.refreshAvailableLanguages.bind(Client),
|
||||
Client.refreshInstalledApps.bind(Client)
|
||||
], function (error) {
|
||||
if (error) return Client.initError(error, init);
|
||||
|
||||
Client.refreshConfig(function (error) {
|
||||
if (error) return Client.initError(error, init);
|
||||
// now mark the Client to be ready
|
||||
Client.setReady();
|
||||
|
||||
Client.refreshAvailableLanguages(function (error) {
|
||||
if (error) return Client.initError(error, init);
|
||||
$scope.config = Client.getConfig();
|
||||
|
||||
Client.refreshInstalledApps(function (error) {
|
||||
if (error) return Client.initError(error, init);
|
||||
if (Client.getUserInfo().hasBackgroundImage) {
|
||||
document.getElementById('mainContentContainer').style.backgroundImage = 'url("' + Client.getBackgroundImageUrl() + '")';
|
||||
document.getElementById('mainContentContainer').classList.add('has-background');
|
||||
}
|
||||
|
||||
// now mark the Client to be ready
|
||||
Client.setReady();
|
||||
$scope.initialized = true;
|
||||
|
||||
$scope.config = Client.getConfig();
|
||||
redirectOnMandatory2FA();
|
||||
|
||||
if (Client.getUserInfo().hasBackgroundImage) {
|
||||
document.getElementById('mainContentContainer').style.backgroundImage = 'url("' + Client.getBackgroundImageUrl() + '")';
|
||||
document.getElementById('mainContentContainer').classList.add('has-background');
|
||||
}
|
||||
$interval(refreshNotifications, 60 * 1000);
|
||||
refreshNotifications();
|
||||
|
||||
$scope.initialized = true;
|
||||
Client.getSubscription(function (error, subscription) {
|
||||
if (error && error.statusCode === 412) return; // not yet registered
|
||||
if (error && error.statusCode === 402) return; // invalid appstore token
|
||||
if (error) return console.error(error);
|
||||
|
||||
redirectOnMandatory2FA();
|
||||
$scope.subscription = subscription;
|
||||
|
||||
$interval(refreshNotifications, 60 * 1000);
|
||||
refreshNotifications();
|
||||
|
||||
Client.getSubscription(function (error, subscription) {
|
||||
if (error && error.statusCode === 412) return; // not yet registered
|
||||
if (error && error.statusCode === 402) return; // invalid appstore token
|
||||
if (error) return console.error(error);
|
||||
|
||||
$scope.subscription = subscription;
|
||||
|
||||
// only track platform status if we are registered
|
||||
trackPlatformStatus();
|
||||
});
|
||||
});
|
||||
});
|
||||
// only track platform status if we are registered
|
||||
trackPlatformStatus();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -81,7 +81,8 @@ app.controller('PasswordResetController', ['$scope', '$translate', '$http', func
|
||||
identifier: $scope.passwordResetIdentifier
|
||||
};
|
||||
|
||||
function done() {
|
||||
function done(error) {
|
||||
if (error) $scope.error = error.message;
|
||||
$scope.busy = false;
|
||||
$scope.mode = 'passwordResetDone';
|
||||
}
|
||||
|
||||
@@ -212,7 +212,7 @@ app.controller('SetupDNSController', ['$scope', '$http', '$timeout', 'Client', f
|
||||
} else if (provider === 'linode') {
|
||||
config.token = $scope.dnsCredentials.linodeToken;
|
||||
} else if (provider === 'bunny') {
|
||||
config.token = $scope.dnsCredentials.bunnyAccessKey;
|
||||
config.accessKey = $scope.dnsCredentials.bunnyAccessKey;
|
||||
} else if (provider === 'dnsimple') {
|
||||
config.accessToken = $scope.dnsCredentials.dnsimpleAccessToken;
|
||||
} else if (provider === 'hetzner') {
|
||||
|
||||
@@ -89,7 +89,8 @@
|
||||
<div class="col-md-12" style="text-align: center;">
|
||||
<img width="128" height="128" style="margin-top: -84px" src="<%= apiOrigin %>/api/v1/cloudron/avatar"/>
|
||||
<br/>
|
||||
<h2>{{ 'passwordReset.emailSent.title' | tr }}</h2>
|
||||
<h2 ng-hide="error">{{ 'passwordReset.emailSent.title' | tr }}</h2>
|
||||
<h4 ng-show="error" class="has-error">{{ error }}</h4>
|
||||
<br/>
|
||||
<a href="/" class="btn btn-primary">{{ 'passwordReset.backToLoginAction' | tr }}</a>
|
||||
</div>
|
||||
|
||||
@@ -274,9 +274,9 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-class="{ 'has-error': error.remotePath }">
|
||||
<label class="control-label" for="inputConfigureRemotePath">Backup Path</label>
|
||||
<label class="control-label" for="inputConfigureRemotePath">Backup Path<sup><a ng-href="https://docs.cloudron.io/backups/#restore-cloudron" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
|
||||
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="Backup Path" required ng-disabled="busy">
|
||||
<input type="text" class="form-control" ng-model="remotePath" name="inputConfigureBackupId" placeholder="e.g. 2024-02-20-130007-637/box_v7.4.3.tar.gz" required ng-disabled="busy">
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-class="{ 'has-error': error.key }">
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
<label class="control-label" for="inputTotpToken">{{ login.2faToken }}</label>
|
||||
<input type="text" class="form-control" name="totpToken" id="inputTotpToken" value="">
|
||||
</div>
|
||||
<button class="btn btn-primary btn-outline pull-right" type="submit" id="login">{{ login.signInAction }}</button>
|
||||
<button class="btn btn-primary btn-outline pull-right" type="submit" id="login"><i id="busyIndicator" class="hide fa fa-circle-notch fa-spin"></i> {{ login.signInAction }}</button>
|
||||
</form>
|
||||
<!-- <a ng-href="" class="hand" ng-click="showPasswordReset()">Reset password</a> -->
|
||||
</div>
|
||||
@@ -67,6 +67,8 @@
|
||||
var password = document.getElementById('inputPassword').value;
|
||||
var totpToken = document.getElementById('inputTotpToken').value;
|
||||
|
||||
document.getElementById('busyIndicator').classList.remove('hide');
|
||||
|
||||
fetch('/login', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
@@ -75,6 +77,7 @@
|
||||
}).then(function (response) {
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
document.getElementById('message').innerText = "{{ login.errorIncorrectCredentials }}"; // FIXME this needs proper escaping for translated strings, single quotes break easily!
|
||||
document.getElementById('busyIndicator').classList.add('hide');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -65,6 +65,12 @@ $state-danger-border: $brand-danger;
|
||||
src: url(3rdparty/Roboto-Light.ttf);
|
||||
}
|
||||
|
||||
@font-face {
|
||||
font-family: Roboto;
|
||||
font-weight: 700;
|
||||
src: url(3rdparty/Roboto-Bold.ttf);
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Bootstrap extension
|
||||
// ----------------------------
|
||||
|
||||
@@ -182,7 +182,6 @@
|
||||
"title": "Tilslut en ekstern mappe",
|
||||
"description": "Cloudron synkroniserer brugere og grupper fra en ekstern LDAP- eller ActiveDirectory-server. Adgangskodebekræftelse til autentificering af disse brugere foretages mod den eksterne server. Synkroniseringen køres ikke automatisk, men skal udløses manuelt.",
|
||||
"bindUsername": "Bind DN/Benyttelsesnavn (valgfrit)",
|
||||
"subscriptionRequired": "Denne funktion er kun tilgængelig i de betalte abonnementer.",
|
||||
"subscriptionRequiredAction": "Oprettelse af abonnement nu",
|
||||
"noopInfo": "LDAP-godkendelse er ikke konfigureret.",
|
||||
"provider": "Udbyder",
|
||||
@@ -271,12 +270,6 @@
|
||||
"failed": "Følgende brugere blev ikke importeret:",
|
||||
"sendInviteCheckbox": "Send en e-mail med invitation til importerede brugere"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"description": "Dette vil migrere brugeren fra den eksterne mappe til Cloudron.",
|
||||
"title": "Gør denne bruger lokal",
|
||||
"warning": "En nulstilling af adgangskode vil blive iværksat for at indstille en lokal adgangskode for denne bruger.",
|
||||
"submitAction": "Gør lokale"
|
||||
},
|
||||
"title": "Brugerkatalog",
|
||||
"newUserAction": "Ny bruger",
|
||||
"users": {
|
||||
@@ -296,7 +289,6 @@
|
||||
"invitationTooltip": "Inviter bruger",
|
||||
"mailmanagerTooltip": "Denne bruger kan administrere brugere og postkasser",
|
||||
"count": "Antal brugere i alt: {{ count }}",
|
||||
"makeLocalTooltip": "Gør brugeren lokal",
|
||||
"setGhostTooltip": "Udgive sig for at være"
|
||||
},
|
||||
"groups": {
|
||||
|
||||
@@ -230,7 +230,6 @@
|
||||
"provider": "Anbieter",
|
||||
"noopInfo": "LDAP Authentifizierung ist nicht konfiguriert.",
|
||||
"subscriptionRequiredAction": "Abonnenement jetzt abschließen",
|
||||
"subscriptionRequired": "Diese Funktion ist nur im Abo enthalten.",
|
||||
"description": "Cloudron synchronisiert User und Gruppen aus dem externen LDAP- oder Active-Directory-Server. Passwörter beim Anmelden werden immer durch den externen Server validiert. Die Synchronisierung läuft nicht automatisch, sondern muss manuell gestartet werden.",
|
||||
"title": "Verbinde ein externes Verzeichnis",
|
||||
"providerOther": "Sonstige",
|
||||
@@ -269,8 +268,7 @@
|
||||
"invitationTooltip": "User einladen",
|
||||
"mailmanagerTooltip": "Dieser User kann Benutzer und Postfächer verwalten.",
|
||||
"setGhostTooltip": "Als anderer User ausgeben",
|
||||
"count": "User insgesamt: {{ count }}",
|
||||
"makeLocalTooltip": "Mache user lokal"
|
||||
"count": "User insgesamt: {{ count }}"
|
||||
},
|
||||
"newUserAction": "Neuer User",
|
||||
"role": {
|
||||
@@ -421,12 +419,6 @@
|
||||
"all": "Alle User",
|
||||
"active": "Aktive User",
|
||||
"inactive": "Inaktive User"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"description": "Dies migriert den User vom externen Verzeichnis zum Cloudron.",
|
||||
"warning": "Das Passwort wird zurückgesetzt um dem User ein lokale Passwort zu geben.",
|
||||
"title": "Mache den Benutzer lokal",
|
||||
"submitAction": "Änderungen lokal speichern"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
@@ -1074,7 +1066,7 @@
|
||||
"welcomeEmail": {
|
||||
"welcomeTo": "Willkommen bei <%= cloudronName %>!",
|
||||
"subject": "Willkommen bei <%= cloudron %>",
|
||||
"inviteLinkActionText": "Öffnen den folgenden Link um dich anzumelden: <%- inviteLink %>",
|
||||
"inviteLinkActionText": "Öffne den folgenden Link, um dich anzumelden: <%- inviteLink %>",
|
||||
"expireNote": "Dieser Link ist 7 Tage gültig.",
|
||||
"invitor": "Diese Email wurde geschickt, weil Du von <%= invitor %> eingeladen wurdest.",
|
||||
"inviteLinkAction": "Starte hier",
|
||||
|
||||
@@ -201,8 +201,7 @@
|
||||
"invitationTooltip": "Invite User",
|
||||
"setGhostTooltip": "Impersonate",
|
||||
"mailmanagerTooltip": "This user can manage users and mailboxes",
|
||||
"count": "Total users: {{ count }}",
|
||||
"makeLocalTooltip": "Make user local"
|
||||
"count": "Total users: {{ count }}"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groups",
|
||||
@@ -222,8 +221,7 @@
|
||||
},
|
||||
"externalLdap": {
|
||||
"title": "Connect an External Directory",
|
||||
"description": "Cloudron will synchronize users and groups from an external LDAP or ActiveDirectory server. Password verification for authenticating those users is done against the external server. The synchronization is not run automatically but needs to be triggered manually.",
|
||||
"subscriptionRequired": "This feature is only available in the paid plans.",
|
||||
"description": "This setting will synchronize and authenticate users and groups from an external LDAP or Active Directory server. The synchronization is run periodically but can also be triggered manually.",
|
||||
"subscriptionRequiredAction": "Set up Subscription Now",
|
||||
"noopInfo": "LDAP authentication is not configured.",
|
||||
"provider": "Provider",
|
||||
@@ -237,15 +235,16 @@
|
||||
"groupFilter": "Group Filter",
|
||||
"groupnameField": "Groupname Field",
|
||||
"auth": "Auth",
|
||||
"autocreateUsersOnLogin": "Automatically create users when they login to Cloudron",
|
||||
"autocreateUsersOnLogin": "Automatically create users on login",
|
||||
"showLogsAction": "Show Logs",
|
||||
"syncAction": "Synchronize",
|
||||
"syncAction": "Sync",
|
||||
"configureAction": "Configure",
|
||||
"bindUsername": "Bind DN/Username (optional)",
|
||||
"bindPassword": "Bind Password (optional)",
|
||||
"errorSelfSignedCert": "Server is using an invalid or self-signed certificate.",
|
||||
"providerOther": "Other",
|
||||
"providerDisabled": "Disabled"
|
||||
"providerDisabled": "Disabled",
|
||||
"disableWarning": "The authentication source of all existing users will be reset to authenticate against the local password database."
|
||||
},
|
||||
"subscriptionDialog": {
|
||||
"title": "Subscription required",
|
||||
@@ -274,7 +273,9 @@
|
||||
"errorDisplayNameRequired": "Name is required",
|
||||
"activeCheckbox": "User is active",
|
||||
"displayNamePlaceholder": "Optional. If not provided, user can provide during sign up",
|
||||
"fallbackEmailPlaceholder": "Optional. If not specified, primary email will be used"
|
||||
"fallbackEmailPlaceholder": "Optional. If not specified, primary email will be used",
|
||||
"external2FA": "2FA setup is managed by external authentication source",
|
||||
"ldapGroups": "LDAP Groups"
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
"title": "Delete user {{ username }}",
|
||||
@@ -363,7 +364,7 @@
|
||||
"description": "Cloudron can act as a central user directory server for external applications.",
|
||||
"enabled": "Enabled",
|
||||
"ipRestriction": {
|
||||
"description": "The directory server can be limited to specific IPs or ranges.",
|
||||
"description": "Limit Directory Server access to specific IPs or ranges. Lines starting with <code>#</code> are treated as comments.",
|
||||
"placeholder": "Line separated IP address or Subnet",
|
||||
"label": "Restrict Access"
|
||||
},
|
||||
@@ -371,7 +372,8 @@
|
||||
"label": "Bind Password",
|
||||
"description": "All LDAP queries have to be authenticated with this secret and the user DN <i>{{ userDN }}</i>",
|
||||
"url": "Server URL"
|
||||
}
|
||||
},
|
||||
"cloudflarePortWarning": "Cloudflare proxying must be disabled on the dashboard domain to access the LDAP server"
|
||||
},
|
||||
"userImportDialog": {
|
||||
"title": "Import Users",
|
||||
@@ -395,12 +397,6 @@
|
||||
"all": "All Users",
|
||||
"active": "Active Users",
|
||||
"inactive": "Inactive Users"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"title": "Make this user local",
|
||||
"description": "This will migrate the user from the external directory to the Cloudron.",
|
||||
"warning": "A password reset will be initiated to set a local password for this user.",
|
||||
"submitAction": "Make local"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
@@ -467,7 +463,10 @@
|
||||
"changeEmail": {
|
||||
"title": "Change primary email address",
|
||||
"errorEmailInvalid": "The Email address is not valid",
|
||||
"errorEmailRequired": "A valid email address is required"
|
||||
"errorEmailRequired": "A valid email address is required",
|
||||
"email": "New Email Address",
|
||||
"password": "Password for confirmation",
|
||||
"errorWrongPassword": "Wrong password"
|
||||
},
|
||||
"changeFallbackEmail": {
|
||||
"title": "Change password recovery email address",
|
||||
@@ -859,7 +858,7 @@
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "Canceled and ends on",
|
||||
"subscriptionSetupAction": "Upgrade to Premium",
|
||||
"subscriptionChangeAction": "Change Subscription",
|
||||
"subscriptionChangeAction": "Manage Subscription",
|
||||
"subscriptionReactivateAction": "Reactivate Subscription",
|
||||
"emailNotVerified": "Email not yet verified"
|
||||
},
|
||||
@@ -1155,7 +1154,8 @@
|
||||
"renameDialog": {
|
||||
"title": "Rename {{ fileName }}",
|
||||
"newName": "New Name",
|
||||
"rename": "Rename"
|
||||
"rename": "Rename",
|
||||
"reallyOverwrite": "A file with that name already exists. Overwrite existing file?"
|
||||
},
|
||||
"chownDialog": {
|
||||
"title": "Change ownership",
|
||||
|
||||
@@ -72,7 +72,10 @@
|
||||
"email": "Email",
|
||||
"description": "Esta cuenta se usa para acceder a la App Store y administrar tu suscripción",
|
||||
"titleLogin": "Iniciar sesión en Cloudron.io",
|
||||
"titleSignUp": "Regístrate en Cloudron.io"
|
||||
"titleSignUp": "Regístrate en Cloudron.io",
|
||||
"setupWithTokenAction": "Ajustes",
|
||||
"setupToken": "Configurar Token",
|
||||
"titleToken": "Registrarse con el token de configuración"
|
||||
},
|
||||
"appNotFoundDialog": {
|
||||
"description": "No hay aplicación <b>{{ appId }}</b> con versión <b>{{ version }}</b>.",
|
||||
@@ -163,7 +166,7 @@
|
||||
"description": "¿Qué te parece si instalas algunas? Echa un vistazo a la <a href=\"{{ appStoreLink }}\"> Tienda de Aplicaciones</a>",
|
||||
"title": "¡No hay aplicaciones instaladas todavía!"
|
||||
},
|
||||
"title": "Mis aplicaciones",
|
||||
"title": "Mis Aplicaciones",
|
||||
"groupsFilterHeader": "Todos los Grupos",
|
||||
"auth": {
|
||||
"nosso": "Inicia sesión con una cuenta dedicada",
|
||||
@@ -208,7 +211,6 @@
|
||||
"provider": "Proveedor",
|
||||
"noopInfo": "La autentificación LDAP no está configurada.",
|
||||
"subscriptionRequiredAction": "Configura tu Suscripción Ahora",
|
||||
"subscriptionRequired": "Esta característica solo está habilitada en planes de pago.",
|
||||
"description": "Cloudron sincronizará usuarios y grupos desde un servidor LDAP o ActiveDirectory externo. La verificación de la contraseña para autentificar a esos usuarios se realiza en el servidor externo. La sincronización no se ejecuta automáticamente, sino que debe activarse manualmente.",
|
||||
"title": "Conectar un directorio externo",
|
||||
"auth": "Auth",
|
||||
@@ -248,7 +250,6 @@
|
||||
"setGhostTooltip": "Suplantar",
|
||||
"invitationTooltip": "Invitar Usuario",
|
||||
"mailmanagerTooltip": "Este usuario puede administrar usuarios y buzones de correo",
|
||||
"makeLocalTooltip": "Hacer que el usuario sea local",
|
||||
"count": "Total usuarios: {{ count }}"
|
||||
},
|
||||
"newUserAction": "Nuevo Usuario",
|
||||
@@ -392,12 +393,6 @@
|
||||
"all": "Todos los Usuarios",
|
||||
"active": "Usuarios Activos",
|
||||
"inactive": "Usuarios Inactivos"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"title": "Hacer este usuario local",
|
||||
"description": "Esto migrará el usuario desde un directorio externo a Cloudron.",
|
||||
"submitAction": "Hacer local",
|
||||
"warning": "Se iniciará un restablecimiento de contraseña para establecer una contraseña local para este usuario."
|
||||
}
|
||||
},
|
||||
"backups": {
|
||||
@@ -975,7 +970,12 @@
|
||||
"bunnyAccessKey": "Clave de acceso Bunny",
|
||||
"cloudflareDefaultProxyStatus": "Habilitar proxy para nuevos registros DNS",
|
||||
"porkbunApikey": "Clave API",
|
||||
"porkbunSecretapikey": "Clave API secreta"
|
||||
"porkbunSecretapikey": "Clave API secreta",
|
||||
"dnsimpleAccessToken": "Token de acceso",
|
||||
"ovhEndpoint": "Punto final",
|
||||
"ovhConsumerKey": "Clave del consumidor",
|
||||
"ovhAppKey": "Clave de Aplicación",
|
||||
"ovhAppSecret": "Clave Secreta Aplicación"
|
||||
},
|
||||
"subscriptionRequired": {
|
||||
"setupAction": "Configura tu suscripción",
|
||||
@@ -1389,7 +1389,19 @@
|
||||
"volumeContent": "Este disco es el volumen <code>{{ name }}</code>",
|
||||
"diskSpeed": "Velocidad: {{ speed }} MB/seg"
|
||||
},
|
||||
"selectPeriodLabel": "Seleccionar Periodo"
|
||||
"selectPeriodLabel": "Seleccionar Periodo",
|
||||
"info": {
|
||||
"title": "Información",
|
||||
"memory": "Memoria",
|
||||
"uptime": "Tiempo de actividad",
|
||||
"activationTime": "Tiempo de creación de Cloudron",
|
||||
"platformVersion": "Versión de plataforma",
|
||||
"product": "Producto",
|
||||
"vendor": "Vendedor"
|
||||
},
|
||||
"graphs": {
|
||||
"title": "Gráficos"
|
||||
}
|
||||
},
|
||||
"support": {
|
||||
"remoteSupport": {
|
||||
@@ -1421,7 +1433,11 @@
|
||||
"emailNotVerified": "El correo electrónico de su cuenta cloudron.io {{email}} no está verificado. Verifíquelo para abrir tickets de soporte.",
|
||||
"typeBilling": "Problema de facturación"
|
||||
},
|
||||
"title": "Soporte"
|
||||
"title": "Soporte",
|
||||
"help": {
|
||||
"title": "Ayuda",
|
||||
"description": "Utiliza los siguientes recursos para obtener ayuda y soporte:\n* [Foro de Cloudron]({{ forumLink }}) - Utiliza las categorías específicas de Soporte y Aplicación si tiene preguntas.\n* [Base de conocimientos y documentos de Cloudron]({{ docsLink }})\n* [API y empaquetado de aplicaciones personalizadas]({{ packagingLink }})\n"
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
"removeVolumeDialog": {
|
||||
@@ -1496,7 +1512,8 @@
|
||||
"renameDialog": {
|
||||
"title": "Renombrar {{ fileName }}",
|
||||
"newName": "Nuevo Nombre",
|
||||
"rename": "Renombrar"
|
||||
"rename": "Renombrar",
|
||||
"reallyOverwrite": "Ya existe un archivo con ese nombre. ¿Sobrescribir el archivo existente?"
|
||||
},
|
||||
"chownDialog": {
|
||||
"newOwner": "Nuevo propietario",
|
||||
|
||||
@@ -22,7 +22,8 @@
|
||||
"auth": {
|
||||
"nosso": "Se connecter avec un compte dédié",
|
||||
"email": "Se connecter avec une adresse email",
|
||||
"sso": "Se connecter avec vos identifiants Cloudron"
|
||||
"sso": "Se connecter avec vos identifiants Cloudron",
|
||||
"openid": "Se connecter avec Cloudron OpenID"
|
||||
},
|
||||
"addAppAction": "Ajouter Application",
|
||||
"addAppproxyAction": "Ajouter Proxy d'application",
|
||||
@@ -39,7 +40,8 @@
|
||||
"cancel": "Annuler",
|
||||
"save": "Sauvegarder",
|
||||
"no": "Non",
|
||||
"yes": "Oui"
|
||||
"yes": "Oui",
|
||||
"delete": "Supprimer"
|
||||
},
|
||||
"username": "Nom d'utilisateur",
|
||||
"actions": "Actions",
|
||||
@@ -55,7 +57,8 @@
|
||||
},
|
||||
"action": {
|
||||
"logs": "Journaux",
|
||||
"reboot": "Redémarrer"
|
||||
"reboot": "Redémarrer",
|
||||
"showLogs": "Afficher Journaux"
|
||||
},
|
||||
"rebootDialog": {
|
||||
"rebootAction": "Redémarrer maintenant",
|
||||
@@ -87,7 +90,9 @@
|
||||
},
|
||||
"disableAction": "Désactiver",
|
||||
"enableAction": "Activer",
|
||||
"loadingPlaceholder": "Chargement"
|
||||
"loadingPlaceholder": "Chargement",
|
||||
"settings": "Paramètres",
|
||||
"saveAction": "Sauvegarde"
|
||||
},
|
||||
"users": {
|
||||
"title": "Annuaire des utilisateurs",
|
||||
@@ -108,8 +113,7 @@
|
||||
"setGhostTooltip": "Emprunter l'identité",
|
||||
"invitationTooltip": "Envoyer une invitation à l'utilisateur",
|
||||
"mailmanagerTooltip": "Cet utilisateur peut gérer les utilisateurs et les boîtes mail",
|
||||
"count": "Total des utilisateurs : {{ count }}",
|
||||
"makeLocalTooltip": "Rendre l'utilisateur local"
|
||||
"count": "Total des utilisateurs : {{ count }}"
|
||||
},
|
||||
"newUserAction": "Nouvel utilisateur",
|
||||
"groups": {
|
||||
@@ -120,7 +124,7 @@
|
||||
"externalLdapTooltip": "Depuis un annuaire LDAP externe"
|
||||
},
|
||||
"settings": {
|
||||
"title": "Paramètres",
|
||||
"title": "Paramètres Utilisateur",
|
||||
"allowProfileEditCheckbox": "Autoriser les utilisateurs à modifier leur nom et leur adresse email",
|
||||
"saveAction": "Enregistrer",
|
||||
"subscriptionRequired": "Ces fonctionnalités sont uniquement disponibles dans la version payante.",
|
||||
@@ -140,7 +144,6 @@
|
||||
"groupnameField": "Champ nom du groupe",
|
||||
"syncGroups": "Groupes synchronisés",
|
||||
"filter": "Filtre",
|
||||
"subscriptionRequired": "Cette fonctionnalité est disponible uniquement dans la version payante.",
|
||||
"acceptSelfSignedCert": "Accepter le certificat auto-signé",
|
||||
"usernameField": "Champ nom d'utilisateur",
|
||||
"groupFilter": "Filtre des groupes",
|
||||
@@ -266,12 +269,6 @@
|
||||
"title": "Lien d'invitation envoyé",
|
||||
"body": "Email envoyé à {{ email }}"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"description": "Cela migrera l'utilisateur du répertoire externe vers le Cloudron.",
|
||||
"submitAction": "Rendre local",
|
||||
"title": "Rendre cet utilisateur local",
|
||||
"warning": "Une réinitialisation du mot de passe sera initiée pour définir un mot de passe local pour cet utilisateur."
|
||||
},
|
||||
"exposedLdap": {
|
||||
"secret": {
|
||||
"label": "Mot de passe de liaison",
|
||||
@@ -502,7 +499,8 @@
|
||||
"port": "Port",
|
||||
"cifsSealSupport": "Utilisez le cryptage du sceau. Nécessite au moins SMB v3",
|
||||
"chown": "Le système de fichiers distant prend en charge chown",
|
||||
"encryptedFilenames": "Crypter les noms de fichiers"
|
||||
"encryptedFilenames": "Crypter les noms de fichiers",
|
||||
"encryptFilenames": "Fichiers Cryptés"
|
||||
},
|
||||
"backupDetails": {
|
||||
"title": "Informations sur la sauvegarde",
|
||||
@@ -544,7 +542,8 @@
|
||||
"preserved": {
|
||||
"description": "Sauvegarde persistante quelle que soit la politique de rétention",
|
||||
"tooltip": "Cela préservera également le courrier et les sauvegardes d'application {{ appsLength }}."
|
||||
}
|
||||
},
|
||||
"remotePath": "Répertoire Distant"
|
||||
}
|
||||
},
|
||||
"emails": {
|
||||
@@ -600,7 +599,8 @@
|
||||
"solrEnabled": "Activé",
|
||||
"solrRunning": "Actif",
|
||||
"acl": "Adresse ACL (liste de contrôle d'accès)",
|
||||
"aclOverview": "{{ dnsblZonesCount }} liste(s) DNSBL"
|
||||
"aclOverview": "{{ dnsblZonesCount }} liste(s) DNSBL",
|
||||
"virtualAllMail": "Dossier \"Tout les Emails\""
|
||||
},
|
||||
"domains": {
|
||||
"disabled": "Désactivé",
|
||||
@@ -859,7 +859,10 @@
|
||||
"titleLogin": "Se connecter à Cloudron.io",
|
||||
"description": "Ce compte permet d'accéder à l'App Store et de gérer votre abonnement",
|
||||
"2faToken": "Jeton 2FA (si activé)",
|
||||
"intendedUse": "Type d'usage"
|
||||
"intendedUse": "Type d'usage",
|
||||
"setupWithTokenAction": "Configuration",
|
||||
"setupToken": "Configuration Jeton",
|
||||
"titleToken": "Se connecter avec un Jeton"
|
||||
},
|
||||
"title": "App Store",
|
||||
"appNotFoundDialog": {
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"apps": {
|
||||
"tagsFilterHeaderAll": "Semua Tag",
|
||||
"adminPageActionTooltip": "Halaman Admin",
|
||||
"domainsFilterHeader": "Semua Domain",
|
||||
"groupsFilterHeader": "Semua Grup",
|
||||
"addAppAction": "Tambah Aplikasi",
|
||||
"title": "Aplikasi Saya",
|
||||
"tagsFilterHeader": "Tag: {{ tags }}"
|
||||
},
|
||||
"main": {
|
||||
"dialog": {
|
||||
"no": "Tidak",
|
||||
"yes": "Ya",
|
||||
"delete": "Hapus",
|
||||
"save": "Simpan"
|
||||
},
|
||||
"table": {
|
||||
"date": "Tanggal"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -932,7 +932,6 @@
|
||||
"server": "URL del Server",
|
||||
"noopInfo": "L'autenticazione LDAP non è configurata.",
|
||||
"subscriptionRequiredAction": "Attiva un piano a pagamento",
|
||||
"subscriptionRequired": "Questa funzionalità è disponibile solo nei piani a pagamento.",
|
||||
"description": "Cloudron sincronizzerà utenti e gruppi da un server LDAP o ActiveDirectory esterni. La verifica della password per l'autenticazione di tali utenti viene eseguita sul server esterno. La sincronizzazione non viene eseguita automaticamente ma deve essere attivata manualmente.",
|
||||
"auth": "Auth",
|
||||
"groupnameField": "Campo Groupname",
|
||||
|
||||
@@ -201,8 +201,7 @@
|
||||
"invitationTooltip": "Gebruiker uitnodigen",
|
||||
"setGhostTooltip": "Nabootsen",
|
||||
"mailmanagerTooltip": "Deze gebruiker kan gebruikers en mailboxen beheren",
|
||||
"count": "Totaal gebruikers: {{ count }}",
|
||||
"makeLocalTooltip": "Maak gebruiker lokaal"
|
||||
"count": "Totaal gebruikers: {{ count }}"
|
||||
},
|
||||
"groups": {
|
||||
"title": "Groepen",
|
||||
@@ -222,7 +221,6 @@
|
||||
},
|
||||
"externalLdap": {
|
||||
"title": "Verbind met een externe lijst",
|
||||
"subscriptionRequired": "Deze functie is alleen beschikbaar voor betaalde abonnementen.",
|
||||
"subscriptionRequiredAction": "Neem nu een abonnement",
|
||||
"noopInfo": "LDAP authenticatie is niet geconfigureerd.",
|
||||
"provider": "Aanbieder",
|
||||
@@ -236,16 +234,17 @@
|
||||
"groupnameField": "Veld voor groepsnaam",
|
||||
"server": "Server URL",
|
||||
"showLogsAction": "Toon logbestanden",
|
||||
"syncAction": "Synchroniseer",
|
||||
"syncAction": "Sync",
|
||||
"configureAction": "Configureer",
|
||||
"bindUsername": "Bind DN/Username (optioneel)",
|
||||
"bindPassword": "Bind Password (optioneel)",
|
||||
"errorSelfSignedCert": "Server gebruikt een ongeldig of zelf-ondertekend certificaat.",
|
||||
"description": "Cloudron synchroniseert gebruikers en groepen van een extern LDAP of ActiveDirectory server. Wachtwoordverificatie vindt plaats door de externe server. De synchronisatie is niet automatisch en dient handmatig gestart te worden.",
|
||||
"description": "Deze instelling synchroniseert en authentificeert gebruikers en groepen van een extern LDAP of ActiveDirectory server. De synchronisatie is periodiek maar kan ook handmatig gestart worden.",
|
||||
"auth": "Authenticatie",
|
||||
"autocreateUsersOnLogin": "Maak automatisch gebruikers aan na inloggen bij deze Cloudron",
|
||||
"autocreateUsersOnLogin": "Maak automatisch gebruikers bij inloggen",
|
||||
"providerOther": "Anders",
|
||||
"providerDisabled": "Uitgeschakeld"
|
||||
"providerDisabled": "Uitgeschakeld",
|
||||
"disableWarning": "De authentificatie-bron van alle bestaande gebruikers zal worden omgezet naar authentificatie via de lokale wachtwoord database."
|
||||
},
|
||||
"subscriptionDialog": {
|
||||
"title": "Abonnement benodigd",
|
||||
@@ -274,7 +273,8 @@
|
||||
"errorInvalidUsername": "Dit is geen geldige gebruikersnaam",
|
||||
"usernamePlaceholder": "Optioneel. Indien niet ingevuld mag de gebruiker bij registratie zelf kiezen",
|
||||
"fallbackEmailPlaceholder": "Optioneel. Indien niet ingevoerd zal de primaire e-mail gebruikt worden",
|
||||
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding"
|
||||
"displayNamePlaceholder": "Optioneel. Indien niet ingevoerd kan de gebruiker het kiezen tijdens eerste aanmelding",
|
||||
"external2FA": "2FA instellingen worden beheerd door een externe authenticatie bron"
|
||||
},
|
||||
"deleteUserDialog": {
|
||||
"deleteAction": "Verwijder",
|
||||
@@ -361,17 +361,18 @@
|
||||
"exposedLdap": {
|
||||
"ipRestriction": {
|
||||
"placeholder": "Regelgescheiden IP adres of Subnet",
|
||||
"description": "De lijstserver kan beperkt worden tot specifieke IP's of bereiken.",
|
||||
"description": "Beperk de toegang tot de Directory Server tot specifieke IP's of bereiken. Regels die starten met <code>#</code> worden beschouwd als commentaar.",
|
||||
"label": "Beperk toegang"
|
||||
},
|
||||
"enabled": "Ingeschakeld",
|
||||
"title": "Lijst server",
|
||||
"description": "Cloudron kan ingezet worden als gebruikerslijstserver voor externe applicaties.",
|
||||
"title": "Directory Server",
|
||||
"description": "Cloudron kan ingezet worden als gebruikers Directory Server voor externe applicaties.",
|
||||
"secret": {
|
||||
"label": "Koppel wachtwoord",
|
||||
"description": "Alle LDAP verzoeken moeten geauthentiseerd worden met dit geheim en de gebruiker DN <i>{{ userDN }}</i>",
|
||||
"url": "Server URL"
|
||||
}
|
||||
},
|
||||
"cloudflarePortWarning": "Cloudflare proxy moet uitgeschakeld zijn op het domein van het dashboard om de LDAP server te kunnen bereiken"
|
||||
},
|
||||
"userImportDialog": {
|
||||
"title": "Importeer gebruikers",
|
||||
@@ -395,12 +396,6 @@
|
||||
"all": "Alle gebruikers",
|
||||
"active": "Actieve gebruikers",
|
||||
"inactive": "Inactieve gebruikers"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"title": "Maak deze gebruiker lokaal",
|
||||
"description": "De gebruiker wordt hiermee gemigreerd van de externe gebruikerslijst naar die van Cloudron.",
|
||||
"warning": "Een wachtwoord herstel wordt geïnitieerd om een lokaal wachtwoord in te stellen voor deze gebruiker.",
|
||||
"submitAction": "Maak lokaal"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
@@ -467,7 +462,10 @@
|
||||
"changeEmail": {
|
||||
"title": "Primair e-mailadres aanpassen",
|
||||
"errorEmailInvalid": "Het e-mailadres is niet geldig",
|
||||
"errorEmailRequired": "Een geldig e-mailadres is verplicht"
|
||||
"errorEmailRequired": "Een geldig e-mailadres is verplicht",
|
||||
"email": "Nieuw e-mailadres",
|
||||
"errorWrongPassword": "Onjuist wachtwoord",
|
||||
"password": "Wachtwoord ter bevestiging"
|
||||
},
|
||||
"changeFallbackEmail": {
|
||||
"errorEmailRequired": "Een geldig e-mailadres is verplicht",
|
||||
@@ -1294,7 +1292,7 @@
|
||||
"cloudronId": "Cloudron ID",
|
||||
"subscriptionEndsAt": "Opgezegd en eindigt op",
|
||||
"subscriptionSetupAction": "Upgrade naar Premium",
|
||||
"subscriptionChangeAction": "Abonnement wijzigen",
|
||||
"subscriptionChangeAction": "Beheer abonnement",
|
||||
"subscriptionReactivateAction": "Abonnement heractiveren",
|
||||
"title": "Cloudron.io Account",
|
||||
"description": "Een Cloudron.io account wordt gebruikt voor toegang tot de App Store en om je abonnement te beheren.",
|
||||
@@ -1393,7 +1391,7 @@
|
||||
},
|
||||
"help": {
|
||||
"title": "Hulp",
|
||||
"description": "Om problemen op te lossen met Cloudron hebben we verschillende bronnen:\n* [Kennisbank & App Docs]({{ docsLink }})\n* [Eigen App Packaging & API]({{ packagingLink }})\n* [Forum]({{ forumLink }})"
|
||||
"description": "Gebruik de volgende bronnen voor hulp en ondersteuning:\n* [Cloudron Forum]({{ forumLink }}) - Gebruik de Support en App specifieke categorieën voor vragen.\n* [Cloudron Docs & Knowledge Base]({{ docsLink }})\n* [Custom App Packaging & API]({{ packagingLink }})\n"
|
||||
}
|
||||
},
|
||||
"system": {
|
||||
@@ -1497,7 +1495,8 @@
|
||||
"renameDialog": {
|
||||
"title": "Hernoem {{ fileName }}",
|
||||
"newName": "Nieuwe naam",
|
||||
"rename": "Hernoem"
|
||||
"rename": "Hernoem",
|
||||
"reallyOverwrite": "Een bestand met die naam bestaat al. Wil je het bestaande bestand overschrijven?"
|
||||
},
|
||||
"chownDialog": {
|
||||
"newOwner": "Nieuwe eigenaar",
|
||||
@@ -1729,7 +1728,7 @@
|
||||
},
|
||||
"addMailinglistDialog": {
|
||||
"title": "Maillijst toevoegen",
|
||||
"members": "Lijst leden",
|
||||
"members": "Ledenlijst",
|
||||
"membersInfo": "Plaats meerdere e-mailadressen elk op een nieuwe regel",
|
||||
"membersOnlyCheckbox": "Het versturen van e-mail aan deze lijst beperken tot de leden",
|
||||
"name": "Naam"
|
||||
|
||||
@@ -196,8 +196,7 @@
|
||||
"invitationTooltip": "Пригласить пользователя",
|
||||
"setGhostTooltip": "Обезличить",
|
||||
"mailmanagerTooltip": "Этот пользователь может управлять другими пользователями и почтовыми ящиками",
|
||||
"count": "Всего пользователей: {{ count }}",
|
||||
"makeLocalTooltip": "Сделать пользователя локальным"
|
||||
"count": "Всего пользователей: {{ count }}"
|
||||
},
|
||||
"title": "Каталог пользователей",
|
||||
"newUserAction": "Новый пользователь",
|
||||
@@ -222,7 +221,6 @@
|
||||
"bindPassword": "Привязать пароль (необязательно)",
|
||||
"bindUsername": "Привязать Уникальное имя (DN)/Имя пользователя (необязательно)",
|
||||
"title": "Подключиться к удалённому каталогу",
|
||||
"subscriptionRequired": "Данная функция доступна только в платной подписке.",
|
||||
"subscriptionRequiredAction": "Настроить подписку сейчас",
|
||||
"noopInfo": "LDAP аутентификация не настроена.",
|
||||
"provider": "Поставщик",
|
||||
@@ -392,12 +390,6 @@
|
||||
"all": "Все пользователи",
|
||||
"active": "Активные пользователи",
|
||||
"inactive": "Неактивные пользователи"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"title": "Установить этого пользователя локально",
|
||||
"description": "Данное действие перенесёт пользователя с внешней директории LDAP в Cloudron.",
|
||||
"warning": "Для создания локального пароля пользователя его прежний пароль будет сброшен.",
|
||||
"submitAction": "Сделать локальным"
|
||||
}
|
||||
},
|
||||
"profile": {
|
||||
|
||||
@@ -54,7 +54,8 @@
|
||||
},
|
||||
"action": {
|
||||
"reboot": "Khởi động lại",
|
||||
"logs": "Log"
|
||||
"logs": "Log",
|
||||
"showLogs": "Hiển thị log"
|
||||
},
|
||||
"clipboard": {
|
||||
"clickToCopy": "Bấm để copy",
|
||||
@@ -89,7 +90,8 @@
|
||||
"enableAction": "Bật",
|
||||
"disableAction": "Tắt",
|
||||
"loadingPlaceholder": "Đang tải",
|
||||
"settings": "Cài đặt"
|
||||
"settings": "Cài đặt",
|
||||
"saveAction": "Lưu"
|
||||
},
|
||||
"appstore": {
|
||||
"title": "Cửa hàng App",
|
||||
@@ -237,7 +239,6 @@
|
||||
"subscriptionRequiredAction": "Cài đặt gói đăng ký ngay",
|
||||
"description": "Cloudron sẽ đồng bộ người dùng và nhóm từ server LDAP hay ActiveDirectory bên ngoài. Xác minh mật khẩu cho người dùng được dựa trên server ngoài. Việc đồng bộ hoá không được chạy tự động mà cần được khởi động bằng tay.",
|
||||
"title": "Kết nối thư mục ngoài",
|
||||
"subscriptionRequired": "Tính năng này chỉ có trong gói trả phí.",
|
||||
"providerOther": "Khác",
|
||||
"providerDisabled": "Đã tắt"
|
||||
},
|
||||
@@ -258,8 +259,7 @@
|
||||
"invitationTooltip": "Mời Người dùng",
|
||||
"setGhostTooltip": "Nhập vai",
|
||||
"count": "Tổng ng dùng: {{ count }}",
|
||||
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư",
|
||||
"makeLocalTooltip": "Người dùng địa phương"
|
||||
"mailmanagerTooltip": "Người dùng này có thể quản lý những ng dùng khác và cả những hộp thư"
|
||||
},
|
||||
"settings": {
|
||||
"saveAction": "Lưu",
|
||||
@@ -364,12 +364,6 @@
|
||||
"all": "Tất cả Người dùng",
|
||||
"active": "Những người dùng đang hoạt động"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"description": "Chức năng này sẽ di chuyển người dùng từ chỉ mục ngoài vào trong Cloudron.",
|
||||
"title": "Người dùng địa phương",
|
||||
"warning": "Phần đặt lại mật khẩu sẽ được kích hoạt để đặt một mật khẩu địa phương cho người dùng này.",
|
||||
"submitAction": "Địa phương hoá"
|
||||
},
|
||||
"setGhostDialog": {
|
||||
"generatePassword": "Tạo mật khẩu",
|
||||
"title": "Tạo mật khẩu để nhập vai người dùng {{ username }}",
|
||||
@@ -641,7 +635,9 @@
|
||||
"password": "Mật khẩu",
|
||||
"username": "Tên đăng nhập",
|
||||
"errorIncorrectCredentials": "Không đúng tên đăng nhập hoặc mật khẩu",
|
||||
"loginTo": "Đăng nhập vào"
|
||||
"loginTo": "Đăng nhập vào",
|
||||
"errorIncorrect2FAToken": "Mã bảo mật 2 Bước không đúng",
|
||||
"errorInternal": "Lỗi nội bộ hệ thống, vui lòng thử lại sau"
|
||||
},
|
||||
"setupAccount": {
|
||||
"username": "Tên đăng nhập",
|
||||
@@ -780,7 +776,8 @@
|
||||
"noAliases": "Không có tên gọi khác nào được chỉnh.",
|
||||
"aliases": "Tên gọi khác",
|
||||
"owner": "Chủ hộp thư",
|
||||
"title": "Chỉnh sửa hộp thư {{ name }}@{{ domain }}"
|
||||
"title": "Chỉnh sửa hộp thư {{ name }}@{{ domain }}",
|
||||
"enableStorageQuota": "Bật giới hạn lưu trữ"
|
||||
},
|
||||
"addMailboxDialog": {
|
||||
"owner": "Chủ hộp thư",
|
||||
@@ -863,7 +860,8 @@
|
||||
},
|
||||
"dyndns": {
|
||||
"description": "Bật lựa chọn này để đồng bộ các bản ghi DNS với một địa chỉ IP thường xuyên thay đổi. Việc này hữu ích khi Cloudron chạy trên hệ thống mạng với địa chỉ IP hay thay đổi như kết nối mạng ở nhà.",
|
||||
"title": "DNS động"
|
||||
"title": "DNS động",
|
||||
"showLogsAction": "Hiển thị log"
|
||||
},
|
||||
"firewall": {
|
||||
"configure": {
|
||||
@@ -1219,7 +1217,8 @@
|
||||
"download": "Tải xuống",
|
||||
"extract": "Giải nén tại đây",
|
||||
"chown": "Đổi quyền sở hữu",
|
||||
"rename": "Đổi tên"
|
||||
"rename": "Đổi tên",
|
||||
"open": "Mở"
|
||||
},
|
||||
"name": "Tên",
|
||||
"symlink": "Liên kết symlink đến {{ target }}",
|
||||
@@ -1277,7 +1276,19 @@
|
||||
},
|
||||
"removeDialog": {
|
||||
"reallyDelete": "Chắc chắn xoá?"
|
||||
}
|
||||
},
|
||||
"uploader": {
|
||||
"exitWarning": "Vẫn đang tải lên. Bạn có chắc muốn đóng trang này?",
|
||||
"uploading": "Đang tải lên"
|
||||
},
|
||||
"textEditor": {
|
||||
"undo": "Hoàn tác",
|
||||
"redo": "Xóa hoàn tác",
|
||||
"save": "Lưu"
|
||||
},
|
||||
"extractionInProgress": "Đang giải nén",
|
||||
"pasteInProgress": "Đang dán",
|
||||
"deleteInProgress": "Đang xoá"
|
||||
},
|
||||
"terminal": {
|
||||
"contextmenu": {
|
||||
@@ -1474,7 +1485,8 @@
|
||||
"time": "Tạo ra lúc",
|
||||
"packageVersion": "Phiên bản đóng gói",
|
||||
"description": "Bản sao lưu là những bản chụp snapshot hoàn chỉnh của app. Bạn có thể dùng các bản sao lưu để khôi phục hoặc nhân bản app này.",
|
||||
"title": "Bản sao lưu"
|
||||
"title": "Bản sao lưu",
|
||||
"downloadBackupTooltip": "Tải bản sao lưu"
|
||||
}
|
||||
},
|
||||
"updates": {
|
||||
@@ -1494,8 +1506,10 @@
|
||||
"packageVersion": "Phiên bản đóng gói",
|
||||
"appId": "ID của app",
|
||||
"description": "Tên app và phiên bản",
|
||||
"title": "Thông tin app"
|
||||
}
|
||||
"title": "Thông tin app",
|
||||
"repository": "Repo của bản đống gói"
|
||||
},
|
||||
"noUpdates": "Không có phiên bản mới"
|
||||
},
|
||||
"security": {
|
||||
"robots": {
|
||||
@@ -1507,7 +1521,8 @@
|
||||
"saveAction": "Lưu",
|
||||
"title": "Chính sách an ninh nội dung",
|
||||
"description": "Cài đặt lựa chọn này sẽ ghi chèn lên những CSP header gửi từ app này ra"
|
||||
}
|
||||
},
|
||||
"hstsPreload": "Bật HSTS preload cho trang web này và tất cả tên miền phụ"
|
||||
},
|
||||
"email": {
|
||||
"csp": {
|
||||
@@ -1541,7 +1556,10 @@
|
||||
"24h": "24 tiếng trước",
|
||||
"12h": "12 tiếng trước",
|
||||
"6h": "6 tiếng"
|
||||
}
|
||||
},
|
||||
"diskTitle": "Dung lượng ổ đĩa",
|
||||
"diskIOTotal": "tổng: đọc {{ read }} / ghi {{ write }}",
|
||||
"networkIOTotal": "tổng: vào {{ inbound }} / ra {{ outbound }}"
|
||||
},
|
||||
"storage": {
|
||||
"mounts": {
|
||||
@@ -1550,13 +1568,20 @@
|
||||
"noMounts": "Không có volume được gắn thêm.",
|
||||
"volume": "Volume",
|
||||
"title": "Thư mục mount thêm",
|
||||
"readOnly": "Chỉ cho phép đọc"
|
||||
"readOnly": "Chỉ cho phép đọc",
|
||||
"permissions": {
|
||||
"readOnly": "Chỉ cho phép đọc",
|
||||
"readWrite": "Đọc và ghi",
|
||||
"label": "Quyền cấp phép"
|
||||
}
|
||||
},
|
||||
"appdata": {
|
||||
"moveAction": "Chuyển dữ liệu",
|
||||
"dataDirPlaceholder": "Để trống để dùng giá trị mặc định của hệ thống",
|
||||
"description": "Nếu hệ thống đang chạy sắp hết dung lượng ổ đĩa, hãy dùng chức năng này để dời những dữ liệu của app sang qua <a href=\"/#/volumes\">volume</a>. Bất cứ dữ liệu nào trong đây đều được sao lưu như một phần trong tổng thể app.",
|
||||
"title": "Thư mục Dữ liệu"
|
||||
"title": "Thư mục Dữ liệu",
|
||||
"diskUsage": "App hiện đang dùng {{ size }} trong bộ lưu trữ (tính đến ngày{{ date }}).",
|
||||
"mountTypeWarning": "Hệ thống tập tin điểm cuối phải hỗ trợ quyền cấp phép và sở hữu cho tập tin để có thể di chuyển dữ liệu"
|
||||
}
|
||||
},
|
||||
"resources": {
|
||||
@@ -1665,7 +1690,7 @@
|
||||
"setupSubscriptionAction": "Cài đặt gói đăng ký",
|
||||
"skipBackupCheckbox": "Bỏ qua sao lưu",
|
||||
"subscriptionExpired": "Gói đăng ký Cloudron của bạn đã hết hạn. Xin cài đặt một gói đăng ký để cập nhật app.",
|
||||
"changelogHeader": "Những thay đổi trong phiên bản mới {{ version}}:",
|
||||
"changelogHeader": "Những thay đổi trong phiên bản dóng gói mới {{ version}}:",
|
||||
"unstableWarning": "Bản cập nhật này là phiên bản ra mắt sớm và chưa được ổn định. Xin lưu ý rủi ro khi cập nhật.",
|
||||
"title": "Cập nhật {{ app }}"
|
||||
},
|
||||
@@ -1673,7 +1698,8 @@
|
||||
"importAction": "Nhập vào",
|
||||
"uploadAction": "Tải lên cấu hình bản sao lưu",
|
||||
"description": "Những dữ liệu được tạo ra tính từ thời điểm này và lần sao lưu cuối cùng sẽ bị mất vĩnh viễn. Bạn nên tạo một bản sao lưu của những dữ liệu hiện tại trước khi thực hiện việc nhập vào.",
|
||||
"title": "Nhập bản sao lưu vào"
|
||||
"title": "Nhập bản sao lưu vào",
|
||||
"remotePath": "Đường dẫn bản sao lưu"
|
||||
},
|
||||
"repairDialog": {
|
||||
"retryAction": "Thử lại {{ task }}",
|
||||
@@ -1712,7 +1738,30 @@
|
||||
"eventlogTabTitle": "Log sự kiện",
|
||||
"sftpInfoAction": "Quyền truy cập SFPT",
|
||||
"cronTabTitle": "Tác vụ lặp lai cron",
|
||||
"forumUrlAction": "Cần trợ giúp? Hãy hỏi thử trên diễn đàn nhé"
|
||||
"forumUrlAction": "Cần trợ giúp? Hãy hỏi thử trên diễn đàn nhé",
|
||||
"servicesTabTitle": "Dịch vụ",
|
||||
"turn": {
|
||||
"title": "Cài đặt TURN",
|
||||
"enable": "Thiết lập app để sử dụng máy chủ TURN được cài sẵn",
|
||||
"disable": "Không thiết lập TURN cho app này. Các cài đặt TURN cho app được giữ nguyên. Bạn có thể tuỳ chỉnh thêm trong app."
|
||||
},
|
||||
"redis": {
|
||||
"title": "Thiết lập Redis",
|
||||
"enable": "Thiết lập app sử dụng Redis"
|
||||
},
|
||||
"addApplinkDialog": {
|
||||
"title": "Thêm link app bên ngoài"
|
||||
},
|
||||
"editApplinkDialog": {
|
||||
"deleteAction": "Xoá",
|
||||
"title": "Chỉnh sửa link app"
|
||||
},
|
||||
"applinks": {
|
||||
"clearIconDescription": "Hệ thống sẽ lấy favicon của app sau khi bạn bấm lưu.",
|
||||
"upstreamUri": "Đường dẫn bên ngoài",
|
||||
"label": "Nhãn",
|
||||
"clearIconAction": "Xoá biểu tượng"
|
||||
}
|
||||
},
|
||||
"volumes": {
|
||||
"name": "Tên volume",
|
||||
@@ -1739,7 +1788,7 @@
|
||||
},
|
||||
"removeVolumeActionTooltip": "Xoá volume",
|
||||
"openFileManagerActionTooltip": "Mở Quản lý tập tin",
|
||||
"hostPath": "Đường dẫn mount",
|
||||
"hostPath": "Điểm đến",
|
||||
"addVolumeAction": "Thêm volume",
|
||||
"updateVolumeDialog": {
|
||||
"title": "Cập nhật Volume {{ volume }}"
|
||||
@@ -1771,7 +1820,9 @@
|
||||
"de": "Tiếng Đức",
|
||||
"en": "Tiếng Anh",
|
||||
"es": "Tiếng Tây Ban Nha",
|
||||
"ru": "Tiếng Nga"
|
||||
"ru": "Tiếng Nga",
|
||||
"da": "Tiếng Đan Mạch",
|
||||
"pt": "Tiếng Bồ Đào Nha"
|
||||
},
|
||||
"passwordResetEmail": {
|
||||
"subject": "[<%= cloudron %>] Đặt lại mật khẩu",
|
||||
@@ -1818,5 +1869,43 @@
|
||||
"mounts": {
|
||||
"description": "Các app có thể truy cập vào <a href=\"/#/volumes\">những volume</a> được mount lên thông qua thư mục <code>/media/{volume name}</code>. Dữ liệu này không được bao gồm trong phần bản sao lưu của app."
|
||||
}
|
||||
}
|
||||
},
|
||||
"oidc": {
|
||||
"newClientDialog": {
|
||||
"title": "Thêm client",
|
||||
"description": "Thêm cài đặt client kết nối OpenID mới.",
|
||||
"createAction": "Tạo"
|
||||
},
|
||||
"client": {
|
||||
"loginRedirectUri": "Đường dẫn callback khi đăng nhập (viết cách ra bởi dấu phẩy nếu có nhiều hơn một)",
|
||||
"name": "Tên",
|
||||
"id": "ID client",
|
||||
"secret": "Mật khẩu client",
|
||||
"signingAlgorithm": "Thuật toán ký mã hoá",
|
||||
"logoutRedirectUri": "Đường dẫn callback khi đăng nhập (không bắt buộc)"
|
||||
},
|
||||
"description": "Cloudron có thể làm nhà cung cấp kết nối OpenID cho các app trong và ngoài hệ thống.",
|
||||
"clients": {
|
||||
"title": "Client",
|
||||
"newClient": "Thêm client mới",
|
||||
"empty": "Chưa có client"
|
||||
},
|
||||
"title": "Nhà cung cấp kết nối OpenID",
|
||||
"editClientDialog": {
|
||||
"title": "Chỉnh sửa client {{ client }}"
|
||||
},
|
||||
"deleteClientDialog": {
|
||||
"title": "Chắc chắn muốn xoá client {{ client }}?",
|
||||
"description": "Thao tác này sẽ ngắt kết nối tất cả app OpenID bên ngoài có trong Cloudron sử dụng ID client này."
|
||||
},
|
||||
"env": {
|
||||
"discoveryUrl": "Đường dẫn Tìm kiếm",
|
||||
"logoutUrl": "Đường dẫn đăng xuất",
|
||||
"profileEndpoint": "Điểm cuối hồ sơ",
|
||||
"keysEndpoint": "Điểm cuối mật mã",
|
||||
"authEndpoint": "Điểm cuối Auth",
|
||||
"tokenEndpoint": "Điểm cuối token"
|
||||
}
|
||||
},
|
||||
"automation": "Tự động hoá"
|
||||
}
|
||||
|
||||
@@ -405,7 +405,6 @@
|
||||
"empty": "没有用户",
|
||||
"resetPasswordTooltip": "重设密码",
|
||||
"transferOwnershipTooltip": "转让所有权",
|
||||
"makeLocalTooltip": "设为本地用户",
|
||||
"invitationTooltip": "邀请用户",
|
||||
"setGhostTooltip": "模拟该用户",
|
||||
"mailmanagerTooltip": "该用户可以管理用户和邮箱",
|
||||
@@ -429,7 +428,6 @@
|
||||
},
|
||||
"externalLdap": {
|
||||
"title": "连接外部用户目录",
|
||||
"subscriptionRequired": "这个功能仅在付费订阅后可用。",
|
||||
"subscriptionRequiredAction": "现在就设置订阅",
|
||||
"noopInfo": "LDAP 认证未配置。",
|
||||
"provider": "Provider",
|
||||
@@ -549,12 +547,6 @@
|
||||
"setPassword": "设置密码",
|
||||
"generatePassword": "生成密码"
|
||||
},
|
||||
"makeLocalDialog": {
|
||||
"title": "将该用户改为本地用户",
|
||||
"warning": "会为该用户触发一次密码重置来设置本地密码。",
|
||||
"description": "该操作将会将用户从外部用户目录迁移到 Cloudron。",
|
||||
"submitAction": "设为本地用户"
|
||||
},
|
||||
"exposedLdap": {
|
||||
"secret": {
|
||||
"label": "密钥",
|
||||
|
||||
@@ -12,12 +12,41 @@
|
||||
<div ng-bind-html="app.manifest.postInstallMessage | markdown2html"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="form-group pull-left" ng-show="postInstallMessage.openApp">
|
||||
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="postInstallMessage.confirmed">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
|
||||
<a class="btn btn-success" ng-href="{{ postInstallMessage.confirmed ? ('https://' + app.fqdn) : '' }}" target="_blank" ng-disabled="!postInstallMessage.confirmed" ng-click="postInstallMessage.submit()" ng-show="postInstallMessage.openApp">{{ 'app.appInfo.openAction' | tr:{ app: app.manifest.title } }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal postinstall confirm -->
|
||||
<div class="modal fade" id="appPostInstallConfirmModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<img ng-src="{{appPostInstallConfirm.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-info-icon"/>
|
||||
<h5 class="app-info-title">
|
||||
{{ appPostInstallConfirm.app.manifest.title }}
|
||||
<span class="app-info-meta text-small">{{ 'app.appInfo.package' | tr }} <a ng-href="/#/appstore/{{appPostInstallConfirm.app.manifest.id}}?version={{appPostInstallConfirm.app.manifest.version}}">v{{ appPostInstallConfirm.app.manifest.version }}</a> </span>
|
||||
<br/>
|
||||
<span ng-show="appPostInstallConfirm.app.manifest.documentationUrl"><a target="_blank" ng-href="{{appPostInstallConfirm.app.manifest.documentationUrl}}">{{ 'app.docsAction' | tr }}</a> </span>
|
||||
<br/>
|
||||
</h5>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p ng-show="appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.ssoEmail' | tr }}</p>
|
||||
<p ng-show="appPostInstallConfirm.app.sso && !appPostInstallConfirm.app.manifest.addons.email">{{ 'app.appInfo.sso' | tr }}</p>
|
||||
|
||||
<div ng-bind-html="appPostInstallConfirm.app.manifest.postInstallMessage | markdown2html"></div>
|
||||
<div ng-show="appPostInstallConfirm.app.manifest.documentationUrl" ng-bind-html="'app.appInfo.appDocsUrl' | tr:{ docsUrl: appPostInstallConfirm.app.manifest.documentationUrl, title: appPostInstallConfirm.app.manifest.title, forumUrl: (appPostInstallConfirm.app.manifest.forumUrl || 'https://forum.cloudron.io') }"></div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="form-group pull-left">
|
||||
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
|
||||
<label class="control-label" for="appPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
|
||||
<a class="btn btn-success" ng-href="{{ postInstallMessage.confirmed ? ('https://' + app.fqdn) : '' }}" target="_blank" ng-disabled="!postInstallMessage.confirmed" ng-click="postInstallMessage.submit()" ng-show="postInstallMessage.openApp">{{ 'app.appInfo.openAction' | tr:{ app: app.manifest.title } }}</a>
|
||||
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -793,12 +822,13 @@
|
||||
<div ng-repeat="(env, info) in location.portBindingsInfo">
|
||||
<ng-form name="portInfo_form">
|
||||
<div class="form-group" ng-class="{ 'has-error': (!portInfo_form.itemName{{$index}}.$dirty && location.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
|
||||
<label class="control-label" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portBindingsEnabled[env]">
|
||||
<label class="control-label" style="width: 100%" for="locationPortInput{{env}}"><input type="checkbox" ng-model="location.portBindingsEnabled[env]">
|
||||
{{ info.title }}
|
||||
<sup>
|
||||
<a popover-placement="top-right" popover-trigger="outsideClick" uib-popover="{{info.description}} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})"><i class="fa fa-question-circle"></i></a>
|
||||
</sup>
|
||||
<small style="padding-left: 5px;" ng-show="info.readOnly">{{ 'appstore.installDialog.portReadOnly' | tr }}</small>
|
||||
<span ng-show="info.portCount" style="display: block; float: right">({{ info.portCount }} ports) {{ location.portBindings[env] }} to {{ location.portBindings[env] + info.portCount - 1 }}</span>
|
||||
</label>
|
||||
<input type="number" class="form-control" ng-model="location.portBindings[env]" ng-disabled="!location.portBindingsEnabled[env]" ng-readonly="info.readOnly" id="locationPortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
|
||||
<p class="text-small text-warning text-bold" ng-show="location.domain.provider === 'cloudflare'">{{ 'appstore.installDialog.cloudflarePortWarning' | tr }} </p>
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
/* global Clipboard */
|
||||
/* global SECRET_PLACEHOLDER */
|
||||
/* global APP_TYPES, STORAGE_PROVIDERS, BACKUP_FORMATS */
|
||||
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR, REGIONS_CONTABO */
|
||||
/* global REGIONS_S3, REGIONS_WASABI, REGIONS_DIGITALOCEAN, REGIONS_EXOSCALE, REGIONS_SCALEWAY, REGIONS_LINODE, REGIONS_OVH, REGIONS_IONOS, REGIONS_UPCLOUD, REGIONS_VULTR */
|
||||
/* global onAppClick */
|
||||
|
||||
angular.module('Application').controller('AppController', ['$scope', '$location', '$translate', '$timeout', '$interval', '$route', '$routeParams', 'Client', function ($scope, $location, $translate, $timeout, $interval, $route, $routeParams, Client) {
|
||||
$scope.s3Regions = REGIONS_S3;
|
||||
@@ -76,6 +77,31 @@ angular.module('Application').controller('AppController', ['$scope', '$location'
|
||||
});
|
||||
};
|
||||
|
||||
$scope.appPostInstallConfirm = {
|
||||
app: {},
|
||||
message: '',
|
||||
confirmed: false,
|
||||
|
||||
show: function (app) {
|
||||
$scope.appPostInstallConfirm.app = app;
|
||||
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
|
||||
$scope.appPostInstallConfirm.confirmed = false;
|
||||
|
||||
$('#appPostInstallConfirmModal').modal('show');
|
||||
|
||||
return false; // prevent propagation and default
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
if (!$scope.appPostInstallConfirm.confirmed) return;
|
||||
|
||||
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
|
||||
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
|
||||
|
||||
$('#appPostInstallConfirmModal').modal('hide');
|
||||
}
|
||||
};
|
||||
|
||||
$scope.postInstallMessage = {
|
||||
confirmed: false,
|
||||
openApp: false,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!-- Modal postinstall confirm -->
|
||||
<div class="modal fade" id="appPostInstallConfirmModal" tabindex="-1" role="dialog">
|
||||
<div class="modal fade" id="appsPostInstallConfirmModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
@@ -21,8 +21,8 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="form-group pull-left">
|
||||
<input type="checkbox" id="appPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
|
||||
<label class="control-label" for="appPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
|
||||
<input type="checkbox" id="appsPostInstallConfirmCheckbox" ng-model="appPostInstallConfirm.confirmed">
|
||||
<label class="control-label" for="appsPostInstallConfirmCheckbox">{{ 'app.appInfo.postInstallConfirmCheckbox' | tr }}</label>
|
||||
</div>
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.close' | tr }}</button>
|
||||
<a class="btn btn-success" ng-href="{{ appPostInstallConfirm.confirmed ? ('https://' + appPostInstallConfirm.app.fqdn) : '' }}" target="_blank" ng-disabled="!appPostInstallConfirm.confirmed" ng-click="appPostInstallConfirm.submit()">{{ 'app.appInfo.openAction' | tr:{ app: appPostInstallConfirm.app.manifest.title } }}</a>
|
||||
@@ -155,7 +155,7 @@
|
||||
|
||||
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
|
||||
<div class="app-grid">
|
||||
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:'fqdn'">
|
||||
<div class="grid-item" ng-class="{ 'stopped': app.runState === 'stopped' }" ng-repeat="app in installedApps | selectedGroupAccessFilter:selectedGroup | selectedStateFilter:selectedState | selectedTagFilter:selectedTags | selectedDomainFilter:selectedDomain | appSearchFilter:appSearch | orderBy:labelOrFQDN">
|
||||
<div class="grid-item-content" uib-tooltip="{{ app.fqdn }}">
|
||||
<a ng-show="app.type !== APP_TYPES.LINK && isOperator(app)" ng-href="#/app/{{ app.id}}/display" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></a>
|
||||
<div ng-show="app.type === APP_TYPES.LINK && isOperator(app)" ng-click="applinksEdit.show(app)" class="btn btn-lg btn-default grid-item-action"><i class="fas fa-cog"></i></div>
|
||||
|
||||
@@ -45,6 +45,11 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
|
||||
if (tr['app.states.updateAvailable']) $scope.states[3].label = tr['app.states.updateAvailable'];
|
||||
});
|
||||
|
||||
// for sorting of the app grid items
|
||||
$scope.labelOrFQDN = function (item) {
|
||||
return item.label || item.fqdn;
|
||||
};
|
||||
|
||||
$scope.$watch('selectedTags', function (newVal, oldVal) {
|
||||
if (newVal === oldVal) return;
|
||||
|
||||
@@ -91,7 +96,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
|
||||
$scope.appPostInstallConfirm.message = app.manifest.postInstallMessage;
|
||||
$scope.appPostInstallConfirm.confirmed = false;
|
||||
|
||||
$('#appPostInstallConfirmModal').modal('show');
|
||||
$('#appsPostInstallConfirmModal').modal('show');
|
||||
|
||||
return false; // prevent propagation and default
|
||||
},
|
||||
@@ -102,7 +107,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$translat
|
||||
$scope.appPostInstallConfirm.app.pendingPostInstallConfirmation = false;
|
||||
delete localStorage['confirmPostInstall_' + $scope.appPostInstallConfirm.app.id];
|
||||
|
||||
$('#appPostInstallConfirmModal').modal('hide');
|
||||
$('#appsPostInstallConfirmModal').modal('hide');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -391,10 +391,10 @@
|
||||
<div uib-collapse="!configureBackup.advancedVisible">
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'800 MB' }}</b></label>
|
||||
<label class="control-label">{{ 'backups.configureBackupStorage.memoryLimit' | tr }}: <b>{{ configureBackup.memoryLimit | prettyBinarySize:'1024 MB' }}</b></label>
|
||||
<p class="small">{{ 'backups.configureBackupStorage.memoryLimitDescription' | tr }}</p>
|
||||
<div style="padding: 0 10px;">
|
||||
<slider id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" step="134217728" tooltip="hide" ticks="configureBackup.memoryTicks" ticks-snap-bounds="67108864"></slider>
|
||||
<slider id="sliderConfigureBackupMemoryLimit" ng-model="configureBackup.memoryLimit" tooltip="hide" step="268435456" ticks="configureBackup.memoryTicks"></slider>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -7,7 +7,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.MIN_MEMORY_LIMIT = 1024 * 1024 * 1024; // 1 GB
|
||||
|
||||
$scope.config = Client.getConfig();
|
||||
$scope.user = Client.getUserInfo();
|
||||
@@ -541,7 +541,7 @@ angular.module('Application').controller('BackupsController', ['$scope', '$locat
|
||||
$scope.configureBackup.chown = $scope.backupConfig.chown;
|
||||
|
||||
var limits = $scope.backupConfig.limits || {};
|
||||
$scope.configureBackup.memoryLimit = limits.memoryLimit;
|
||||
$scope.configureBackup.memoryLimit = Math.max(limits.memoryLimit, $scope.MIN_MEMORY_LIMIT);
|
||||
|
||||
$scope.configureBackup.uploadPartSize = limits.uploadPartSize || ($scope.configureBackup.provider === 'scaleway-objectstorage' ? 100 * 1024 * 1024 : 10 * 1024 * 1024);
|
||||
$scope.configureBackup.downloadConcurrency = limits.downloadConcurrency || ($scope.backupConfig.provider === 's3' ? 30 : 10);
|
||||
|
||||
@@ -156,8 +156,8 @@
|
||||
{{ 'emails.title' | tr }}
|
||||
|
||||
<div class="pull-right">
|
||||
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
|
||||
<a class="btn btn-default" ng-show="user.isAtLeastOwner" href="#/emails-eventlog">{{ 'eventlog.title' | tr }}</a>
|
||||
<a class="btn btn-default" ng-show="user.isAtLeastAdmin" href="#/emails-queue">{{ 'emails.action.queue' | tr }}</a>
|
||||
<a class="btn btn-default" ng-show="user.isAtLeastAdmin" href="#/emails-eventlog">{{ 'eventlog.title' | tr }}</a>
|
||||
</div>
|
||||
</h1>
|
||||
</div>
|
||||
@@ -195,10 +195,20 @@
|
||||
</td>
|
||||
<td class="elide-table-cell no-padding">
|
||||
<a href="/#/email/{{ domain.domain }}" class="email-domain-list-item">
|
||||
<span ng-show="domain.inbound && domain.outbound && domain.usage === null">{{ 'main.loadingPlaceholder' | tr }} ...</span>
|
||||
<span ng-show="domain.inbound && domain.outbound && domain.usage !== null">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount, usage: (domain.usage | prettyDecimalSize) } }}</span>
|
||||
<span ng-show="!domain.inbound && domain.outbound">{{ 'emails.domains.outbound' | tr }}</span>
|
||||
<span ng-show="!domain.inbound && !domain.outbound">{{ 'emails.domains.disabled' | tr }}</span>
|
||||
<span ng-switch on="domain.loading">
|
||||
<span ng-switch-when="true">{{ 'main.loadingPlaceholder' | tr }} ...</span>
|
||||
<span ng-switch-default>
|
||||
<span ng-switch on="domain.inbound">
|
||||
<span ng-switch-when="true">
|
||||
<span ng-show="domain.loadingUsage">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount } }} {{ 'main.loadingPlaceholder' | tr }} ... </span>
|
||||
<span ng-show="!domain.loadingUsage">{{ 'emails.domains.stats' | tr:{ mailboxCount: domain.mailboxCount, usage: (domain.usage | prettyDecimalSize) } }}</span>
|
||||
</span>
|
||||
<span ng-switch-default>
|
||||
<span ng-show="domain.outbound">{{ 'emails.domains.outbound' | tr }}</span>
|
||||
<span ng-show="!domain.outbound">{{ 'emails.domains.disabled' | tr }}</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</a>
|
||||
</td>
|
||||
<td class="text-right no-wrap">
|
||||
@@ -213,11 +223,11 @@
|
||||
</div>
|
||||
|
||||
<!-- mailbox sharing -->
|
||||
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
|
||||
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
|
||||
<h3>{{ 'emails.mailboxSharing.title' | tr }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
|
||||
<div class="card" ng-show="user.isAtLeastAdmin" style="margin-bottom: 15px;">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p>{{ 'emails.mailboxSharing.description' | tr }}</p>
|
||||
@@ -234,7 +244,7 @@
|
||||
</div>
|
||||
|
||||
<!-- server location -->
|
||||
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
|
||||
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
|
||||
<h3>
|
||||
{{ 'emails.settings.location' | tr }}
|
||||
<div class="btn-group btn-group-sm pull-right">
|
||||
@@ -302,11 +312,11 @@
|
||||
</div>
|
||||
|
||||
<!-- settings -->
|
||||
<div class="text-left section-header" ng-show="user.isAtLeastOwner">
|
||||
<div class="text-left section-header" ng-show="user.isAtLeastAdmin">
|
||||
<h3>{{ 'emails.settings.title' | tr }}</h3>
|
||||
</div>
|
||||
|
||||
<div class="card" ng-show="user.isAtLeastOwner" style="margin-bottom: 15px;">
|
||||
<div class="card" ng-show="user.isAtLeastAdmin" style="margin-bottom: 15px;">
|
||||
<p ng-bind-html=" 'emails.settings.info' | tr "></p>
|
||||
<div class="row">
|
||||
<div class="col-xs-6">
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use strict';
|
||||
|
||||
/* global $, angular, TASK_TYPES */
|
||||
/* global async */
|
||||
|
||||
angular.module('Application').controller('EmailsController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
|
||||
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastMailManager) $location.path('/'); });
|
||||
@@ -404,44 +405,83 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
|
||||
}
|
||||
};
|
||||
|
||||
function refreshDomainStatuses() {
|
||||
$scope.domains.forEach(function (domain) {
|
||||
domain.usage = null; // used by ui to show 'loading'
|
||||
function refreshMailStatus(domain, done) {
|
||||
Client.getMailStatusForDomain(domain.domain, function (error, result) {
|
||||
if (error) {
|
||||
console.error('Failed to fetch mail status for domain', domain.domain, error);
|
||||
return done();
|
||||
}
|
||||
|
||||
Client.getMailStatusForDomain(domain.domain, function (error, result) {
|
||||
if (error) return console.error('Failed to fetch mail status for domain', domain.domain, error);
|
||||
domain.status = result;
|
||||
|
||||
domain.status = result;
|
||||
domain.statusOk = Object.keys(result).every(function (k) {
|
||||
if (k === 'dns') return Object.keys(result.dns).every(function (k) { return result.dns[k].status; });
|
||||
|
||||
domain.statusOk = Object.keys(result).every(function (k) {
|
||||
if (k === 'dns') return Object.keys(result.dns).every(function (k) { return result.dns[k].status; });
|
||||
if (!('status' in result[k])) return true; // if status is not present, the test was not run
|
||||
|
||||
if (!('status' in result[k])) return true; // if status is not present, the test was not run
|
||||
|
||||
return result[k].status;
|
||||
});
|
||||
return result[k].status;
|
||||
});
|
||||
|
||||
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
|
||||
if (error) return console.error('Failed to fetch mail config for domain', domain.domain, error);
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
domain.inbound = mailConfig.enabled;
|
||||
domain.outbound = mailConfig.relay.provider !== 'noop';
|
||||
function refreshMailConfig(domain, done) {
|
||||
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
|
||||
if (error) {
|
||||
console.error('Failed to fetch mail config for domain', domain.domain, error);
|
||||
return done();
|
||||
}
|
||||
|
||||
// do this even if no outbound since people forget to remove mailboxes
|
||||
Client.getMailboxCount(domain.domain, function (error, count) {
|
||||
if (error) return console.error('Failed to fetch mailboxes for domain', domain.domain, error);
|
||||
domain.inbound = mailConfig.enabled;
|
||||
domain.outbound = mailConfig.relay.provider !== 'noop';
|
||||
|
||||
domain.mailboxCount = count;
|
||||
// do this even if no outbound since people forget to remove mailboxes
|
||||
Client.getMailboxCount(domain.domain, function (error, count) {
|
||||
if (error) {
|
||||
console.error('Failed to fetch mailboxes for domain', domain.domain, error);
|
||||
return done();
|
||||
}
|
||||
|
||||
Client.getMailUsage(domain.domain, function (error, usage) {
|
||||
if (error) return console.error('Failed to fetch usage for domain', domain.domain, error);
|
||||
domain.mailboxCount = count;
|
||||
|
||||
domain.usage = 0;
|
||||
// we used to use quotaValue here but it's quite different wrt diskSize. so choose diskSize consistently
|
||||
// also, quotaValue can be missing for deleted mailboxes that are on disk but removed from dovecot/ldap itself
|
||||
Object.keys(usage).forEach(function (m) { domain.usage += usage[m].diskSize; });
|
||||
});
|
||||
done();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function refreshMailUsage(domain, done) {
|
||||
Client.getMailUsage(domain.domain, function (error, usage) {
|
||||
if (error) {
|
||||
console.error('Failed to fetch usage for domain', domain.domain, error);
|
||||
return done();
|
||||
}
|
||||
|
||||
domain.usage = 0;
|
||||
// we used to use quotaValue here but it's quite different wrt diskSize. so choose diskSize consistently
|
||||
// also, quotaValue can be missing for deleted mailboxes that are on disk but removed from dovecot/ldap itself
|
||||
Object.keys(usage).forEach(function (m) { domain.usage += usage[m].diskSize; });
|
||||
|
||||
done();
|
||||
});
|
||||
}
|
||||
|
||||
function refreshDomainStatuses() {
|
||||
async.each($scope.domains, function (domain, iteratorDone) {
|
||||
async.series([
|
||||
refreshMailStatus.bind(null, domain),
|
||||
refreshMailConfig.bind(null, domain),
|
||||
], function () {
|
||||
domain.loading = false;
|
||||
iteratorDone();
|
||||
});
|
||||
}, function () {
|
||||
// mail usage is loaded separately with a cancellation check. when there are a lot of domains, it runs a long time in background and slows down loading of new views
|
||||
async.eachLimit($scope.domains, 5, function (domain, itemDone) {
|
||||
if ($scope.$$destroyed) return itemDone(); // abort!
|
||||
refreshMailUsage(domain, function () {
|
||||
domain.loadingUsage = false;
|
||||
itemDone();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -451,10 +491,12 @@ angular.module('Application').controller('EmailsController', ['$scope', '$locati
|
||||
Client.getDomains(function (error, domains) {
|
||||
if (error) return console.error('Unable to get domain listing.', error);
|
||||
|
||||
domains.forEach(function (domain) { domain.loading = true; domain.loadingUsage = true; }); // used by ui to show 'loading'
|
||||
$scope.domains = domains;
|
||||
|
||||
$scope.ready = true;
|
||||
|
||||
if ($scope.user.isAtLeastOwner) {
|
||||
if ($scope.user.isAtLeastAdmin) {
|
||||
$scope.mailLocation.refresh();
|
||||
$scope.maxEmailSize.refresh();
|
||||
$scope.virtualAllMail.refresh();
|
||||
|
||||
@@ -46,10 +46,12 @@ angular.module('Application').controller('EventLogController', ['$scope', '$loca
|
||||
{ name: 'cloudron.update', value: 'cloudron.update' },
|
||||
{ name: 'cloudron.update.finish', value: 'cloudron.update.finish' },
|
||||
{ name: 'dashboard.domain.update', value: 'dashboard.domain.update' },
|
||||
{ name: 'directoryserver.configure', value: 'directoryserver.configure' },
|
||||
{ name: 'dyndns.update', value: 'dyndns.update' },
|
||||
{ name: 'domain.add', value: 'domain.add' },
|
||||
{ name: 'domain.update', value: 'domain.update' },
|
||||
{ name: 'domain.remove', value: 'domain.remove' },
|
||||
{ name: 'externalldap.configure', value: 'externalldap.configure' },
|
||||
{ name: 'mail.location', value: 'mail.location' },
|
||||
{ name: 'mail.enabled', value: 'mail.enabled' },
|
||||
{ name: 'mail.box.add', value: 'mail.box.add' },
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificadtionTypeToColor) }">
|
||||
<div class="card notification-item" ng-repeat="notification in notifications" ng-class="{'notification-unread': !notification.acknowledged }" ng-click="notification.isCollapsed = !notification.isCollapsed" ng-style="{ borderLeftColor: (notification | notificationTypeToColor) }">
|
||||
<div class="row">
|
||||
<div class="col-xs-12" ng-class="{ 'notification-details': notification.detailsShown }">
|
||||
<span class="notification-title">{{ notification.title }}</span> <small class="text-muted" uib-tooltip="{{ notification.creationTime | prettyLongDate }}">{{ notification.creationTime | prettyDate }}</small>
|
||||
|
||||
@@ -115,13 +115,21 @@
|
||||
<h4 class="modal-title">{{ 'profile.changeEmail.title' | tr }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form name="emailChangeForm" role="form" novalidate ng-submit="emailchange.submit()" autocomplete="off">
|
||||
<div class="form-group" ng-class="{ 'has-error': (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) || (!emailChangeForm.email.$dirty && emailchange.error.email)}">
|
||||
<input type="email" class="form-control" ng-model="emailchange.email" id="inputEmailChangeEmail" name="email" required autofocus>
|
||||
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailchange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
|
||||
<form name="emailChangeForm" role="form" novalidate ng-submit="emailChange.submit()" autocomplete="off">
|
||||
<div class="form-group" ng-class="{ 'has-error': (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) || (!emailChangeForm.email.$dirty && emailChange.error.email)}">
|
||||
<label class="control-label" for="inputEmailChangeEmail">{{ 'profile.changeEmail.email' | tr }}</label>
|
||||
<input type="email" class="form-control" ng-model="emailChange.email" id="inputEmailChangeEmail" name="email" required autofocus>
|
||||
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailChange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
|
||||
<small ng-show="emailChangeForm.email.$error.required">{{ 'profile.changeEmail.errorEmailRequired' | tr }}</small>
|
||||
<small ng-show="(emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) && !emailChangeForm.email.$error.required">{{ 'profile.changeEmail.errorEmailInvalid' | tr }}</small>
|
||||
<small ng-show="!emailChangeForm.email.$dirty && emailchange.error.email">{{ emailchange.error.email }}</small>
|
||||
<small ng-show="!emailChangeForm.email.$dirty && emailChange.error.email">{{ emailChange.error.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-class="{ 'has-error': (emailChange.error.password && !emailChangeForm.password.$dirty) }">
|
||||
<label class="control-label" for="inputEmailChangePassword">{{ 'profile.changeEmail.password' | tr }}</label>
|
||||
<input type="password" class="form-control" ng-model="emailChange.password" id="inputEmailChangePassword" name="password" required autofocus password-reveal>
|
||||
<div class="control-label" ng-show="emailChange.error.password && !emailChangeForm.password.$dirty">
|
||||
<small ng-show="emailChange.error.password">{{ 'profile.changeEmail.errorWrongPassword' | tr }}</small>
|
||||
</div>
|
||||
</div>
|
||||
<input class="ng-hide" type="submit" ng-disabled="emailChangeForm.$invalid"/>
|
||||
@@ -129,7 +137,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="emailchange.submit()" ng-disabled="emailChangeForm.$invalid || emailchange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="emailchange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="emailChange.submit()" ng-disabled="emailChangeForm.$invalid || emailChange.busy"><i class="fa fa-circle-notch fa-spin" ng-show="emailChange.busy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -408,7 +416,7 @@
|
||||
<tr>
|
||||
<td class="text-muted" style="vertical-align: top;">{{ 'profile.primaryEmail' | tr }}</td>
|
||||
<td class="text-right" style="vertical-align: top; white-space: nowrap;">
|
||||
{{ user.email }} <a href="" ng-click="emailchange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
|
||||
{{ user.email }} <a href="" ng-click="emailChange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
@@ -417,7 +425,7 @@
|
||||
{{ user.fallbackEmail }} <a href="" ng-click="fallbackEmailChange.show()" ng-hide="user.source || config.profileLocked"><i class="fa fa-edit text-small"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<tr ng-hide="user.source">
|
||||
<td colspan="2" class="text-right">
|
||||
<a href="" ng-click="sendPasswordReset()">{{ 'profile.passwordResetAction' | tr }}</a>
|
||||
</td>
|
||||
@@ -437,7 +445,7 @@
|
||||
<br/>
|
||||
<button class="btn btn-default" ng-click="backgroundImageChange.show()">Set Background Image</button>
|
||||
<button class="btn btn-primary pull-right" ng-click="passwordchange.show()" ng-hide="user.source">{{ 'profile.changePasswordAction' | tr }}</button>
|
||||
<button class="btn pull-right" uib-tooltip="{{ user.source ? ('profile.enable2FANotAvailable' | tr) : '' }}" ng-disabled="user.source" ng-class="user.twoFactorAuthenticationEnabled ? 'btn-danger' : 'btn-success'" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction' | tr }}</button> </div>
|
||||
<button class="btn pull-right" uib-tooltip="{{ (user.source && config.external2FA) ? ('profile.enable2FANotAvailable' | tr) : '' }}" ng-disabled="user.source && config.external2FA" ng-class="user.twoFactorAuthenticationEnabled ? 'btn-danger' : 'btn-success'" ng-click="twoFactorAuthentication.show()">{{ user.twoFactorAuthenticationEnabled ? 'profile.disable2FAAction' : 'profile.enable2FAAction' | tr }}</button> </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -15,7 +15,12 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
|
||||
$scope.$watch('language', function (newVal, oldVal) {
|
||||
if (newVal === oldVal) return;
|
||||
$translate.use(newVal.id);
|
||||
|
||||
Client.setProfileLanguage(newVal.id, function (error) {
|
||||
if (error) return console.error('Failed to reset password:', error);
|
||||
});
|
||||
|
||||
$translate.use(newVal.id); // this switches the language and saves locally in localStorage['NG_TRANSLATE_LANG_KEY']
|
||||
});
|
||||
|
||||
$scope.sendPasswordReset = function () {
|
||||
@@ -101,7 +106,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
return;
|
||||
}
|
||||
|
||||
Client.refreshUserInfo();
|
||||
Client.refreshProfile();
|
||||
|
||||
$('#twoFactorAuthenticationEnableModal').modal('hide');
|
||||
});
|
||||
@@ -123,7 +128,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
return;
|
||||
}
|
||||
|
||||
Client.refreshUserInfo();
|
||||
Client.refreshProfile();
|
||||
|
||||
$('#twoFactorAuthenticationDisableModal').modal('hide');
|
||||
});
|
||||
@@ -180,7 +185,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
function done(error) {
|
||||
if (error) return console.error('Unable to change avatar.', error);
|
||||
|
||||
Client.refreshUserInfo(function (error) {
|
||||
Client.refreshProfile(function (error) {
|
||||
if (error) return console.error(error);
|
||||
|
||||
$('#avatarChangeModal').modal('hide');
|
||||
@@ -207,15 +212,10 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
avatarChangeReset: function () {
|
||||
$scope.avatarChange.error.avatar = null;
|
||||
|
||||
if ($scope.user.avatarUrl.indexOf('/api/v1/profile/avatar') !== -1) {
|
||||
$scope.avatarChange.type = 'custom';
|
||||
} else if ($scope.user.avatarUrl.indexOf('https://www.gravatar.com') === 0) {
|
||||
$scope.avatarChange.type = 'gravatar';
|
||||
} else {
|
||||
$scope.avatarChange.type = '';
|
||||
}
|
||||
|
||||
console.log($scope.user)
|
||||
$scope.avatarChange.type = $scope.user.avatarType;
|
||||
$scope.avatarChange.typeOrig = $scope.avatarChange.type;
|
||||
|
||||
document.getElementById('previewAvatar').src = $scope.avatarChange.type === 'custom' ? $scope.user.avatarUrl : '';
|
||||
$scope.avatarChange.pictureChanged = false;
|
||||
$scope.avatarChange.avatar = null;
|
||||
@@ -354,42 +354,44 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
}
|
||||
};
|
||||
|
||||
$scope.emailchange = {
|
||||
$scope.emailChange = {
|
||||
busy: false,
|
||||
error: {},
|
||||
email: '',
|
||||
password: '',
|
||||
|
||||
reset: function () {
|
||||
$scope.emailchange.busy = false;
|
||||
$scope.emailchange.error.email = null;
|
||||
$scope.emailchange.email = '';
|
||||
$scope.emailChange.busy = false;
|
||||
$scope.emailChange.error = {};
|
||||
$scope.emailChange.email = '';
|
||||
$scope.emailChange.password = '';
|
||||
|
||||
$scope.emailChangeForm.$setUntouched();
|
||||
$scope.emailChangeForm.$setPristine();
|
||||
},
|
||||
|
||||
show: function () {
|
||||
$scope.emailchange.reset();
|
||||
$scope.emailChange.reset();
|
||||
$('#emailChangeModal').modal('show');
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
$scope.emailchange.error.email = null;
|
||||
$scope.emailchange.busy = true;
|
||||
$scope.emailChange.error.email = null;
|
||||
$scope.emailChange.busy = true;
|
||||
|
||||
var data = {
|
||||
email: $scope.emailchange.email
|
||||
};
|
||||
|
||||
Client.updateProfile(data, function (error) {
|
||||
$scope.emailchange.busy = false;
|
||||
Client.setProfileEmail($scope.emailChange.email, $scope.emailChange.password, function (error) {
|
||||
$scope.emailChange.busy = false;
|
||||
|
||||
if (error) {
|
||||
if (error.statusCode === 409) $scope.emailchange.error.email = 'Email already taken';
|
||||
else if (error.statusCode === 400) $scope.emailchange.error.email = error.message;
|
||||
else console.error('Unable to change email.', error);
|
||||
|
||||
$('#inputEmailChangeEmail').focus();
|
||||
if (error.statusCode === 412) {
|
||||
$scope.emailChange.error.password = true;
|
||||
$scope.emailChange.password = '';
|
||||
$scope.emailChangeForm.password.$setPristine();
|
||||
$('#inputFallbackEmailChangePassword').focus();
|
||||
} else {
|
||||
$scope.emailChange.error.email = error.message;
|
||||
$('#inputEmailChangeEmail').focus();
|
||||
}
|
||||
|
||||
$scope.emailChangeForm.$setUntouched();
|
||||
$scope.emailChangeForm.$setPristine();
|
||||
@@ -397,9 +399,9 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
return;
|
||||
}
|
||||
|
||||
Client.refreshUserInfo();
|
||||
Client.refreshProfile();
|
||||
|
||||
$scope.emailchange.reset();
|
||||
$scope.emailChange.reset();
|
||||
$('#emailChangeModal').modal('hide');
|
||||
});
|
||||
}
|
||||
@@ -436,12 +438,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
$scope.fallbackEmailChange.error.generic = null;
|
||||
$scope.fallbackEmailChange.busy = true;
|
||||
|
||||
var data = {
|
||||
fallbackEmail: $scope.fallbackEmailChange.email,
|
||||
password: $scope.fallbackEmailChange.password
|
||||
};
|
||||
|
||||
Client.updateProfile(data, function (error) {
|
||||
Client.setProfileFallbackEmail($scope.fallbackEmailChange.email, $scope.fallbackEmailChange.password, function (error) {
|
||||
$scope.fallbackEmailChange.busy = false;
|
||||
|
||||
if (error) {
|
||||
@@ -460,7 +457,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
}
|
||||
|
||||
// update user info in the background
|
||||
Client.refreshUserInfo();
|
||||
Client.refreshProfile();
|
||||
|
||||
$scope.fallbackEmailChange.reset();
|
||||
$('#fallbackEmailChangeModal').modal('hide');
|
||||
@@ -592,11 +589,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
$scope.displayNameChange.error.displayName = null;
|
||||
$scope.displayNameChange.busy = true;
|
||||
|
||||
var user = {
|
||||
displayName: $scope.displayNameChange.displayName
|
||||
};
|
||||
|
||||
Client.updateProfile(user, function (error) {
|
||||
Client.setProfileDisplayName($scope.displayNameChange.displayName, function (error) {
|
||||
$scope.displayNameChange.busy = false;
|
||||
|
||||
if (error) {
|
||||
@@ -612,7 +605,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
}
|
||||
|
||||
// update user info in the background
|
||||
Client.refreshUserInfo();
|
||||
Client.refreshProfile();
|
||||
|
||||
$scope.displayNameChange.reset();
|
||||
$('#displayNameChangeModal').modal('hide');
|
||||
@@ -714,7 +707,7 @@ angular.module('Application').controller('ProfileController', ['$scope', '$trans
|
||||
Client.onReady(function () {
|
||||
$scope.appPassword.refresh();
|
||||
$scope.tokens.refresh();
|
||||
Client.refreshUserInfo(); // 2fa status might have changed by admin
|
||||
Client.refreshProfile(); // 2fa status might have changed by admin
|
||||
|
||||
$translate.onReady(function () {
|
||||
var usedLang = $translate.use() || $translate.fallbackLanguage();
|
||||
|
||||
@@ -96,8 +96,8 @@ angular.module('Application').controller('SystemController', ['$scope', '$locati
|
||||
|
||||
if (content.type === 'app') {
|
||||
content.app = Client.getInstalledAppsByAppId()[content.id];
|
||||
content.label = content.app.label || content.app.fqdn;
|
||||
if (!content.app) content.uninstalled = true;
|
||||
else content.label = content.app.label || content.app.fqdn;
|
||||
} else if (content.type === 'volume') {
|
||||
content.volume = $scope.volumesById[content.id];
|
||||
content.label = content.volume.name;
|
||||
|
||||
@@ -9,10 +9,14 @@
|
||||
<p class="has-error text-center" ng-show="externalLdap.error.generic">{{ externalLdap.error.generic }}</p>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="ldapProvider">{{ 'users.externalLdap.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#external-directory" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<label class="control-label" for="ldapProvider">{{ 'users.externalLdap.provider' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#external-directory" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
|
||||
<select class="form-control" id="ldapProvider" ng-model="externalLdap.provider" ng-options="a.value as a.name for a in ldapProvider"></select>
|
||||
</div>
|
||||
|
||||
<p class="text-small text-warning" ng-show="externalLdap.provider === 'noop' && externalLdap.currentConfig.provider !== 'noop'">
|
||||
{{ 'users.externalLdap.disableWarning' | tr }}
|
||||
</p>
|
||||
|
||||
<div uib-collapse="externalLdap.provider === 'noop'">
|
||||
<form name="externalLdapConfigForm" role="form" novalidate ng-submit="externalLdap.submit()" autocomplete="off">
|
||||
<fieldset>
|
||||
@@ -93,7 +97,7 @@
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="externalLdap.submit()" ng-disabled="externalLdapConfigForm.$invalid || externalLdap.busy"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.busy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
<button type="button" class="btn btn-primary" ng-click="externalLdap.submit()" ng-disabled="externalLdapConfigForm.$invalid || externalLdap.saveBusy"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.saveBusy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -237,7 +241,7 @@
|
||||
<fieldset ng-disabled="profileConfig.busy">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="profileConfig.editableUserProfiles"> {{ 'users.settings.allowProfileEditCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-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-directory/#lock-profile" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</label>
|
||||
</div>
|
||||
<div class="checkbox">
|
||||
@@ -263,7 +267,21 @@
|
||||
</div>
|
||||
|
||||
<div class="text-left section-header">
|
||||
<h3>{{ 'users.externalLdap.title' | tr }}</h3>
|
||||
<h3>
|
||||
{{ 'users.externalLdap.title' | tr }}
|
||||
<div class="btn-group btn-group-sm pull-right">
|
||||
<button type="button" class="btn btn-small btn-default dropdown-toggle" ng-show="externalLdap.tasks.length" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false" uib-tooltip="{{ 'domains.renewCerts.showLogsAction' | tr }}">
|
||||
<i class="fas fa-align-left"></i> <span class="caret"></span>
|
||||
</button>
|
||||
<ul class="dropdown-menu">
|
||||
<li ng-repeat="task in externalLdap.tasks">
|
||||
<a ng-href="/frontend/logs.html?taskId={{task.id}}" target="_blank" class="text-right">
|
||||
{{ task.ts | prettyLongDate }} <i class="fa" style="margin-left: 20px" ng-class="{ 'status-active fa-check-circle': !task.active && task.success, 'fa-circle-notch fa-spin': task.active, 'status-error fa-times-circle': !task.active && !task.success }"></i>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div class="card card-large">
|
||||
@@ -273,150 +291,138 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row" ng-hide="config.features.externalLdap">
|
||||
<div class="col-md-12">
|
||||
{{ 'users.externalLdap.subscriptionRequired' | tr }} <a href="" class="pull-right" ng-click="openSubscriptionSetup()">{{ 'users.externalLdap.subscriptionRequiredAction' | tr }}</a>
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider === 'noop'">
|
||||
<div class="col-xs-12">
|
||||
<span class="text-muted">{{ 'users.externalLdap.noopInfo' | tr }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div ng-show="config.features.externalLdap">
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.provider' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.provider }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider === 'noop'">
|
||||
<div class="col-xs-12">
|
||||
<span class="text-muted">{{ 'users.externalLdap.noopInfo' | tr }}</span>
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.server' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.acceptSelfSignedCert' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.acceptSelfSignedCerts ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.baseDn' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.baseDn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.filter' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.filter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.usernameField' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.usernameField || 'uid' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.syncGroups' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.syncGroups ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.groupBaseDn' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.groupBaseDn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.groupFilter' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.groupFilter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.groupnameField' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.groupnameField }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.auth' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.bindDn ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.autocreateUsersOnLogin' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.autoCreate ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<br/>
|
||||
<div class="col-md-12" style="margin-bottom: 10px;">
|
||||
<div ng-show="externalLdap.busy" class="progress progress-striped active animateMe">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ externalLdap.percent }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.provider' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.provider }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.server' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.url }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.acceptSelfSignedCert' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.acceptSelfSignedCerts ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.baseDn' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.baseDn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.filter' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.filter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.usernameField' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.usernameField || 'uid' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.syncGroups' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.syncGroups ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.groupBaseDn' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.groupBaseDn }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.groupFilter' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.groupFilter }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron' && externalLdap.currentConfig.syncGroups">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.groupnameField' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.groupnameField }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop' && externalLdap.currentConfig.provider !== 'cloudron'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.auth' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.bindDn ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row" ng-show="externalLdap.currentConfig.provider !== 'noop'">
|
||||
<div class="col-xs-6">
|
||||
<span class="text-muted">{{ 'users.externalLdap.autocreateUsersOnLogin' | tr }}</span>
|
||||
</div>
|
||||
<div class="col-xs-6 text-right">
|
||||
<span>{{ externalLdap.currentConfig.autoCreate ? 'Yes' : 'No' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<br/>
|
||||
<div class="col-md-12" style="margin-bottom: 10px;">
|
||||
<div ng-show="externalLdap.syncBusy" class="progress progress-striped active animateMe">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ externalLdap.percent }}%"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<p ng-show="externalLdap.syncBusy">{{ externalLdap.message }}</p>
|
||||
<p ng-hide="externalLdap.syncBusy">
|
||||
<div class="has-error" ng-show="!externalLdap.active">{{ externalLdap.errorMessage }}</div>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="col-md-6 text-right">
|
||||
<button class="btn btn-primary pull-right" ng-click="externalLdap.show()">{{ 'users.externalLdap.configureAction' | tr }}</button>
|
||||
<button class="btn btn-success pull-right" ng-disabled="externalLdap.currentConfig.provider === 'noop'" ng-click="externalLdap.sync()"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.syncBusy"></i> {{ 'users.externalLdap.syncAction' | tr }}</button>
|
||||
<a class="btn btn-primary pull-right" ng-show="externalLdap.taskId" ng-href="/frontend/logs.html?taskId={{ externalLdap.taskId }}" target="_blank">{{ 'users.externalLdap.showLogsAction' | tr }}</a>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<p ng-show="externalLdap.busy">{{ externalLdap.message }}</p>
|
||||
<p ng-hide="externalLdap.busy">
|
||||
<div class="has-error" ng-show="!externalLdap.active">{{ externalLdap.errorMessage }}</div>
|
||||
</p>
|
||||
<button class="btn btn-primary pull-right" ng-click="externalLdap.show()">{{ 'users.externalLdap.configureAction' | tr }}</button>
|
||||
<button class="btn btn-success pull-right" ng-disabled="externalLdap.currentConfig.provider === 'noop'" ng-click="externalLdap.sync()"><i class="fa fa-circle-notch fa-spin" ng-show="externalLdap.syncBusy"></i> {{ 'users.externalLdap.syncAction' | tr }}</button>
|
||||
<a class="btn btn-primary pull-right" ng-show="externalLdap.taskId" ng-href="/frontend/logs.html?taskId={{ externalLdap.taskId }}" target="_blank">{{ 'users.externalLdap.showLogsAction' | tr }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -436,7 +442,7 @@
|
||||
<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>
|
||||
<input type="checkbox" ng-model="userDirectoryConfig.enabled" ng-disabled="userDirectoryConfig.busy"> {{ 'users.exposedLdap.enabled' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-directory/#directory-server" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -447,6 +453,7 @@
|
||||
<button class="btn btn-default" type="button" id="userDirectoryUrlClipboardButton" data-clipboard-target="#userDirectoryUrlInput"><i class="fa fa-clipboard"></i></button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="text-small text-warning text-bold" ng-show="adminDomain.provider === 'cloudflare'">{{ 'users.exposedLdap.cloudflarePortWarning' | tr }} </p>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{ 'users.exposedLdap.secret.label' | tr }}</label>
|
||||
@@ -456,7 +463,7 @@
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{ 'users.exposedLdap.ipRestriction.label' | tr }}</label>
|
||||
<p class="small">{{ 'users.exposedLdap.ipRestriction.description' | tr }}</p>
|
||||
<p class="small" ng-bind-html=" 'users.exposedLdap.ipRestriction.description' | tr "></p>
|
||||
<textarea ng-model="userDirectoryConfig.allowlist" ng-disabled="!userDirectoryConfig.enabled || userDirectoryConfig.busy" placeholder="{{ 'users.exposedLdap.ipRestriction.placeholder' | tr }}" name="allowlist" class="form-control" ng-class="{ 'has-error': !userDirectoryConfigForm.allowlist.$dirty && userDirectoryConfig.error.allowlist }" rows="4"></textarea>
|
||||
<div class="has-error" ng-show="userDirectoryConfig.error.allowlist">{{ userDirectoryConfig.error.allowlist }}</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/* global angular */
|
||||
/* global Clipboard */
|
||||
/* global $ */
|
||||
/* global $, TASK_TYPES */
|
||||
|
||||
angular.module('Application').controller('UserSettingsController', ['$scope', '$location', '$translate', '$timeout', 'Client', function ($scope, $location, $translate, $timeout, Client) {
|
||||
Client.onReady(function () { if (!Client.getUserInfo().isAtLeastAdmin) $location.path('/'); });
|
||||
@@ -25,6 +25,7 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
|
||||
$scope.ready = false;
|
||||
$scope.config = Client.getConfig();
|
||||
$scope.userInfo = Client.getUserInfo();
|
||||
$scope.adminDomain = null;
|
||||
$scope.oidcClients = [];
|
||||
|
||||
$scope.profileConfig = {
|
||||
@@ -119,11 +120,11 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
|
||||
busy: false,
|
||||
percent: 0,
|
||||
message: '',
|
||||
errorMessage: '',
|
||||
error: {},
|
||||
taskId: 0,
|
||||
errorMessage: '', // last task error
|
||||
tasks: [],
|
||||
|
||||
syncBusy: false,
|
||||
error: {}, // save error
|
||||
saveBusy: false,
|
||||
|
||||
// fields
|
||||
provider: 'noop',
|
||||
@@ -139,57 +140,43 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
|
||||
|
||||
currentConfig: {},
|
||||
|
||||
checkStatus: function () {
|
||||
Client.getLatestTaskByType('syncExternalLdap', function (error, task) {
|
||||
if (error) return console.error(error);
|
||||
|
||||
if (!task) return;
|
||||
|
||||
$scope.externalLdap.taskId = task.id;
|
||||
$scope.externalLdap.updateStatus();
|
||||
});
|
||||
},
|
||||
|
||||
sync: function () {
|
||||
$scope.externalLdap.syncBusy = true;
|
||||
|
||||
Client.startExternalLdapSync(function (error, taskId) {
|
||||
if (error) {
|
||||
$scope.externalLdap.syncBusy = false;
|
||||
console.error('Unable to start ldap syncer task.', error);
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.externalLdap.taskId = taskId;
|
||||
$scope.externalLdap.updateStatus();
|
||||
});
|
||||
},
|
||||
|
||||
refresh: function() {
|
||||
init: function () {
|
||||
Client.getExternalLdapConfig(function (error, result) {
|
||||
if (error) return console.error('Unable to get external ldap config.', error);
|
||||
|
||||
$scope.externalLdap.currentConfig = result;
|
||||
$scope.externalLdap.checkStatus();
|
||||
$scope.externalLdap.refreshTasks();
|
||||
});
|
||||
},
|
||||
|
||||
refreshTasks: function () {
|
||||
Client.getTasksByType(TASK_TYPES.TASK_SYNC_EXTERNAL_LDAP, function (error, tasks) {
|
||||
if (error) return console.error(error);
|
||||
$scope.externalLdap.tasks = tasks.slice(0, 10);
|
||||
if ($scope.externalLdap.tasks.length && $scope.externalLdap.tasks[0].active) $scope.externalLdap.updateStatus();
|
||||
});
|
||||
},
|
||||
|
||||
updateStatus: function () {
|
||||
Client.getTask($scope.externalLdap.taskId, function (error, data) {
|
||||
var taskId = $scope.externalLdap.tasks[0].id;
|
||||
|
||||
Client.getTask(taskId, function (error, data) {
|
||||
if (error) return window.setTimeout($scope.externalLdap.updateStatus, 5000);
|
||||
|
||||
if (!data.active) {
|
||||
$scope.externalLdap.syncBusy = false;
|
||||
$scope.externalLdap.busy = false;
|
||||
$scope.externalLdap.message = '';
|
||||
$scope.externalLdap.percent = 100; // indicates that 'result' is valid
|
||||
$scope.externalLdap.errorMessage = data.success ? '' : data.error.message;
|
||||
|
||||
$scope.externalLdap.refreshTasks(); // update the tasks list dropdown
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.externalLdap.syncBusy = true;
|
||||
$scope.externalLdap.busy = true;
|
||||
$scope.externalLdap.percent = data.percent;
|
||||
$scope.externalLdap.message = data.message;
|
||||
window.setTimeout($scope.externalLdap.updateStatus, 3000);
|
||||
window.setTimeout($scope.externalLdap.updateStatus, 500);
|
||||
});
|
||||
},
|
||||
|
||||
@@ -214,8 +201,25 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
|
||||
$('#externalLdapModal').modal('show');
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
sync: function () {
|
||||
$scope.externalLdap.busy = true;
|
||||
$scope.externalLdap.percent = 0;
|
||||
$scope.externalLdap.message = '';
|
||||
$scope.externalLdap.errorMessage = '';
|
||||
|
||||
Client.startExternalLdapSync(function (error) {
|
||||
if (error) {
|
||||
console.error(error);
|
||||
$scope.externalLdap.errorMessage = error.message;
|
||||
$scope.externalLdap.busy = false;
|
||||
} else {
|
||||
$scope.externalLdap.refreshTasks();
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
$scope.externalLdap.saveBusy = true;
|
||||
$scope.externalLdap.error = {};
|
||||
|
||||
var config = {
|
||||
@@ -256,12 +260,13 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
|
||||
}
|
||||
|
||||
Client.setExternalLdapConfig(config, function (error) {
|
||||
$scope.externalLdap.busy = false;
|
||||
$scope.externalLdap.saveBusy = false;
|
||||
|
||||
if (error) {
|
||||
if (error.statusCode === 424) {
|
||||
if (error.code === 'SELF_SIGNED_CERT_IN_CHAIN') $scope.externalLdap.error.acceptSelfSignedCerts = true;
|
||||
else $scope.externalLdap.error.url = true;
|
||||
$scope.externalLdap.error.generic = error.message;
|
||||
} else if (error.statusCode === 400 && error.message === 'invalid baseDn') {
|
||||
$scope.externalLdap.error.baseDn = true;
|
||||
} else if (error.statusCode === 400 && error.message === 'invalid filter') {
|
||||
@@ -282,7 +287,7 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
|
||||
}
|
||||
} else {
|
||||
$('#externalLdapModal').modal('hide');
|
||||
$scope.externalLdap.refresh();
|
||||
$scope.externalLdap.init();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -402,10 +407,15 @@ angular.module('Application').controller('UserSettingsController', ['$scope', '$
|
||||
};
|
||||
|
||||
Client.onReady(function () {
|
||||
$scope.externalLdap.refresh();
|
||||
$scope.externalLdap.init();
|
||||
$scope.profileConfig.refresh();
|
||||
$scope.userDirectoryConfig.refresh();
|
||||
$scope.refreshOIDCClients();
|
||||
|
||||
Client.getDomains(function (error, result) {
|
||||
if (error) return console.error('Unable to list domains.', error);
|
||||
$scope.adminDomain = result.filter(function (d) { return d.domain === $scope.config.adminDomain; })[0];
|
||||
});
|
||||
});
|
||||
|
||||
// setup all the dialog focus handling
|
||||
|
||||
@@ -1,22 +1,3 @@
|
||||
<!-- Modal make user local -->
|
||||
<div class="modal fade" id="makeLocalModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ 'users.makeLocalDialog.title' | tr }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>{{ 'users.makeLocalDialog.description' | tr }}</p>
|
||||
<p class="text-warning">{{ 'users.makeLocalDialog.warning' | tr }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="makeLocal.submit()" ng-disabled="makeLocal.busy"><i class="fa fa-circle-notch fa-spin" ng-show="makeLocal.busy"></i> {{ 'users.makeLocalDialog.submitAction' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal add user -->
|
||||
<div class="modal fade" id="userAddModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
@@ -25,49 +6,49 @@
|
||||
<h4 class="modal-title">{{ 'users.addUserDialog.title' | tr }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form name="useraddForm" role="form" ng-submit="useradd.submit()" autocomplete="off">
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.displayName.$dirty && useradd.error.displayName) }">
|
||||
<form name="useraddForm" role="form" ng-submit="userAdd.submit()" autocomplete="off">
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.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="(!useraddForm.displayName.$dirty && useradd.error.displayName) || (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.displayName.$dirty && useradd.error.displayName)">
|
||||
<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="(!useraddForm.displayName.$dirty && userAdd.error.displayName) || (useraddForm.displayName.$dirty && useraddForm.displayName.$invalid) || (!useraddForm.displayName.$dirty && userAdd.error.displayName)">
|
||||
<small ng-show="useraddForm.displayName.$error.displayName">{{ 'users.user.errorNotValidFullName' | tr }}</small>
|
||||
<small ng-show="!useraddForm.displayName.$dirty && useradd.error.displayName">{{ useradd.error.displayName }}</small>
|
||||
<small ng-show="!useraddForm.displayName.$dirty && userAdd.error.displayName">{{ userAdd.error.displayName }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && useradd.error.email) }">
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && userAdd.error.email) }">
|
||||
<label class="control-label">{{ 'users.user.primaryEmail' | tr }} <sup><a ng-href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></label>
|
||||
<input type="email" class="form-control" ng-model="useradd.email" name="email" id="inputUserAddEmail" required>
|
||||
<div class="control-label" ng-show="(!useraddForm.email.$dirty && useradd.error.email) || (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && useradd.error.email)">
|
||||
<input type="email" class="form-control" ng-model="userAdd.email" name="email" id="inputUserAddEmail" required>
|
||||
<div class="control-label" ng-show="(!useraddForm.email.$dirty && userAdd.error.email) || (useraddForm.email.$dirty && useraddForm.email.$invalid) || (!useraddForm.email.$dirty && userAdd.error.email)">
|
||||
<small ng-show="useraddForm.email.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
|
||||
<small ng-show="useraddForm.email.$error.email">{{ 'users.user.errorInvalidEmail' | tr }}</small>
|
||||
<small ng-show="!useraddForm.email.$dirty && useradd.error.email">{{ useradd.error.email }}</small>
|
||||
<small ng-show="!useraddForm.email.$dirty && userAdd.error.email">{{ userAdd.error.email }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail) }">
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail) }">
|
||||
<label class="control-label">{{ 'users.user.recoveryEmail' | tr }} <sup><a ng-href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></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="(!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail) || (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail)">
|
||||
<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="(!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail) || (useraddForm.fallbackEmail.$dirty && useraddForm.fallbackEmail.$invalid) || (!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail)">
|
||||
<small ng-show="useraddForm.fallbackEmail.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
|
||||
<small ng-show="useraddForm.fallbackEmail.$error.fallbackEmail">{{ 'users.user.errorInvalidEmail' | tr }}</small>
|
||||
<small ng-show="!useraddForm.fallbackEmail.$dirty && useradd.error.fallbackEmail">{{ useradd.error.fallbackEmail }}</small>
|
||||
<small ng-show="!useraddForm.fallbackEmail.$dirty && userAdd.error.fallbackEmail">{{ userAdd.error.fallbackEmail }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.username.$dirty && useradd.error.username) }">
|
||||
<div class="form-group" ng-class="{ 'has-error': (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.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="(!useraddForm.username.$dirty && useradd.error.username) || (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.username.$dirty && useradd.error.username)">
|
||||
<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="(!useraddForm.username.$dirty && userAdd.error.username) || (useraddForm.username.$dirty && useraddForm.username.$invalid) || (!useraddForm.username.$dirty && userAdd.error.username)">
|
||||
<small ng-show="useraddForm.username.$error.username">{{ 'users.user.errorInvalidUsername' | tr }}</small>
|
||||
<small ng-show="!useraddForm.username.$dirty && useradd.error.username">{{ useradd.error.username }}</small>
|
||||
<small ng-show="!useraddForm.username.$dirty && userAdd.error.username">{{ userAdd.error.username }}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" ng-show="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>
|
||||
<div class="control-label">
|
||||
<select class="form-control" ng-model="useradd.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
|
||||
<select class="form-control" ng-model="userAdd.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -75,22 +56,23 @@
|
||||
<label class="control-label">{{ 'users.user.groups' | tr }}</label>
|
||||
<div class="control-label">
|
||||
<div ng-show="groups.length === 0">{{ 'users.user.noGroups' | tr }}</div>
|
||||
<multiselect ng-show="groups.length !== 0" ng-model="useradd.selectedGroups" options="group.name for group in groups" data-compare-by="name" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
<!-- local groups. they can have local and external users . angular cannot filter empty strings - https://github.com/angular/angular.js/issues/7890 -->
|
||||
<multiselect ng-show="groups.length !== 0" ng-model="userAdd.selectedLocalGroups" options="group.name for group in groups | filter:{ source: '!ldap' }" data-compare-by="name" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="useradd.sendInvite" id="inputUserAddSendInvite"> {{ 'users.addUserDialog.sendInviteCheckbox' | tr }}
|
||||
<input type="checkbox" ng-model="userAdd.sendInvite" id="inputUserAddSendInvite"> {{ 'users.addUserDialog.sendInviteCheckbox' | tr }}
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<input class="ng-hide" type="submit" ng-disabled="useraddForm.$invalid || useradd.busy"/>
|
||||
<input class="ng-hide" type="submit" ng-disabled="useraddForm.$invalid || userAdd.busy"/>
|
||||
</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="useradd.submit()" ng-disabled="useraddForm.$invalid || useradd.busy"><i class="fa fa-circle-notch fa-spin" ng-show="useradd.busy"></i> {{ 'users.addUserDialog.addUserAction' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="userAdd.submit()" ng-disabled="useraddForm.$invalid || userAdd.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userAdd.busy"></i> {{ 'users.addUserDialog.addUserAction' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,15 +83,15 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ 'users.deleteUserDialog.title' | tr:{ username: (userremove.userInfo.username || userremove.userInfo.email) } }}</h4>
|
||||
<h4 class="modal-title">{{ 'users.deleteUserDialog.title' | tr:{ username: (userRemove.userInfo.username || userRemove.userInfo.email) } }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p class="text-bold text-danger" ng-show="userremove.error">{{ userremove.error }}</p>
|
||||
<p ng-hide="userremove.error">{{ 'users.deleteUserDialog.description' | tr }}</p>
|
||||
<p class="text-bold text-danger" ng-show="userRemove.error">{{ userRemove.error }}</p>
|
||||
<p ng-hide="userRemove.error">{{ 'users.deleteUserDialog.description' | tr }}</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
|
||||
<button type="button" class="btn btn-danger" ng-click="userremove.submit()" ng-hide="userremove.error" ng-disabled="userremove.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userremove.busy"></i> {{ 'users.deleteUserDialog.deleteAction' | tr }}</button>
|
||||
<button type="button" class="btn btn-danger" ng-click="userRemove.submit()" ng-hide="userRemove.error" ng-disabled="userRemove.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userRemove.busy"></i> {{ 'users.deleteUserDialog.deleteAction' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -120,83 +102,94 @@
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">{{ 'users.editUserDialog.title' | tr:{ username: (useredit.userInfo.username || useredit.userInfo.email) } }}</h4>
|
||||
<h4 class="modal-title">{{ 'users.editUserDialog.title' | tr:{ username: (userEdit.userInfo.username || userEdit.userInfo.email) } }}</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div ng-show="useredit.source">
|
||||
<div ng-show="userEdit.source">
|
||||
<p class="text-warning">{{ 'users.editUserDialog.externalLdapWarning' | tr }}</p>
|
||||
<p><label>{{ 'users.user.displayName' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="useredit.displayName">
|
||||
<p><label>{{ 'users.user.email' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="useredit.email"></p>
|
||||
<p><label>{{ 'users.user.displayName' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="userEdit.displayName">
|
||||
<p><label>{{ 'users.user.email' | tr }}</label><br/><input type="text" class="form-control" ng-disabled="true" ng-model="userEdit.email"></p>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
<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) }">
|
||||
<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 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">
|
||||
<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) }">
|
||||
<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)">
|
||||
<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)">
|
||||
<small ng-show="useredit_form.displayName.$error.required">{{ 'users.user.errorDisplayNameRequired' | tr }}</small>
|
||||
<small ng-show="!useredit_form.displayName.$dirty && useredit.error.displayName">{{ useredit.error.displayName }}</small>
|
||||
<small ng-show="!useredit_form.displayName.$dirty && userEdit.error.displayName">{{ userEdit.error.displayName }}</small>
|
||||
</div>
|
||||
<input type="text" class="form-control" ng-model="useredit.displayName" name="displayName" required autofocus autocomplete="off">
|
||||
<input type="text" class="form-control" ng-model="userEdit.displayName" name="displayName" required autofocus autocomplete="off">
|
||||
</div>
|
||||
<div class="form-group" ng-hide="useredit.source" ng-class="{ 'has-error': (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && useredit.error.email) }">
|
||||
<div class="form-group" ng-hide="userEdit.source" ng-class="{ 'has-error': (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && userEdit.error.email) }">
|
||||
<label class="control-label">{{ 'users.user.primaryEmail' | tr }} <sup><a ng-href="https://docs.cloudron.io/profile/#primary-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></label>
|
||||
<div class="control-label" ng-show="(!useredit_form.email.$dirty && useredit.error.email) || (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && useredit.error.email)">
|
||||
<div class="control-label" ng-show="(!useredit_form.email.$dirty && userEdit.error.email) || (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && userEdit.error.email)">
|
||||
<small ng-show="useredit_form.email.$error.required">{{ 'users.user.errorEmailRequired' | tr }}</small>
|
||||
<small ng-show="useredit_form.email.$error.email">{{ 'users.user.errorInvalidEmail' | tr }}</small>
|
||||
<small ng-show="!useredit_form.email.$dirty && useredit.error.email">{{ useredit.error.email }}</small>
|
||||
<small ng-show="!useredit_form.email.$dirty && userEdit.error.email">{{ userEdit.error.email }}</small>
|
||||
</div>
|
||||
<input type="email" class="form-control" ng-model="useredit.email" name="email" required>
|
||||
<input type="email" class="form-control" ng-model="userEdit.email" name="email" required>
|
||||
</div>
|
||||
<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) }">
|
||||
<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 }} <sup><a ng-href="https://docs.cloudron.io/profile/#password-recovery-email" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></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)">
|
||||
<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.fallbackEmail">{{ 'users.user.errorInvalidEmail' | tr }}</small>
|
||||
<small ng-show="!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail">{{ useredit.error.fallbackEmail }}</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">
|
||||
<input type="fallbackEmail" class="form-control" ng-model="userEdit.fallbackEmail" name="fallbackEmail">
|
||||
</div>
|
||||
<div class="form-group" ng-show="!isMe(useredit.userInfo) && userInfo.isAtLeastAdmin">
|
||||
<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>
|
||||
<div class="control-label">
|
||||
<select class="form-control" ng-model="useredit.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
|
||||
<select class="form-control" ng-model="userEdit.role" ng-options="role.id as role.name disable when role.disabled for role in roles"></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{ 'users.user.groups' | tr }}</label>
|
||||
<div class="control-label">
|
||||
<div ng-show="groups.length === 0">{{ 'users.user.noGroups' | tr }}</div>
|
||||
<multiselect ng-show="groups.length !== 0" ng-model="useredit.selectedGroups" options="group.name for group in groups" data-compare-by="id" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
<div ng-switch on="groups.length">
|
||||
<div ng-switch-when="0">{{ 'users.user.noGroups' | tr }}</div>
|
||||
<div ng-switch-default>
|
||||
<!-- local groups. they can have local and external users . angular cannot filter empty strings - https://github.com/angular/angular.js/issues/7890 -->
|
||||
<multiselect ng-show="hasLocalGroups" ng-model="userEdit.selectedLocalGroups" options="group.name for group in groups | filter:{ source: '!ldap' }" data-compare-by="id" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group" ng-hide="isMe(useredit.userInfo)">
|
||||
<div class="form-group" ng-show="userEdit.externalGroups.length">
|
||||
<!-- remote groups. cannot be edited -->
|
||||
<label class="control-label">{{ 'users.user.ldapGroups' | tr }}</label>
|
||||
<div><span ng-repeat="group in userEdit.externalGroups">{{ group.name }}</span></div>
|
||||
</div>
|
||||
<div class="form-group" ng-hide="isMe(userEdit.userInfo)">
|
||||
<div class="checkbox">
|
||||
<label>
|
||||
<input type="checkbox" ng-model="useredit.active"> {{ 'users.user.activeCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#disable-user" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
<input type="checkbox" ng-model="userEdit.active"> {{ 'users.user.activeCheckbox' | tr }} <sup><a ng-href="https://docs.cloudron.io/user-management/#disable-user" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<input class="hide" type="submit" ng-disabled="useredit_form.$invalid || useredit.busy"/>
|
||||
<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 ng-hide="userEdit.source && config.external2FA">
|
||||
<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 ng-show="userEdit.source && config.external2FA"> {{ 'users.user.external2FA' | tr }}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">{{ 'main.dialog.cancel' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="useredit.submit()" ng-disabled="useredit_form.$invalid || useredit.busy"><i class="fa fa-circle-notch fa-spin" ng-show="useredit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
<button type="button" class="btn btn-success" ng-click="userEdit.submit()" ng-disabled="useredit_form.$invalid || userEdit.busy"><i class="fa fa-circle-notch fa-spin" ng-show="userEdit.busy"></i> {{ 'main.dialog.save' | tr }}</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -256,7 +249,8 @@
|
||||
<div class="form-group">
|
||||
<label class="control-label">{{ 'users.group.users' | tr }}</label>
|
||||
<div class="control-label">
|
||||
<multiselect ng-model="groupEdit.selectedUsers" options="(user.username || user.email) for user in allUsers" data-compare-by="email" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
<multiselect ng-hide="groupEdit.source" ng-model="groupEdit.selectedUsers" ng-disabled="groupEdit.busy" options="(user.username || user.email) for user in allUsers" data-compare-by="email" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
<div ng-show="groupEdit.source"><span ng-repeat="user in groupEdit.selectedUsers"> {{ (user.username || user.email) }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
@@ -265,7 +259,7 @@
|
||||
<multiselect ng-model="groupEdit.selectedApps" options="(app.label || app.fqdn) for app in groupEdit.apps" data-compare-by="fqdn" data-multiple="true" filter-after-rows="5" scroll-after-rows="10"></multiselect>
|
||||
</div>
|
||||
</div>
|
||||
<input class="hide" type="submit" ng-disabled="groupEdit_form.$invalid || useredit.busy"/>
|
||||
<input class="hide" type="submit" ng-disabled="groupEdit_form.$invalid || groupEdit.busy"/>
|
||||
</form>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
@@ -460,7 +454,8 @@
|
||||
<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">
|
||||
<!-- import/export buttons are hidden until we figure what the exact use case is -->
|
||||
<div class="btn-group" ng-hide="true">
|
||||
<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">
|
||||
@@ -472,7 +467,7 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn btn-primary btn-outline" ng-click="useradd.show()">
|
||||
<button class="btn btn-primary btn-outline" ng-click="userAdd.show()">
|
||||
<i class="fa fa-user-plus"></i> {{ 'users.newUserAction' | tr }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -506,13 +501,13 @@
|
||||
<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">
|
||||
<td class="hand elide-table-cell" ng-click="canEdit(user) && userEdit.show(user)" ng-show="user.username">
|
||||
{{ user.displayName }} <span class="text-muted">{{ user.username }}</span> <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">
|
||||
<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.email }}</span>
|
||||
</td>
|
||||
<td class="text-left hand elide-table-cell hidden-xs hidden-sm" ng-click="canEdit(user) && useredit.show(user)">
|
||||
<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">
|
||||
{{ groupsById[groupId].name }}
|
||||
</span>
|
||||
@@ -520,11 +515,10 @@
|
||||
|
||||
<td class="text-right no-wrap" style="vertical-align: bottom">
|
||||
<button ng-disabled="!canEdit(user)" ng-show="!user.inviteAccepted && !isMe(user) && !user.source" 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-show="user.source" class="btn btn-xs btn-default" ng-click="makeLocal.show(user)" uib-tooltip="{{ 'users.users.makeLocalTooltip' | tr }}"><i class="fas fa-thumbtack" style="width: 10.5px;"></i></button>
|
||||
<button ng-disabled="!canEdit(user)" ng-show="user.inviteAccepted && !user.source" 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="!canImpersonate(user)" 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>
|
||||
<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>
|
||||
|
||||
+160
-178
@@ -13,6 +13,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
$scope.users = []; // users of current page
|
||||
$scope.allUsersById = [];
|
||||
$scope.groups = [];
|
||||
$scope.hasLocalGroups = false;
|
||||
$scope.groupsById = { };
|
||||
$scope.config = Client.getConfig();
|
||||
$scope.userInfo = Client.getUserInfo();
|
||||
@@ -186,7 +187,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
$scope.userImport.busy = false;
|
||||
$scope.userImport.done = true;
|
||||
if ($scope.userImport.success) {
|
||||
refresh();
|
||||
refreshCurrentPage();
|
||||
refreshAllUsers();
|
||||
}
|
||||
});
|
||||
@@ -231,30 +232,30 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
});
|
||||
};
|
||||
|
||||
$scope.userremove = {
|
||||
$scope.userRemove = {
|
||||
busy: false,
|
||||
error: null,
|
||||
userInfo: {},
|
||||
|
||||
show: function (userInfo) {
|
||||
$scope.userremove.error = null;
|
||||
$scope.userremove.userInfo = userInfo;
|
||||
$scope.userRemove.error = null;
|
||||
$scope.userRemove.userInfo = userInfo;
|
||||
|
||||
$('#userRemoveModal').modal('show');
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
$scope.userremove.busy = true;
|
||||
$scope.userRemove.busy = true;
|
||||
|
||||
Client.removeUser($scope.userremove.userInfo.id, function (error) {
|
||||
$scope.userremove.busy = false;
|
||||
Client.removeUser($scope.userRemove.userInfo.id, function (error) {
|
||||
$scope.userRemove.busy = false;
|
||||
|
||||
if (error && error.statusCode === 403) return $scope.userremove.error = error.message;
|
||||
if (error && error.statusCode === 403) return $scope.userRemove.error = error.message;
|
||||
else if (error) return console.error('Unable to delete user.', error);
|
||||
|
||||
$scope.userremove.userInfo = {};
|
||||
$scope.userRemove.userInfo = {};
|
||||
|
||||
refresh();
|
||||
refreshCurrentPage();
|
||||
refreshAllUsers();
|
||||
|
||||
$('#userRemoveModal').modal('hide');
|
||||
@@ -262,7 +263,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
}
|
||||
};
|
||||
|
||||
$scope.useradd = {
|
||||
$scope.userAdd = {
|
||||
busy: false,
|
||||
alreadyTaken: false,
|
||||
error: {},
|
||||
@@ -270,19 +271,19 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
fallbackEmail: '',
|
||||
username: '',
|
||||
displayName: '',
|
||||
selectedGroups: [],
|
||||
selectedLocalGroups: [],
|
||||
role: 'user',
|
||||
sendInvite: false,
|
||||
|
||||
show: function () {
|
||||
$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.error = {};
|
||||
$scope.userAdd.email = '';
|
||||
$scope.userAdd.fallbackEmail = '';
|
||||
$scope.userAdd.username = '';
|
||||
$scope.userAdd.displayName = '';
|
||||
$scope.userAdd.selectedLocalGroups = [];
|
||||
$scope.userAdd.role = 'user';
|
||||
$scope.userAdd.sendInvite = false;
|
||||
|
||||
$scope.useraddForm.$setUntouched();
|
||||
$scope.useraddForm.$setPristine();
|
||||
@@ -291,33 +292,33 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
$scope.useradd.busy = true;
|
||||
$scope.userAdd.busy = true;
|
||||
|
||||
$scope.useradd.alreadyTaken = false;
|
||||
$scope.useradd.error.email = null;
|
||||
$scope.useradd.error.fallbackEmail = null;
|
||||
$scope.useradd.error.username = null;
|
||||
$scope.useradd.error.displayName = null;
|
||||
$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
|
||||
username: $scope.userAdd.username || null,
|
||||
email: $scope.userAdd.email,
|
||||
fallbackEmail: $scope.userAdd.fallbackEmail,
|
||||
displayName: $scope.userAdd.displayName,
|
||||
role: $scope.userAdd.role
|
||||
};
|
||||
|
||||
Client.addUser(user, function (error, userId) {
|
||||
if (error) {
|
||||
$scope.useradd.busy = false;
|
||||
$scope.userAdd.busy = false;
|
||||
|
||||
if (error.statusCode === 409) {
|
||||
if (error.message.toLowerCase().indexOf('email') !== -1) {
|
||||
$scope.useradd.error.email = 'Email already taken';
|
||||
$scope.userAdd.error.email = 'Email already taken';
|
||||
$scope.useraddForm.email.$setPristine();
|
||||
$('#inputUserAddEmail').focus();
|
||||
} else if (error.message.toLowerCase().indexOf('username') !== -1 || error.message.toLowerCase().indexOf('mailbox') !== -1) {
|
||||
$scope.useradd.error.username = 'Username already taken';
|
||||
$scope.userAdd.error.username = 'Username already taken';
|
||||
$scope.useraddForm.username.$setPristine();
|
||||
$('#inputUserAddUsername').focus();
|
||||
} else {
|
||||
@@ -327,12 +328,12 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
return;
|
||||
} else if (error.statusCode === 400) {
|
||||
if (error.message.toLowerCase().indexOf('email') !== -1) {
|
||||
$scope.useradd.error.email = 'Invalid Email';
|
||||
$scope.useradd.error.emailAttempted = $scope.useradd.email;
|
||||
$scope.userAdd.error.email = 'Invalid Email';
|
||||
$scope.userAdd.error.emailAttempted = $scope.userAdd.email;
|
||||
$scope.useraddForm.email.$setPristine();
|
||||
$('#inputUserAddEmail').focus();
|
||||
} else if (error.message.toLowerCase().indexOf('username') !== -1) {
|
||||
$scope.useradd.error.username = error.message;
|
||||
$scope.userAdd.error.username = error.message;
|
||||
$scope.useraddForm.username.$setPristine();
|
||||
$('#inputUserAddUsername').focus();
|
||||
} else {
|
||||
@@ -344,16 +345,16 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
}
|
||||
}
|
||||
|
||||
var groupIds = $scope.useradd.selectedGroups.map(function (g) { return g.id; });
|
||||
var localGroupIds = $scope.userAdd.selectedLocalGroups.map(function (g) { return g.id; });
|
||||
|
||||
Client.setGroups(userId, groupIds, function (error) {
|
||||
$scope.useradd.busy = false;
|
||||
Client.setLocalGroups(userId, localGroupIds, 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); });
|
||||
if ($scope.userAdd.sendInvite) Client.sendInviteEmail(userId, user.email, function (error) { if (error) console.error('Failed to send invite.', error); });
|
||||
|
||||
refresh();
|
||||
refreshCurrentPage();
|
||||
refreshAllUsers();
|
||||
|
||||
$('#userAddModal').modal('hide');
|
||||
@@ -362,7 +363,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
}
|
||||
};
|
||||
|
||||
$scope.useredit = {
|
||||
$scope.userEdit = {
|
||||
busy: false,
|
||||
reset2FABusy: false,
|
||||
error: {},
|
||||
@@ -376,20 +377,22 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
displayName: '',
|
||||
active: false,
|
||||
source: '',
|
||||
selectedGroups: [],
|
||||
selectedLocalGroups: [],
|
||||
externalGroups: [],
|
||||
role: '',
|
||||
|
||||
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;
|
||||
$scope.useredit.userInfo = userInfo;
|
||||
$scope.useredit.selectedGroups = userInfo.groupIds.map(function (gid) { return $scope.groupsById[gid]; });
|
||||
$scope.useredit.active = userInfo.active;
|
||||
$scope.useredit.source = userInfo.source;
|
||||
$scope.useredit.role = userInfo.role;
|
||||
$scope.userEdit.error = {};
|
||||
$scope.userEdit.username = userInfo.username;
|
||||
$scope.userEdit.email = userInfo.email;
|
||||
$scope.userEdit.displayName = userInfo.displayName;
|
||||
$scope.userEdit.fallbackEmail = userInfo.fallbackEmail;
|
||||
$scope.userEdit.userInfo = userInfo;
|
||||
$scope.userEdit.selectedLocalGroups = userInfo.groupIds.map(function (gid) { return $scope.groupsById[gid]; }).filter(function (g) { return g.source === ''; });
|
||||
$scope.userEdit.externalGroups = userInfo.groupIds.map(function (gid) { return $scope.groupsById[gid]; }).filter(function (g) { return g.source !== ''; });
|
||||
$scope.userEdit.active = userInfo.active;
|
||||
$scope.userEdit.source = userInfo.source;
|
||||
$scope.userEdit.role = userInfo.role;
|
||||
|
||||
$scope.useredit_form.$setPristine();
|
||||
$scope.useredit_form.$setUntouched();
|
||||
@@ -398,72 +401,69 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
$scope.useredit.error = {};
|
||||
$scope.useredit.busy = true;
|
||||
$scope.userEdit.error = {};
|
||||
$scope.userEdit.busy = true;
|
||||
|
||||
var userId = $scope.useredit.userInfo.id;
|
||||
var data = {
|
||||
id: userId
|
||||
};
|
||||
var userId = $scope.userEdit.userInfo.id;
|
||||
|
||||
// only send if not the current active user
|
||||
if (userId !== $scope.userInfo.id) {
|
||||
data.active = $scope.useredit.active;
|
||||
data.role = $scope.useredit.role;
|
||||
}
|
||||
async.series([
|
||||
function setRole(next) {
|
||||
if (userId === $scope.userInfo.id) return next(); // cannot set role on self
|
||||
Client.setRole(userId, $scope.userEdit.role, next);
|
||||
},
|
||||
function setActive(next) {
|
||||
if (userId === $scope.userInfo.id) return next(); // cannot set role on self
|
||||
Client.setActive(userId, $scope.userEdit.active, next);
|
||||
},
|
||||
function updateUserProfile(next) {
|
||||
if ($scope.userEdit.source) return next(); // cannot update profile of external user
|
||||
// username is settable only if it was empty previously. it's editable for the "lock" profiles feature
|
||||
var data = {};
|
||||
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;
|
||||
Client.updateUserProfile(userId, data, next);
|
||||
},
|
||||
function setLocalGroups(next) {
|
||||
var localGroupIds = $scope.userEdit.selectedLocalGroups.map(function (g) { return g.id; });
|
||||
Client.setLocalGroups(userId, localGroupIds, next);
|
||||
}
|
||||
], function (error) {
|
||||
$scope.userEdit.busy = false;
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
Client.updateUser(data, function (error) {
|
||||
if (error) {
|
||||
$scope.useredit.busy = false;
|
||||
|
||||
if (error.statusCode === 409) {
|
||||
if (error.message.toLowerCase().indexOf('email') !== -1) {
|
||||
$scope.useredit.error.email = 'Email already taken';
|
||||
$scope.userEdit.error.email = 'Email already taken';
|
||||
} else if (error.message.toLowerCase().indexOf('username') !== -1) {
|
||||
$scope.useredit.error.username = 'Username already taken';
|
||||
$scope.userEdit.error.username = 'Username already taken';
|
||||
}
|
||||
$scope.useredit_form.email.$setPristine();
|
||||
$('#inputUserEditEmail').focus();
|
||||
} else {
|
||||
$scope.useredit.error.generic = error.message;
|
||||
$scope.userEdit.error.generic = error.message;
|
||||
console.error('Unable to update user:', error);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
var groupIds = $scope.useredit.selectedGroups.map(function (g) { return g.id; });
|
||||
|
||||
Client.setGroups(data.id, groupIds, function (error) {
|
||||
$scope.useredit.busy = false;
|
||||
|
||||
if (error) return console.error('Unable to update groups for user:', error);
|
||||
|
||||
refreshUsers(false);
|
||||
|
||||
$('#userEditModal').modal('hide');
|
||||
});
|
||||
refreshUsersCurrentPage(false /* busy indicator */);
|
||||
refreshGroups();
|
||||
$('#userEditModal').modal('hide');
|
||||
});
|
||||
},
|
||||
|
||||
reset2FA: function () {
|
||||
$scope.useredit.reset2FABusy = true;
|
||||
$scope.userEdit.reset2FABusy = true;
|
||||
|
||||
Client.disableTwoFactorAuthenticationByUserId($scope.useredit.userInfo.id, function (error) {
|
||||
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;
|
||||
$scope.userEdit.userInfo.twoFactorAuthenticationEnabled = false;
|
||||
$scope.userEdit.reset2FABusy = false;
|
||||
}, 3000);
|
||||
});
|
||||
}
|
||||
@@ -517,7 +517,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
|
||||
if (error) return console.error('Unable to add memebers.', error.statusCode, error.message);
|
||||
|
||||
refresh();
|
||||
refreshCurrentPage();
|
||||
|
||||
$('#groupAddModal').modal('hide');
|
||||
});
|
||||
@@ -556,11 +556,61 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
$('#groupEditModal').modal('show');
|
||||
},
|
||||
|
||||
updateAccessRestriction: function () {
|
||||
// find apps where ACL has changed
|
||||
var addedApps = $scope.groupEdit.selectedApps.filter(function (a) {
|
||||
return !$scope.groupEdit.selectedAppsOriginal.find(function (b) { return b.id === a.id; });
|
||||
});
|
||||
var removedApps = $scope.groupEdit.selectedAppsOriginal.filter(function (a) {
|
||||
return !$scope.groupEdit.selectedApps.find(function (b) { return b.id === a.id; });
|
||||
});
|
||||
|
||||
async.eachSeries(addedApps, function (app, callback) {
|
||||
var accessRestriction = app.accessRestriction;
|
||||
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
|
||||
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
|
||||
|
||||
accessRestriction.groups.push($scope.groupEdit.groupInfo.id);
|
||||
|
||||
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
|
||||
}, function (error) {
|
||||
if (error) {
|
||||
$scope.groupEdit.busy = false;
|
||||
return console.error('Unable to set added app access.', error.statusCode, error.message);
|
||||
}
|
||||
|
||||
async.eachSeries(removedApps, function (app, callback) {
|
||||
var accessRestriction = app.accessRestriction;
|
||||
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
|
||||
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
|
||||
|
||||
var deleted = accessRestriction.groups.splice(accessRestriction.groups.indexOf($scope.groupEdit.groupInfo.id), 1);
|
||||
|
||||
// if not found return early
|
||||
if (deleted.length === 0) return callback();
|
||||
|
||||
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
|
||||
}, function (error) {
|
||||
$scope.groupEdit.busy = false;
|
||||
if (error) return console.error('Unable to set removed app access.', error.statusCode, error.message);
|
||||
|
||||
refreshCurrentPage();
|
||||
|
||||
// refresh apps to reflect change
|
||||
Client.refreshInstalledApps();
|
||||
|
||||
$('#groupEditModal').modal('hide');
|
||||
});
|
||||
});
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
$scope.groupEdit.busy = true;
|
||||
$scope.groupEdit.error = {};
|
||||
|
||||
Client.updateGroup($scope.groupEdit.groupInfo.id, $scope.groupEdit.name, function (error) {
|
||||
if ($scope.groupEdit.source) return $scope.groupEdit.updateAccessRestriction(); // cannot update name or members of external groups
|
||||
|
||||
Client.setGroupName($scope.groupEdit.groupInfo.id, $scope.groupEdit.name, function (error) {
|
||||
if (error) {
|
||||
$scope.groupEdit.busy = false;
|
||||
|
||||
@@ -587,51 +637,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
return console.error('Unable to set group members.', error.statusCode, error.message);
|
||||
}
|
||||
|
||||
// find apps where ACL has changed
|
||||
var addedApps = $scope.groupEdit.selectedApps.filter(function (a) {
|
||||
return !$scope.groupEdit.selectedAppsOriginal.find(function (b) { return b.id === a.id; });
|
||||
});
|
||||
var removedApps = $scope.groupEdit.selectedAppsOriginal.filter(function (a) {
|
||||
return !$scope.groupEdit.selectedApps.find(function (b) { return b.id === a.id; });
|
||||
});
|
||||
|
||||
async.eachSeries(addedApps, function (app, callback) {
|
||||
var accessRestriction = app.accessRestriction;
|
||||
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
|
||||
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
|
||||
|
||||
accessRestriction.groups.push($scope.groupEdit.groupInfo.id);
|
||||
|
||||
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
|
||||
}, function (error) {
|
||||
if (error) {
|
||||
$scope.groupEdit.busy = false;
|
||||
return console.error('Unable to set added app access.', error.statusCode, error.message);
|
||||
}
|
||||
|
||||
async.eachSeries(removedApps, function (app, callback) {
|
||||
var accessRestriction = app.accessRestriction;
|
||||
if (!accessRestriction) accessRestriction = { users: [], groups: [] };
|
||||
if (!Array.isArray(accessRestriction.groups)) accessRestriction.groups = [];
|
||||
|
||||
var deleted = accessRestriction.groups.splice(accessRestriction.groups.indexOf($scope.groupEdit.groupInfo.id), 1);
|
||||
|
||||
// if not found return early
|
||||
if (deleted.length === 0) return callback();
|
||||
|
||||
Client.configureApp(app.id, 'access_restriction', { accessRestriction: accessRestriction }, callback);
|
||||
}, function (error) {
|
||||
$scope.groupEdit.busy = false;
|
||||
if (error) return console.error('Unable to set removed app access.', error.statusCode, error.message);
|
||||
|
||||
refresh();
|
||||
|
||||
// refresh apps to reflect change
|
||||
Client.refreshInstalledApps();
|
||||
|
||||
$('#groupEditModal').modal('hide');
|
||||
});
|
||||
});
|
||||
$scope.groupEdit.updateAccessRestriction();
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -668,7 +674,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
|
||||
if (error) return console.error('Unable to remove group.', error.statusCode, error.message);
|
||||
|
||||
refresh();
|
||||
refreshCurrentPage();
|
||||
$('#groupRemoveModal').modal('hide');
|
||||
});
|
||||
}
|
||||
@@ -717,32 +723,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
}
|
||||
};
|
||||
|
||||
$scope.makeLocal = {
|
||||
busy: false,
|
||||
user: null,
|
||||
|
||||
show: function (user) {
|
||||
$scope.makeLocal.busy = false;
|
||||
$scope.makeLocal.user = user;
|
||||
|
||||
$('#makeLocalModal').modal('show');
|
||||
},
|
||||
|
||||
submit: function () {
|
||||
$scope.makeLocal.busy = false;
|
||||
|
||||
Client.makeUserLocal($scope.makeLocal.user.id, function (error) {
|
||||
if (error) return console.error('Failed to make user local.', error);
|
||||
|
||||
$scope.makeLocal.busy = false;
|
||||
|
||||
refreshUsers();
|
||||
|
||||
$('#makeLocalModal').modal('hide');
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
$scope.invitation = {
|
||||
busy: false,
|
||||
inviteLink: '',
|
||||
@@ -825,7 +805,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
}
|
||||
};
|
||||
|
||||
function getUsers(callback) {
|
||||
function getUsersCurrentPage(callback) {
|
||||
var users = [];
|
||||
|
||||
Client.getUsers($scope.userSearchString, $scope.userStateFilter.value, $scope.currentPage, $scope.pageItems, function (error, results) {
|
||||
@@ -845,10 +825,10 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
});
|
||||
}
|
||||
|
||||
function refreshUsers(showBusy) { // loads users on current page only
|
||||
function refreshUsersCurrentPage(showBusy) { // loads users on current page only
|
||||
if (showBusy) $scope.userRefreshBusy = true;
|
||||
|
||||
getUsers(function (error, result) {
|
||||
getUsersCurrentPage(function (error, result) {
|
||||
if (error) return console.error('Unable to get user listing.', error);
|
||||
|
||||
angular.copy(result, $scope.users);
|
||||
@@ -867,34 +847,36 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
|
||||
angular.copy(result, $scope.groups);
|
||||
$scope.groupsById = { };
|
||||
$scope.hasLocalGroups = false;
|
||||
for (var i = 0; i < result.length; i++) {
|
||||
$scope.groupsById[result[i].id] = result[i];
|
||||
if (result[i].source === '') $scope.hasLocalGroups = true;
|
||||
}
|
||||
|
||||
if (callback) callback();
|
||||
});
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
function refreshCurrentPage() {
|
||||
refreshGroups(function (error) {
|
||||
if (error) return console.error('Unable to get group listing.', error);
|
||||
refreshUsers(true);
|
||||
refreshUsersCurrentPage(true /* busy indicator */);
|
||||
});
|
||||
}
|
||||
|
||||
$scope.showNextPage = function () {
|
||||
$scope.currentPage++;
|
||||
refreshUsers();
|
||||
refreshUsersCurrentPage(false /* no busy indicator */);
|
||||
};
|
||||
|
||||
$scope.showPrevPage = function () {
|
||||
if ($scope.currentPage > 1) $scope.currentPage--;
|
||||
else $scope.currentPage = 1;
|
||||
refreshUsers();
|
||||
refreshUsersCurrentPage(false /* no busy indicator */);
|
||||
};
|
||||
|
||||
$scope.updateFilter = function () {
|
||||
refreshUsers();
|
||||
refreshUsersCurrentPage(false /* no busy indicator */);
|
||||
};
|
||||
|
||||
function refreshAllUsers() { // this loads all users on Cloudron, not just current page
|
||||
@@ -911,7 +893,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
|
||||
}
|
||||
|
||||
Client.onReady(function () {
|
||||
refresh();
|
||||
refreshCurrentPage();
|
||||
refreshAllUsers();
|
||||
|
||||
// Order matters for permissions used in canEdit
|
||||
|
||||
Generated
+172
-173
@@ -8,32 +8,32 @@
|
||||
"name": "my-vue-app",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@fontsource/noto-sans": "^5.0.17",
|
||||
"@fontsource/noto-sans": "^5.0.19",
|
||||
"@xterm/addon-attach": "^0.10.0",
|
||||
"@xterm/addon-fit": "^0.9.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"anser": "^2.1.1",
|
||||
"combokeys": "^3.0.1",
|
||||
"filesize": "^10.1.0",
|
||||
"marked": "^10.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"pankow": "^1.1.8",
|
||||
"marked": "^12.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"pankow": "^1.2.1",
|
||||
"primeicons": "^6.0.1",
|
||||
"primevue": "^3.41.1",
|
||||
"primevue": "^3.49.1",
|
||||
"superagent": "^8.1.2",
|
||||
"vue": "^3.3.9",
|
||||
"vue-i18n": "^9.7.1",
|
||||
"vue-router": "^4.2.5",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-attach": "^0.9.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.10.1",
|
||||
"vue-router": "^4.3.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.2"
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.1.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.23.4",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.23.4.tgz",
|
||||
"integrity": "sha512-vf3Xna6UEprW+7t6EtOmFpHNAuxw3xqPZghy+brsnusscJRW5BMUzzHZc5ICjULee81WeUV2jjakG09MDglJXQ==",
|
||||
"version": "7.24.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.24.0.tgz",
|
||||
"integrity": "sha512-QuP/FxEAzMSjXygs8v4N9dvdXzEHN4W1oF3PxuWAtPo08UdM17u89RDMgjLn/mlc56iM0HlLmVkO/wgR+rDgHg==",
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
},
|
||||
@@ -394,17 +394,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@fontsource/noto-sans": {
|
||||
"version": "5.0.17",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans/-/noto-sans-5.0.17.tgz",
|
||||
"integrity": "sha512-VcnKA99cE8OgRiy6O3T6xCKirsguD5+MYrGrbBWYA3m3fqDArCr66eEvR3iuTngGLbTODJq4bzc6yfaiGZu/pQ=="
|
||||
"version": "5.0.19",
|
||||
"resolved": "https://registry.npmjs.org/@fontsource/noto-sans/-/noto-sans-5.0.19.tgz",
|
||||
"integrity": "sha512-5PmyWnplHmjuUwkaSpOljOcZ2GrdV4H2fDQS/OpmcgpgvgRM+8YYAoEI4xOlXb15kwWy/nHAFQYw3EYu7gPeng=="
|
||||
},
|
||||
"node_modules/@intlify/core-base": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.7.1.tgz",
|
||||
"integrity": "sha512-jPJTeECEhqQ7g//8g3Fb79j5SzSSRqlFCWD6pcX94uMLXU+L1m07gVZnnvzoJBnaMyJHiiwxOqZVfvu6rQfLvw==",
|
||||
"version": "9.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/core-base/-/core-base-9.10.1.tgz",
|
||||
"integrity": "sha512-0+Wtjj04GIyglh5KKiNjRwgjpHrhqqGZhaKY/QVjjogWKZq5WHROrTi84pNVsRN18QynyPmjtsVUWqFKPQ45xQ==",
|
||||
"dependencies": {
|
||||
"@intlify/message-compiler": "9.7.1",
|
||||
"@intlify/shared": "9.7.1"
|
||||
"@intlify/message-compiler": "9.10.1",
|
||||
"@intlify/shared": "9.10.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
@@ -414,11 +414,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/message-compiler": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.7.1.tgz",
|
||||
"integrity": "sha512-HfIr2Hn/K7b0Zv4kGqkxAxwtipyxAwhI9a3krN5cuhH/G9gkaik7of1PdzjR3Mix43t2onBiKYQyaU7mo7e0aA==",
|
||||
"version": "9.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/message-compiler/-/message-compiler-9.10.1.tgz",
|
||||
"integrity": "sha512-b68UTmRhgZfswJZI7VAgW6BXZK5JOpoi5swMLGr4j6ss2XbFY13kiw+Hu+xYAfulMPSapcHzdWHnq21VGnMCnA==",
|
||||
"dependencies": {
|
||||
"@intlify/shared": "9.7.1",
|
||||
"@intlify/shared": "9.10.1",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
@@ -429,9 +429,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@intlify/shared": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.7.1.tgz",
|
||||
"integrity": "sha512-CBKnHzlUYGrk5QII9q4nElAQKO5cX1rRx8VmSWXltyOZjbkGHXYQTHULn6KwRi+CypuBCfmPkyPBHMzosypIeg==",
|
||||
"version": "9.10.1",
|
||||
"resolved": "https://registry.npmjs.org/@intlify/shared/-/shared-9.10.1.tgz",
|
||||
"integrity": "sha512-liyH3UMoglHBUn70iCYcy9CQlInx/lp50W2aeSxqqrvmG+LDj/Jj7tBJhBoQL4fECkldGhbmW0g2ommHfL6Wmw==",
|
||||
"engines": {
|
||||
"node": ">= 16"
|
||||
},
|
||||
@@ -629,124 +629,133 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/@vitejs/plugin-vue": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-4.5.0.tgz",
|
||||
"integrity": "sha512-a2WSpP8X8HTEww/U00bU4mX1QpLINNuz/2KMNpLsdu3BzOpak3AGI1CJYBTXcc4SPhaD0eNRUp7IyQK405L5dQ==",
|
||||
"version": "5.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-5.0.4.tgz",
|
||||
"integrity": "sha512-WS3hevEszI6CEVEx28F8RjTX97k3KsrcY6kvTg7+Whm5y3oYvcqzVeGCU3hxSAn4uY2CLCkeokkGKpoctccilQ==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^14.18.0 || >=16.0.0"
|
||||
"node": "^18.0.0 || >=20.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vite": "^4.0.0 || ^5.0.0",
|
||||
"vite": "^5.0.0",
|
||||
"vue": "^3.2.25"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-core": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.3.9.tgz",
|
||||
"integrity": "sha512-+/Lf68Vr/nFBA6ol4xOtJrW+BQWv3QWKfRwGSm70jtXwfhZNF4R/eRgyVJYoxFRhdCTk/F6g99BP0ffPgZihfQ==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.4.21.tgz",
|
||||
"integrity": "sha512-MjXawxZf2SbZszLPYxaFCjxfibYrzr3eYbKxwpLR9EQN+oaziSu3qKVbwBERj1IFIB8OLUewxB5m/BFzi613og==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.23.3",
|
||||
"@vue/shared": "3.3.9",
|
||||
"@babel/parser": "^7.23.9",
|
||||
"@vue/shared": "3.4.21",
|
||||
"entities": "^4.5.0",
|
||||
"estree-walker": "^2.0.2",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-dom": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.3.9.tgz",
|
||||
"integrity": "sha512-nfWubTtLXuT4iBeDSZ5J3m218MjOy42Vp2pmKVuBKo2/BLcrFUX8nCSr/bKRFiJ32R8qbdnnnBgRn9AdU5v0Sg==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.4.21.tgz",
|
||||
"integrity": "sha512-IZC6FKowtT1sl0CR5DpXSiEB5ayw75oT2bma1BEhV7RRR1+cfwLrxc2Z8Zq/RGFzJ8w5r9QtCOvTjQgdn0IKmA==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-core": "3.3.9",
|
||||
"@vue/shared": "3.3.9"
|
||||
"@vue/compiler-core": "3.4.21",
|
||||
"@vue/shared": "3.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-sfc": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.3.9.tgz",
|
||||
"integrity": "sha512-wy0CNc8z4ihoDzjASCOCsQuzW0A/HP27+0MDSSICMjVIFzk/rFViezkR3dzH+miS2NDEz8ywMdbjO5ylhOLI2A==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.4.21.tgz",
|
||||
"integrity": "sha512-me7epoTxYlY+2CUM7hy9PCDdpMPfIwrOvAXud2Upk10g4YLv9UBW7kL798TvMeDhPthkZ0CONNrK2GoeI1ODiQ==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.23.3",
|
||||
"@vue/compiler-core": "3.3.9",
|
||||
"@vue/compiler-dom": "3.3.9",
|
||||
"@vue/compiler-ssr": "3.3.9",
|
||||
"@vue/reactivity-transform": "3.3.9",
|
||||
"@vue/shared": "3.3.9",
|
||||
"@babel/parser": "^7.23.9",
|
||||
"@vue/compiler-core": "3.4.21",
|
||||
"@vue/compiler-dom": "3.4.21",
|
||||
"@vue/compiler-ssr": "3.4.21",
|
||||
"@vue/shared": "3.4.21",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.5",
|
||||
"postcss": "^8.4.31",
|
||||
"magic-string": "^0.30.7",
|
||||
"postcss": "^8.4.35",
|
||||
"source-map-js": "^1.0.2"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/compiler-ssr": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.3.9.tgz",
|
||||
"integrity": "sha512-NO5oobAw78R0G4SODY5A502MGnDNiDjf6qvhn7zD7TJGc8XDeIEw4fg6JU705jZ/YhuokBKz0A5a/FL/XZU73g==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.4.21.tgz",
|
||||
"integrity": "sha512-M5+9nI2lPpAsgXOGQobnIueVqc9sisBFexh5yMIMRAPYLa7+5wEJs8iqOZc1WAa9WQbx9GR2twgznU8LTIiZ4Q==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.3.9",
|
||||
"@vue/shared": "3.3.9"
|
||||
"@vue/compiler-dom": "3.4.21",
|
||||
"@vue/shared": "3.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/devtools-api": {
|
||||
"version": "6.5.0",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.5.0.tgz",
|
||||
"integrity": "sha512-o9KfBeaBmCKl10usN4crU53fYtC1r7jJwdGKjPT24t348rHxgfpZ0xL3Xm/gLUYnc0oTp8LAmrxOeLyu6tbk2Q=="
|
||||
"version": "6.6.1",
|
||||
"resolved": "https://registry.npmjs.org/@vue/devtools-api/-/devtools-api-6.6.1.tgz",
|
||||
"integrity": "sha512-LgPscpE3Vs0x96PzSSB4IGVSZXZBZHpfxs+ZA1d+VEPwHdOXowy/Y2CsvCAIFrf+ssVU1pD1jidj505EpUnfbA=="
|
||||
},
|
||||
"node_modules/@vue/reactivity": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.3.9.tgz",
|
||||
"integrity": "sha512-VmpIqlNp+aYDg2X0xQhJqHx9YguOmz2UxuUJDckBdQCNkipJvfk9yA75woLWElCa0Jtyec3lAAt49GO0izsphw==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.4.21.tgz",
|
||||
"integrity": "sha512-UhenImdc0L0/4ahGCyEzc/pZNwVgcglGy9HVzJ1Bq2Mm9qXOpP8RyNTjookw/gOCUlXSEtuZ2fUg5nrHcoqJcw==",
|
||||
"dependencies": {
|
||||
"@vue/shared": "3.3.9"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/reactivity-transform": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/reactivity-transform/-/reactivity-transform-3.3.9.tgz",
|
||||
"integrity": "sha512-HnUFm7Ry6dFa4Lp63DAxTixUp8opMtQr6RxQCpDI1vlh12rkGIeYqMvJtK+IKyEfEOa2I9oCkD1mmsPdaGpdVg==",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.23.3",
|
||||
"@vue/compiler-core": "3.3.9",
|
||||
"@vue/shared": "3.3.9",
|
||||
"estree-walker": "^2.0.2",
|
||||
"magic-string": "^0.30.5"
|
||||
"@vue/shared": "3.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-core": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.3.9.tgz",
|
||||
"integrity": "sha512-xxaG9KvPm3GTRuM4ZyU8Tc+pMVzcu6eeoSRQJ9IE7NmCcClW6z4B3Ij6L4EDl80sxe/arTtQ6YmgiO4UZqRc+w==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.4.21.tgz",
|
||||
"integrity": "sha512-pQthsuYzE1XcGZznTKn73G0s14eCJcjaLvp3/DKeYWoFacD9glJoqlNBxt3W2c5S40t6CCcpPf+jG01N3ULyrA==",
|
||||
"dependencies": {
|
||||
"@vue/reactivity": "3.3.9",
|
||||
"@vue/shared": "3.3.9"
|
||||
"@vue/reactivity": "3.4.21",
|
||||
"@vue/shared": "3.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/runtime-dom": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.3.9.tgz",
|
||||
"integrity": "sha512-e7LIfcxYSWbV6BK1wQv9qJyxprC75EvSqF/kQKe6bdZEDNValzeRXEVgiX7AHI6hZ59HA4h7WT5CGvm69vzJTQ==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.4.21.tgz",
|
||||
"integrity": "sha512-gvf+C9cFpevsQxbkRBS1NpU8CqxKw0ebqMvLwcGQrNpx6gqRDodqKqA+A2VZZpQ9RpK2f9yfg8VbW/EpdFUOJw==",
|
||||
"dependencies": {
|
||||
"@vue/runtime-core": "3.3.9",
|
||||
"@vue/shared": "3.3.9",
|
||||
"csstype": "^3.1.2"
|
||||
"@vue/runtime-core": "3.4.21",
|
||||
"@vue/shared": "3.4.21",
|
||||
"csstype": "^3.1.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/server-renderer": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.3.9.tgz",
|
||||
"integrity": "sha512-w0zT/s5l3Oa3ZjtLW88eO4uV6AQFqU8X5GOgzq7SkQQu6vVr+8tfm+OI2kDBplS/W/XgCBuFXiPw6T5EdwXP0A==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.4.21.tgz",
|
||||
"integrity": "sha512-aV1gXyKSN6Rz+6kZ6kr5+Ll14YzmIbeuWe7ryJl5muJ4uwSwY/aStXTixx76TwkZFJLm1aAlA/HSWEJ4EyiMkg==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-ssr": "3.3.9",
|
||||
"@vue/shared": "3.3.9"
|
||||
"@vue/compiler-ssr": "3.4.21",
|
||||
"@vue/shared": "3.4.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"vue": "3.3.9"
|
||||
"vue": "3.4.21"
|
||||
}
|
||||
},
|
||||
"node_modules/@vue/shared": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.3.9.tgz",
|
||||
"integrity": "sha512-ZE0VTIR0LmYgeyhurPTpy4KzKsuDyQbMSdM49eKkMnT5X4VfFBLysMzjIZhLEFQYjjOVVfbvUDHckwjDFiO2eA=="
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.4.21.tgz",
|
||||
"integrity": "sha512-PuJe7vDIi6VYSinuEbUIQgMIRZGgM8e4R+G+/dQTk0X1NEdvgvvgv7m+rfmDH1gZzyA1OjjoWskvHlfRNfQf3g=="
|
||||
},
|
||||
"node_modules/@xterm/addon-attach": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-attach/-/addon-attach-0.10.0.tgz",
|
||||
"integrity": "sha512-ES/XO8pC1tPHSkh4j7qzM8ajFt++u8KMvfRc9vKIbjHTDOxjl9IUVo+vcQgLn3FTCM3w2czTvBss8nMWlD83Cg==",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/addon-fit": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.9.0.tgz",
|
||||
"integrity": "sha512-hDlPPbTVPYyvwXu/asW8HbJkI/2RMi0cMaJnBZYVeJB0SWP2NeESMCNr+I7CvBlyI0sAxpxOg8Wk4OMkxBz9WA==",
|
||||
"peerDependencies": {
|
||||
"@xterm/xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@xterm/xterm": {
|
||||
"version": "5.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.4.0.tgz",
|
||||
"integrity": "sha512-GlyzcZZ7LJjhFevthHtikhiDIl8lnTSgol6eTM4aoSNLcuXu3OEhnbqdCVIjtIil3jjabf3gDtb1S8FGahsuEw=="
|
||||
},
|
||||
"node_modules/abbrev": {
|
||||
"version": "1.1.1",
|
||||
@@ -909,9 +918,9 @@
|
||||
"integrity": "sha512-LDx6oHrK+PhzLKJU9j5S7/Y3jM/mUHvD/DeI1WQmJn652iPC5Y4TBzC9l+5OMOXlyTTA+SmVUPm0HQUwpD5Jqw=="
|
||||
},
|
||||
"node_modules/csstype": {
|
||||
"version": "3.1.2",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.2.tgz",
|
||||
"integrity": "sha512-I7K1Uu0MBPzaFKg4nI5Q7Vs2t+3gWWW648spaF+Rg7pI9ds18Ugn+lvg4SHczUdKlHI5LWBXyqfS8+DufyBsgQ=="
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.1.3.tgz",
|
||||
"integrity": "sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw=="
|
||||
},
|
||||
"node_modules/debug": {
|
||||
"version": "4.3.4",
|
||||
@@ -979,6 +988,17 @@
|
||||
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/entities": {
|
||||
"version": "4.5.0",
|
||||
"resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
|
||||
"integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
|
||||
"engines": {
|
||||
"node": ">=0.12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/fb55/entities?sponsor=1"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.19.7",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.19.7.tgz",
|
||||
@@ -1261,9 +1281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/magic-string": {
|
||||
"version": "0.30.5",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz",
|
||||
"integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==",
|
||||
"version": "0.30.7",
|
||||
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.7.tgz",
|
||||
"integrity": "sha512-8vBuFF/I/+OSLRmdf2wwFCJCz+nSn0m6DPvGH1fS/KiQoSaR+sETbov0eIk9KhEKy8CYqIkIAnbohxT/4H0kuA==",
|
||||
"dependencies": {
|
||||
"@jridgewell/sourcemap-codec": "^1.4.15"
|
||||
},
|
||||
@@ -1296,9 +1316,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/marked": {
|
||||
"version": "10.0.0",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-10.0.0.tgz",
|
||||
"integrity": "sha512-YiGcYcWj50YrwBgNzFoYhQ1hT6GmQbFG8SksnYJX1z4BXTHSOrz1GB5/Jm2yQvMg4nN1FHP4M6r03R10KrVUiA==",
|
||||
"version": "12.0.1",
|
||||
"resolved": "https://registry.npmjs.org/marked/-/marked-12.0.1.tgz",
|
||||
"integrity": "sha512-Y1/V2yafOcOdWQCX0XpAKXzDakPOpn6U0YLxTJs3cww6VxOzZV1BTOOYWLvH3gX38cq+iLwljHHTnMtlDfg01Q==",
|
||||
"bin": {
|
||||
"marked": "bin/marked.js"
|
||||
},
|
||||
@@ -1415,17 +1435,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.29.4.tgz",
|
||||
"integrity": "sha512-5LC9SOxjSc2HF6vO2CyuTDNivEdoz2IvyJJGj6X8DJ0eFyfszE0QiEd+iXmBvUP3WHxSjFH/vIsA0EN00cgr8w==",
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/monaco-editor": {
|
||||
"version": "0.44.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.44.0.tgz",
|
||||
"integrity": "sha512-5SmjNStN6bSuSE5WPT2ZV+iYn1/yI9sd4Igtk23ChvqB7kDk9lZbB9F5frsuvpB+2njdIeGGFf2G4gbE6rCC9Q=="
|
||||
"version": "0.46.0",
|
||||
"resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.46.0.tgz",
|
||||
"integrity": "sha512-ADwtLIIww+9FKybWscd7OCfm9odsFYHImBRI1v9AviGce55QY8raT+9ihH8jX/E/e6QVSGM+pKj4jSUSRmALNQ=="
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.2",
|
||||
@@ -1439,9 +1459,9 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.6",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.6.tgz",
|
||||
"integrity": "sha512-BGcqMMJuToF7i1rt+2PWSNVnWIkGCU78jBG3RxO/bZlnZPK2Cmi2QaffxGO/2RvWi9sL+FAiRiXMgsyxQ1DIDA==",
|
||||
"version": "3.3.7",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
|
||||
"integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
@@ -1528,14 +1548,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/pankow": {
|
||||
"version": "1.1.8",
|
||||
"resolved": "https://registry.npmjs.org/pankow/-/pankow-1.1.8.tgz",
|
||||
"integrity": "sha512-XJkiE4GolbbNuXstc+tHoIP1jrRV6I6XLbgypEzr2hBxdr7wJsKjZF2rIpBDdTiK4l9KCRSA5vbqThD6ymA2GQ==",
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/pankow/-/pankow-1.2.1.tgz",
|
||||
"integrity": "sha512-tlPxzyAOVCV40k3yLCJltqde30/mcCMfn80zWU5f/4t4oTgdPwL/e9vkv7lAxdNpJx3tkfu/UIvDzAnZP7h/Ig==",
|
||||
"dependencies": {
|
||||
"filesize": "^10.1.0",
|
||||
"monaco-editor": "^0.44.0",
|
||||
"monaco-editor": "^0.46.0",
|
||||
"pdfjs-dist": "^3.11.174",
|
||||
"primevue": "^3.41.1",
|
||||
"primevue": "^3.49.1",
|
||||
"superagent": "^8.1.2"
|
||||
}
|
||||
},
|
||||
@@ -1575,9 +1595,9 @@
|
||||
"integrity": "sha512-1fygroTLlHu66zi26VoTDv8yRgm0Fccecssto+MhsZ0D/DGW2sm8E8AjW7NU5VVTRt5GxbeZ5qBuJr+HyLYkjQ=="
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.4.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
|
||||
"integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==",
|
||||
"version": "8.4.35",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.35.tgz",
|
||||
"integrity": "sha512-u5U8qYpBCpN13BsiEB0CbR1Hhh4Gc0zLFuedrHJKMctHCHAGrMdG0PRM/KErzAL3CU6/eckEtmHNB3x6e3c0vA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "opencollective",
|
||||
@@ -1593,7 +1613,7 @@
|
||||
}
|
||||
],
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.6",
|
||||
"nanoid": "^3.3.7",
|
||||
"picocolors": "^1.0.0",
|
||||
"source-map-js": "^1.0.2"
|
||||
},
|
||||
@@ -1607,9 +1627,9 @@
|
||||
"integrity": "sha512-KDeO94CbWI4pKsPnYpA1FPjo79EsY9I+M8ywoPBSf9XMXoe/0crjbUK7jcQEDHuc0ZMRIZsxH3TYLv4TUtHmAA=="
|
||||
},
|
||||
"node_modules/primevue": {
|
||||
"version": "3.41.1",
|
||||
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.41.1.tgz",
|
||||
"integrity": "sha512-+RDGLsw7ktS2Jz/mptPwwbW/YI5E0u1tWYustlG745FFdX3o1/Dxs47hdnvo22wysPUQr1mdh/uznsKJZMV7fw==",
|
||||
"version": "3.49.1",
|
||||
"resolved": "https://registry.npmjs.org/primevue/-/primevue-3.49.1.tgz",
|
||||
"integrity": "sha512-OmUTqbKbPB63Zqf7uA49cipDi+Qh+/13AYJPwgvsVsI4QmAKIkeibBwkOgj1CNIFlopfF79YmyBshFUAPqlw9A==",
|
||||
"peerDependencies": {
|
||||
"vue": "^3.0.0"
|
||||
}
|
||||
@@ -1868,13 +1888,13 @@
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.0.2.tgz",
|
||||
"integrity": "sha512-6CCq1CAJCNM1ya2ZZA7+jS2KgnhbzvxakmlIjN24cF/PXhRMzpM/z8QgsVJA/Dm5fWUWnVEsmtBoMhmerPxT0g==",
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz",
|
||||
"integrity": "sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.19.3",
|
||||
"postcss": "^8.4.31",
|
||||
"postcss": "^8.4.35",
|
||||
"rollup": "^4.2.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -1923,15 +1943,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue": {
|
||||
"version": "3.3.9",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.3.9.tgz",
|
||||
"integrity": "sha512-sy5sLCTR8m6tvUk1/ijri3Yqzgpdsmxgj6n6yl7GXXCXqVbmW2RCXe9atE4cEI6Iv7L89v5f35fZRRr5dChP9w==",
|
||||
"version": "3.4.21",
|
||||
"resolved": "https://registry.npmjs.org/vue/-/vue-3.4.21.tgz",
|
||||
"integrity": "sha512-5hjyV/jLEIKD/jYl4cavMcnzKwjMKohureP8ejn3hhEjwhWIhWeuzL2kJAjzl/WyVsgPY56Sy4Z40C3lVshxXA==",
|
||||
"dependencies": {
|
||||
"@vue/compiler-dom": "3.3.9",
|
||||
"@vue/compiler-sfc": "3.3.9",
|
||||
"@vue/runtime-dom": "3.3.9",
|
||||
"@vue/server-renderer": "3.3.9",
|
||||
"@vue/shared": "3.3.9"
|
||||
"@vue/compiler-dom": "3.4.21",
|
||||
"@vue/compiler-sfc": "3.4.21",
|
||||
"@vue/runtime-dom": "3.4.21",
|
||||
"@vue/server-renderer": "3.4.21",
|
||||
"@vue/shared": "3.4.21"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"typescript": "*"
|
||||
@@ -1943,12 +1963,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-i18n": {
|
||||
"version": "9.7.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.7.1.tgz",
|
||||
"integrity": "sha512-A6DzWqJQMdzBj+392+g3zIgGV0FnFC7o/V+txs5yIALANEZzY6ZV8hM2wvZR3nTbQI7dntAmzBHMeoEteJO0kQ==",
|
||||
"version": "9.10.1",
|
||||
"resolved": "https://registry.npmjs.org/vue-i18n/-/vue-i18n-9.10.1.tgz",
|
||||
"integrity": "sha512-37HVJQZ/pZaRXGzFmmMomM1u1k7kndv3xCBPYHKEVfv5W3UVK67U/TpBug71ILYLNmjHLHdvTUPRF81pFT5fFg==",
|
||||
"dependencies": {
|
||||
"@intlify/core-base": "9.7.1",
|
||||
"@intlify/shared": "9.7.1",
|
||||
"@intlify/core-base": "9.10.1",
|
||||
"@intlify/shared": "9.10.1",
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -1962,11 +1982,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vue-router": {
|
||||
"version": "4.2.5",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.2.5.tgz",
|
||||
"integrity": "sha512-DIUpKcyg4+PTQKfFPX88UWhlagBEBEfJ5A8XDXRJLUnZOvcpMF8o/dnL90vpVkGaPbjvXazV/rC1qBKrZlFugw==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/vue-router/-/vue-router-4.3.0.tgz",
|
||||
"integrity": "sha512-dqUcs8tUeG+ssgWhcPbjHvazML16Oga5w34uCUmsk7i0BcnskoLGwjpa15fqMr2Fa5JgVBrdL2MEgqz6XZ/6IQ==",
|
||||
"dependencies": {
|
||||
"@vue/devtools-api": "^6.5.0"
|
||||
"@vue/devtools-api": "^6.5.1"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/posva"
|
||||
@@ -2005,27 +2025,6 @@
|
||||
"resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz",
|
||||
"integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ=="
|
||||
},
|
||||
"node_modules/xterm": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm/-/xterm-5.3.0.tgz",
|
||||
"integrity": "sha512-8QqjlekLUFTrU6x7xck1MsPzPA571K5zNqWm0M0oroYEWVOptZ0+ubQSkQ3uxIEhcIHRujJy6emDWX4A7qyFzg=="
|
||||
},
|
||||
"node_modules/xterm-addon-attach": {
|
||||
"version": "0.9.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-attach/-/xterm-addon-attach-0.9.0.tgz",
|
||||
"integrity": "sha512-NykWWOsobVZPPK3P9eFkItrnBK9Lw0f94uey5zhqIVB1bhswdVBfl+uziEzSOhe2h0rT9wD0wOeAYsdSXeavPw==",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/xterm-addon-fit": {
|
||||
"version": "0.8.0",
|
||||
"resolved": "https://registry.npmjs.org/xterm-addon-fit/-/xterm-addon-fit-0.8.0.tgz",
|
||||
"integrity": "sha512-yj3Np7XlvxxhYF/EJ7p3KHaMt6OdwQ+HDu573Vx1lRXsVxOcnVJs51RgjZOouIZOczTsskaS+CpXspK81/DLqw==",
|
||||
"peerDependencies": {
|
||||
"xterm": "^5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/yallist": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz",
|
||||
|
||||
+13
-13
@@ -9,25 +9,25 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/noto-sans": "^5.0.17",
|
||||
"@fontsource/noto-sans": "^5.0.19",
|
||||
"anser": "^2.1.1",
|
||||
"combokeys": "^3.0.1",
|
||||
"filesize": "^10.1.0",
|
||||
"marked": "^10.0.0",
|
||||
"moment": "^2.29.4",
|
||||
"pankow": "^1.1.8",
|
||||
"marked": "^12.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"pankow": "^1.2.1",
|
||||
"primeicons": "^6.0.1",
|
||||
"primevue": "^3.41.1",
|
||||
"primevue": "^3.49.1",
|
||||
"superagent": "^8.1.2",
|
||||
"vue": "^3.3.9",
|
||||
"vue-i18n": "^9.7.1",
|
||||
"vue-router": "^4.2.5",
|
||||
"xterm": "^5.3.0",
|
||||
"xterm-addon-attach": "^0.9.0",
|
||||
"xterm-addon-fit": "^0.8.0"
|
||||
"vue": "^3.4.21",
|
||||
"vue-i18n": "^9.10.1",
|
||||
"vue-router": "^4.3.0",
|
||||
"@xterm/xterm": "^5.4.0",
|
||||
"@xterm/addon-attach": "^0.10.0",
|
||||
"@xterm/addon-fit": "^0.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@vitejs/plugin-vue": "^4.5.0",
|
||||
"vite": "^5.0.2"
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"vite": "^5.1.5"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,8 +17,8 @@
|
||||
</TopBar>
|
||||
</template>
|
||||
<template #body>
|
||||
<div v-for="line in logLines" :key="line.id" class="log-line">
|
||||
<span class="time">{{ line.time }}</span><span v-html="line.html"></span>
|
||||
<div v-for="line of logLines" class="log-line">
|
||||
<span class="time">{{ line.time || '[no timestamp] ' }}</span> <span v-html="line.html"></span>
|
||||
</div>
|
||||
<div class="bottom-spacer"></div>
|
||||
</template>
|
||||
@@ -137,8 +137,8 @@ export default {
|
||||
|
||||
this.downloadUrl = this.logsModel.getDownloadUrl();
|
||||
|
||||
this.logsModel.stream((id, time, html) => {
|
||||
this.logLines.push({ id, time, html});
|
||||
this.logsModel.stream((time, html) => {
|
||||
this.logLines.push({ time, html});
|
||||
|
||||
const tmp = document.getElementsByClassName('cloudron-layout-body')[0];
|
||||
if (!tmp) return;
|
||||
|
||||
@@ -71,10 +71,10 @@ import ProgressSpinner from 'primevue/progressspinner';
|
||||
|
||||
import { TopBar, MainLayout, FileUploader } from 'pankow';
|
||||
|
||||
import 'xterm/css/xterm.css';
|
||||
import { Terminal } from 'xterm';
|
||||
import { AttachAddon } from 'xterm-addon-attach';
|
||||
import { FitAddon } from 'xterm-addon-fit';
|
||||
import '@xterm/xterm/css/xterm.css';
|
||||
import { Terminal } from '@xterm/xterm';
|
||||
import { AttachAddon } from '@xterm/addon-attach';
|
||||
import { FitAddon } from '@xterm/addon-fit';
|
||||
|
||||
import { create } from '../models/AppModel.js';
|
||||
|
||||
@@ -335,6 +335,7 @@ export default {
|
||||
|
||||
body {
|
||||
background-color: black;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.title {
|
||||
|
||||
@@ -90,9 +90,9 @@ export function createDirectoryModel(origin, accessToken, api) {
|
||||
await superagent.del(`${origin}/api/v1/${api}/files/${filePath}`)
|
||||
.query({ access_token: accessToken });
|
||||
},
|
||||
async rename(fromFilePath, toFilePath) {
|
||||
async rename(fromFilePath, toFilePath, overwrite = false) {
|
||||
await superagent.put(`${origin}/api/v1/${api}/files/${fromFilePath}`)
|
||||
.send({ action: 'rename', newFilePath: sanitize(toFilePath) })
|
||||
.send({ action: 'rename', newFilePath: sanitize(toFilePath), overwrite })
|
||||
.query({ access_token: accessToken });
|
||||
},
|
||||
async copy(fromFilePath, toFilePath) {
|
||||
|
||||
@@ -69,11 +69,10 @@ export function create(origin, accessToken, type, id) {
|
||||
return console.error(e);
|
||||
}
|
||||
|
||||
const id = data.realtimeTimestamp;
|
||||
const time = data.realtimeTimestamp ? moment(data.realtimeTimestamp/1000).format('MMM DD HH:mm:ss') : '';
|
||||
const html = ansiToHtml(escapeHtml(typeof data.message === 'string' ? data.message : ab2str(data.message)));
|
||||
|
||||
lineHandler(id, time, html);
|
||||
lineHandler(time, html);
|
||||
};
|
||||
},
|
||||
getDownloadUrl() {
|
||||
|
||||
@@ -378,8 +378,26 @@ export default {
|
||||
|
||||
},
|
||||
async renameHandler(file, newName) {
|
||||
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
|
||||
await this.loadCwd();
|
||||
if (file.name === newName) return;
|
||||
|
||||
try {
|
||||
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName));
|
||||
await this.loadCwd();
|
||||
} catch (e) {
|
||||
if (e.status === 409) {
|
||||
this.$confirm.require({
|
||||
message: this.$t('filemanager.renameDialog.reallyOverwrite'),
|
||||
icon: '',
|
||||
acceptClass: 'p-button-danger',
|
||||
accept: async () => {
|
||||
await this.directoryModel.rename(this.directoryModel.buildFilePath(this.cwd, file.name), sanitize(this.cwd + '/' + newName), true /* overwrite */);
|
||||
await this.loadCwd();
|
||||
this.$confirm.close();
|
||||
}
|
||||
});
|
||||
}
|
||||
else console.error(`Failed to rename ${file} to ${newName}`, e);
|
||||
}
|
||||
},
|
||||
async changeOwnerHandler(files, newOwnerUid) {
|
||||
if (!files) return;
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = async function (db) {
|
||||
await db.runSql('ALTER TABLE appPortBindings ADD COLUMN count INTEGER DEFAULT 1');
|
||||
};
|
||||
|
||||
exports.down = async function (db) {
|
||||
await db.runSql('ALTER TABLE appPortBindings DROP COLUMN count');
|
||||
};
|
||||
@@ -0,0 +1,9 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = async function (db) {
|
||||
await db.runSql('ALTER TABLE users ADD COLUMN language VARCHAR(8) NOT NULL DEFAULT ""');
|
||||
};
|
||||
|
||||
exports.down = async function (db) {
|
||||
await db.runSql('ALTER TABLE users DROP COLUMN language');
|
||||
};
|
||||
@@ -0,0 +1,20 @@
|
||||
'use strict';
|
||||
|
||||
// ensure the inboxDomain and mailboxDomain are cleared when the addons are missing or disabled
|
||||
// this allows the domain to be deleted. otherwise, the ui hides these fields and user cannot do anything to delete the domain
|
||||
exports.up = async function(db) {
|
||||
const apps = await db.runSql('SELECT * FROM apps', []);
|
||||
for (const app of apps) {
|
||||
const manifest = JSON.parse(app.manifestJson);
|
||||
if (!manifest.addons?.recvmail || !app.enableInbox) {
|
||||
await db.runSql('UPDATE apps SET enableInbox=?, inboxName=?, inboxDomain=? WHERE id=?', [ false, null, null, app.id ]);
|
||||
}
|
||||
|
||||
if (!manifest.addons?.sendmail || !app.enableMailbox) {
|
||||
await db.runSql('UPDATE apps SET enableMailbox=?, mailboxName=?, mailboxDomain=? WHERE id=?', [ false, null, null, app.id ]);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
exports.down = async function(/* db */) {
|
||||
};
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
exports.up = function(db, callback) {
|
||||
db.runSql('ALTER TABLE apps ADD CONSTRAINT inbox_domain_constraint FOREIGN KEY(inboxDomain) REFERENCES domains(domain)', function (error)
|
||||
{
|
||||
if (error) console.error(error);
|
||||
callback(error);
|
||||
});
|
||||
};
|
||||
|
||||
exports.down = function(db, callback) {
|
||||
db.runSql('ALTER TABLE apps DROP FOREIGN KEY inbox_domain_constraint', function (error) {
|
||||
if (error) console.error(error);
|
||||
callback(error);
|
||||
});
|
||||
};
|
||||
|
||||
@@ -105,6 +105,7 @@ CREATE TABLE IF NOT EXISTS apps(
|
||||
upstreamUri VARCHAR(256) DEFAULT "",
|
||||
|
||||
FOREIGN KEY(mailboxDomain) REFERENCES domains(domain),
|
||||
FOREIGN KEY(inboxDomain) REFERENCES domains(domain),
|
||||
FOREIGN KEY(taskId) REFERENCES tasks(id),
|
||||
FOREIGN KEY(storageVolumeId) REFERENCES volumes(id),
|
||||
UNIQUE (storageVolumeId, storageVolumePrefix),
|
||||
@@ -115,6 +116,7 @@ CREATE TABLE IF NOT EXISTS appPortBindings(
|
||||
type VARCHAR(8) NOT NULL DEFAULT "tcp",
|
||||
environmentVariable VARCHAR(128) NOT NULL,
|
||||
appId VARCHAR(128) NOT NULL,
|
||||
count INTEGER DEFAULT 1,
|
||||
FOREIGN KEY(appId) REFERENCES apps(id),
|
||||
PRIMARY KEY(hostPort));
|
||||
|
||||
|
||||
Generated
+149
-156
@@ -11,15 +11,15 @@
|
||||
"@google-cloud/dns": "^3.0.2",
|
||||
"@google-cloud/storage": "^6.12.0",
|
||||
"async": "^3.2.5",
|
||||
"aws-sdk": "^2.1502.0",
|
||||
"aws-sdk": "^2.1554.0",
|
||||
"basic-auth": "^2.0.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"cloudron-manifestformat": "^5.21.0",
|
||||
"cloudron-manifestformat": "^5.22.1",
|
||||
"connect": "^3.7.0",
|
||||
"connect-lastmile": "^2.2.0",
|
||||
"connect-timeout": "^1.9.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cookie-session": "^2.0.0",
|
||||
"cookie-session": "^2.1.0",
|
||||
"cron": "^2.4.4",
|
||||
"db-migrate": "^0.11.14",
|
||||
"db-migrate-mysql": "^2.3.2",
|
||||
@@ -33,18 +33,18 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapjs": "^2.3.3",
|
||||
"marked": "^7.0.5",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"multiparty": "^4.2.3",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"nodemailer": "^6.9.9",
|
||||
"nsyslog-parser": "^0.10.1",
|
||||
"oidc-provider": "^8.4.1",
|
||||
"oidc-provider": "^8.4.5",
|
||||
"ovh": "^2.0.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"readdirp": "^3.6.0",
|
||||
"safetydance": "^2.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"semver": "^7.6.0",
|
||||
"speakeasy": "^2.0.0",
|
||||
"superagent": "^8.1.2",
|
||||
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
|
||||
@@ -53,7 +53,7 @@
|
||||
"underscore": "^1.13.6",
|
||||
"uuid": "^9.0.1",
|
||||
"validator": "^13.11.0",
|
||||
"ws": "^8.14.2",
|
||||
"ws": "^8.16.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"bin": {
|
||||
@@ -63,14 +63,14 @@
|
||||
"devDependencies": {
|
||||
"commander": "^11.1.0",
|
||||
"easy-table": "^1.2.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint": "^8.56.0",
|
||||
"expect.js": "*",
|
||||
"hock": "^1.4.1",
|
||||
"js2xmlparser": "^5.0.0",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha": "^10.3.0",
|
||||
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
|
||||
"nock": "^13.3.8",
|
||||
"ssh2": "^1.14.0",
|
||||
"nock": "^13.5.1",
|
||||
"ssh2": "^1.15.0",
|
||||
"yesno": "^0.4.0"
|
||||
}
|
||||
},
|
||||
@@ -112,9 +112,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/eslintrc": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.3.tgz",
|
||||
"integrity": "sha512-yZzuIG+jnVu6hNSzFEN07e8BxF3uAzYtQb6uDkaYZLo6oYZDCq454c5kB8zxnzfCYyP4MIuyBn10L0DqwujTmA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-2.1.4.tgz",
|
||||
"integrity": "sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ajv": "^6.12.4",
|
||||
@@ -147,9 +147,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@eslint/js": {
|
||||
"version": "8.54.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.54.0.tgz",
|
||||
"integrity": "sha512-ut5V+D+fOoWPgGGNj83GGjnntO39xDy6DWxO0wb7Jp3DcMX0TfIqdzHF85VTQkerdyGmuuMD9AKAo5KiNlf/AQ==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-8.56.0.tgz",
|
||||
"integrity": "sha512-gMsVel9D7f2HLkBma9VbtzZRehRogVRfbr++f06nL2vnCGCNlzOD+/MUov/F4p8myyAHspEhVobgjpX64q5m6A==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^12.22.0 || ^14.17.0 || >=16.0.0"
|
||||
@@ -323,9 +323,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@koa/cors": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-4.0.0.tgz",
|
||||
"integrity": "sha512-Y4RrbvGTlAaa04DBoPBWJqDR5gPj32OOz827ULXfgB1F7piD1MB/zwn8JR2LAnvdILhxUbXbkXGWuNVsFuVFCQ==",
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@koa/cors/-/cors-5.0.0.tgz",
|
||||
"integrity": "sha512-x/iUDjcS90W69PryLDIMgFyV21YLTnG9zOpPXS7Bkt2b8AsY3zZsIpOLBkYr9fBcF3HbkKaER5hOBZLfpLgYNw==",
|
||||
"dependencies": {
|
||||
"vary": "^1.1.2"
|
||||
},
|
||||
@@ -492,9 +492,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acorn": {
|
||||
"version": "8.11.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.2.tgz",
|
||||
"integrity": "sha512-nc0Axzp/0FILLEVsm4fNwLCwMttvhEI263QtVPQcbpfZZ3ts0hLsZGOpE6czNlid7CJ9MlyH8reXkpsf3YUY4w==",
|
||||
"version": "8.11.3",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.11.3.tgz",
|
||||
"integrity": "sha512-Y9rRfJG5jcKOE0CLisYbojUjIrIEE7AGMzA/Sm4BslANhbS+cDMpgBdcPT91oJ7OuJ9hYJBx59RjbhxVnrF8Xg==",
|
||||
"dev": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
@@ -641,9 +641,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/aws-sdk": {
|
||||
"version": "2.1502.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1502.0.tgz",
|
||||
"integrity": "sha512-mUXUaWmbIyqE6zyIcbUUQIUgw1evK7gV1vQP7ZZEE0qi6hO2Mw99Nc25Bh+187yvRxamMTsFXvvmBViR0Q75SA==",
|
||||
"version": "2.1554.0",
|
||||
"resolved": "https://registry.npmjs.org/aws-sdk/-/aws-sdk-2.1554.0.tgz",
|
||||
"integrity": "sha512-MmCfg80CKCOFeC8K6UMSmDLPPGVesAglOzmO2IMEugHt10UsK2szOa+C31IHO2PEnjhn+l4WoVlaBAN/YQX+tQ==",
|
||||
"dependencies": {
|
||||
"buffer": "4.9.2",
|
||||
"events": "1.1.1",
|
||||
@@ -654,7 +654,7 @@
|
||||
"url": "0.10.3",
|
||||
"util": "^0.12.4",
|
||||
"uuid": "8.0.0",
|
||||
"xml2js": "0.5.0"
|
||||
"xml2js": "0.6.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 10.0.0"
|
||||
@@ -667,18 +667,6 @@
|
||||
"uuid": "dist/bin/uuid"
|
||||
}
|
||||
},
|
||||
"node_modules/aws-sdk/node_modules/xml2js": {
|
||||
"version": "0.5.0",
|
||||
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.5.0.tgz",
|
||||
"integrity": "sha512-drPFnkQJik/O+uPKpqSgr22mpuFHqKdbS835iAQrUC73L2F5WkboIRd63ai/2Yg6I1jzifPFKH2NTK+cfglkIA==",
|
||||
"dependencies": {
|
||||
"sax": ">=0.6.0",
|
||||
"xmlbuilder": "~11.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/backoff": {
|
||||
"version": "2.5.0",
|
||||
"license": "MIT",
|
||||
@@ -1003,25 +991,34 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cloudron-manifestformat": {
|
||||
"version": "5.21.0",
|
||||
"resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.21.0.tgz",
|
||||
"integrity": "sha512-FG3f2v1jq0GFbJnbTi6WOlHCCpZIrKdHC7uZBMbjcAkhMmEX505NUY/QZSmxyU+Eb3qwxX/UNI+zVfbJ9jOSDA==",
|
||||
"version": "5.22.1",
|
||||
"resolved": "https://registry.npmjs.org/cloudron-manifestformat/-/cloudron-manifestformat-5.22.1.tgz",
|
||||
"integrity": "sha512-WfCko1oNbrwMLoErZEXD68Z62Ia/1hGrA2urVs5k6NbpbRvL7/kwIPHfGTYLr7N3jDHrgIudwanemO0YwfzNrg==",
|
||||
"dependencies": {
|
||||
"cron": "^2.4.3",
|
||||
"cron": "^3.1.6",
|
||||
"java-packagename-regex": "^1.0.0",
|
||||
"safetydance": "2.2.0",
|
||||
"semver": "^7.5.4",
|
||||
"safetydance": "2.4.0",
|
||||
"semver": "^7.6.0",
|
||||
"tv4": "^1.3.0",
|
||||
"validator": "^13.11.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cloudron-manifestformat/node_modules/safetydance": {
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/safetydance/-/safetydance-2.2.0.tgz",
|
||||
"integrity": "sha512-TzAedqLBi4KLXVYUuFp17HhX2AJJlzFsZqlPWyO5GHFEeqhUo70azU+CiGeFKi8xlbrvHUIz0hSIqw3eQTXidw==",
|
||||
"engines": [
|
||||
"node >= 4.0.0"
|
||||
]
|
||||
"node_modules/cloudron-manifestformat/node_modules/cron": {
|
||||
"version": "3.1.6",
|
||||
"resolved": "https://registry.npmjs.org/cron/-/cron-3.1.6.tgz",
|
||||
"integrity": "sha512-cvFiQCeVzsA+QPM6fhjBtlKGij7tLLISnTSvFxVdnFGLdz+ZdXN37kNe0i2gefmdD17XuZA6n2uPVwzl4FxW/w==",
|
||||
"dependencies": {
|
||||
"@types/luxon": "~3.3.0",
|
||||
"luxon": "~3.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/cloudron-manifestformat/node_modules/luxon": {
|
||||
"version": "3.4.4",
|
||||
"resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.4.tgz",
|
||||
"integrity": "sha512-zobTr7akeGHnv7eBOXcRgMeCP6+uyYsczwmeRCauvpvaAltgNyTbLH/+VaEAPUeWBT+1GuNmz4wC/6jtQzbbVA==",
|
||||
"engines": {
|
||||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/co": {
|
||||
"version": "4.6.0",
|
||||
@@ -1212,10 +1209,11 @@
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-session": {
|
||||
"version": "2.0.0",
|
||||
"license": "MIT",
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/cookie-session/-/cookie-session-2.1.0.tgz",
|
||||
"integrity": "sha512-u73BDmR8QLGcs+Lprs0cfbcAPKl2HnPcjpwRXT41sEV4DRJ2+W0vJEEZkG31ofkx+HZflA70siRIjiTdIodmOQ==",
|
||||
"dependencies": {
|
||||
"cookies": "0.8.0",
|
||||
"cookies": "0.9.1",
|
||||
"debug": "3.2.7",
|
||||
"on-headers": "~1.0.2",
|
||||
"safe-buffer": "5.2.1"
|
||||
@@ -1224,6 +1222,18 @@
|
||||
"node": ">= 0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-session/node_modules/cookies": {
|
||||
"version": "0.9.1",
|
||||
"resolved": "https://registry.npmjs.org/cookies/-/cookies-0.9.1.tgz",
|
||||
"integrity": "sha512-TG2hpqe4ELx54QER/S3HQ9SRVnQnGBtKUz5bLQWtYAQ+o6GpgMs6sYUvaiJjVxb+UXwhRhAEP3m7LbsIZ77Hmw==",
|
||||
"dependencies": {
|
||||
"depd": "~2.0.0",
|
||||
"keygrip": "~1.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-session/node_modules/debug": {
|
||||
"version": "3.2.7",
|
||||
"license": "MIT",
|
||||
@@ -1231,6 +1241,14 @@
|
||||
"ms": "^2.1.1"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-session/node_modules/depd": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz",
|
||||
"integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==",
|
||||
"engines": {
|
||||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-session/node_modules/safe-buffer": {
|
||||
"version": "5.2.1",
|
||||
"funding": [
|
||||
@@ -1281,9 +1299,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cpu-features": {
|
||||
"version": "0.0.8",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.8.tgz",
|
||||
"integrity": "sha512-BbHBvtYhUhksqTjr6bhNOjGgMnhwhGTQmOoZGD+K7BCaQDCuZl/Ve1ZxUSMRwVC4D/rkCPQ2MAIeYzrWyK7eEg==",
|
||||
"version": "0.0.9",
|
||||
"resolved": "https://registry.npmjs.org/cpu-features/-/cpu-features-0.0.9.tgz",
|
||||
"integrity": "sha512-AKjgn2rP2yJyfbepsmLfiYcmtNn/2eUvocUyM/09yB0YDiz39HteK/5/T4Onf0pmdYDMgkBoGvRLvEguzyL7wQ==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"optional": true,
|
||||
@@ -1826,15 +1844,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eslint": {
|
||||
"version": "8.54.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.54.0.tgz",
|
||||
"integrity": "sha512-NY0DfAkM8BIZDVl6PgSa1ttZbx3xHgJzSNJKYcQglem6CppHyMhRIQkBVSSMaSRnLhig3jsDbEzOjwCVt4AmmA==",
|
||||
"version": "8.56.0",
|
||||
"resolved": "https://registry.npmjs.org/eslint/-/eslint-8.56.0.tgz",
|
||||
"integrity": "sha512-Go19xM6T9puCOWntie1/P997aXxFsOi37JIHRWI514Hc6ZnaHGKY9xFhrU65RT6CcBEzZoGG1e6Nq+DT04ZtZQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.2.0",
|
||||
"@eslint-community/regexpp": "^4.6.1",
|
||||
"@eslint/eslintrc": "^2.1.3",
|
||||
"@eslint/js": "8.54.0",
|
||||
"@eslint/eslintrc": "^2.1.4",
|
||||
"@eslint/js": "8.56.0",
|
||||
"@humanwhocodes/config-array": "^0.11.13",
|
||||
"@humanwhocodes/module-importer": "^1.0.1",
|
||||
"@nodelib/fs.walk": "^1.2.8",
|
||||
@@ -2039,9 +2057,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/eta": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/eta/-/eta-3.1.1.tgz",
|
||||
"integrity": "sha512-GVKq8BhYjvGiwKAnvPOnTwAHach3uHglvW0nG9gjEmo8ZIe8HR1aCLdQ97jlxXPcCWhB6E3rDWOk2fahFKG5Cw==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/eta/-/eta-3.2.0.tgz",
|
||||
"integrity": "sha512-Qzc3it7nLn49dbOb9+oHV9rwtt9qN8oShRztqkZ3gXPqQflF0VLin5qhWk0g/2ioibBwT4DU6OIMVft7tg/rVg==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
},
|
||||
@@ -2627,9 +2645,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/globals": {
|
||||
"version": "13.23.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.23.0.tgz",
|
||||
"integrity": "sha512-XAmF0RjlrjY23MA51q3HltdlGxUpXPvg0GioKiD9X6HD28iMjo2dKC8Vqwm7lne4GNr78+RHTfliktR6ZH09wA==",
|
||||
"version": "13.24.0",
|
||||
"resolved": "https://registry.npmjs.org/globals/-/globals-13.24.0.tgz",
|
||||
"integrity": "sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"type-fest": "^0.20.2"
|
||||
@@ -2915,9 +2933,9 @@
|
||||
"license": "BSD-3-Clause"
|
||||
},
|
||||
"node_modules/ignore": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.0.tgz",
|
||||
"integrity": "sha512-g7dmpshy+gD7mh88OC9NwSGTKoc3kyLAZQRU1mt53Aw/vnvfXnbC+F/7F7QoYVKbV+KNvJx8wArewKy1vXMtlg==",
|
||||
"version": "5.3.1",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.1.tgz",
|
||||
"integrity": "sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 4"
|
||||
@@ -3693,9 +3711,10 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/mocha": {
|
||||
"version": "10.2.0",
|
||||
"version": "10.3.0",
|
||||
"resolved": "https://registry.npmjs.org/mocha/-/mocha-10.3.0.tgz",
|
||||
"integrity": "sha512-uF2XJs+7xSLsrmIvn37i/wnc91nw7XjOQB8ccyx5aEgdnohr7n+rEiZP23WkCYHjilR6+EboEnbq/ZQDz4LSbg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"ansi-colors": "4.1.1",
|
||||
"browser-stdout": "1.3.1",
|
||||
@@ -3704,13 +3723,12 @@
|
||||
"diff": "5.0.0",
|
||||
"escape-string-regexp": "4.0.0",
|
||||
"find-up": "5.0.0",
|
||||
"glob": "7.2.0",
|
||||
"glob": "8.1.0",
|
||||
"he": "1.2.0",
|
||||
"js-yaml": "4.1.0",
|
||||
"log-symbols": "4.1.0",
|
||||
"minimatch": "5.0.1",
|
||||
"ms": "2.1.3",
|
||||
"nanoid": "3.3.3",
|
||||
"serialize-javascript": "6.0.0",
|
||||
"strip-json-comments": "3.1.1",
|
||||
"supports-color": "8.1.1",
|
||||
@@ -3725,10 +3743,6 @@
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 14.0.0"
|
||||
},
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/mochajs"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/find-up": {
|
||||
@@ -3747,35 +3761,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/glob": {
|
||||
"version": "7.2.0",
|
||||
"version": "8.1.0",
|
||||
"resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz",
|
||||
"integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"fs.realpath": "^1.0.0",
|
||||
"inflight": "^1.0.4",
|
||||
"inherits": "2",
|
||||
"minimatch": "^3.0.4",
|
||||
"once": "^1.3.0",
|
||||
"path-is-absolute": "^1.0.0"
|
||||
"minimatch": "^5.0.1",
|
||||
"once": "^1.3.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
"node": ">=12"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/glob/node_modules/minimatch": {
|
||||
"version": "3.1.2",
|
||||
"dev": true,
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"brace-expansion": "^1.1.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/mocha/node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"dev": true,
|
||||
@@ -3896,16 +3899,17 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.29.4",
|
||||
"license": "MIT",
|
||||
"version": "2.30.1",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz",
|
||||
"integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/moment-timezone": {
|
||||
"version": "0.5.43",
|
||||
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.43.tgz",
|
||||
"integrity": "sha512-72j3aNyuIsDxdF1i7CEgV2FfxM1r6aaqJyLB2vwb33mXYyoyLly+F1zbWqhA3/bVIoJ4szlUoMbUnVdid32NUQ==",
|
||||
"version": "0.5.45",
|
||||
"resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.45.tgz",
|
||||
"integrity": "sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==",
|
||||
"dependencies": {
|
||||
"moment": "^2.29.4"
|
||||
},
|
||||
@@ -4035,21 +4039,27 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/nan": {
|
||||
"version": "2.17.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.17.0.tgz",
|
||||
"integrity": "sha512-2ZTgtl0nJsO0KQCjEpxcIr5D+Yv90plTitZt9JBfQvVJDS5seMl3FOvsh3+9CoYWXf/1l5OaZzzF6nDm4cagaQ==",
|
||||
"version": "2.18.0",
|
||||
"resolved": "https://registry.npmjs.org/nan/-/nan-2.18.0.tgz",
|
||||
"integrity": "sha512-W7tfG7vMOGtD30sHoZSSc/JVYiyDPEyQVso/Zz+/uQd0B0L46gtC+pHha5FFMRpil6fm/AoEcRWyOVi4+E/f8w==",
|
||||
"dev": true,
|
||||
"optional": true
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.3",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"version": "5.0.5",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.5.tgz",
|
||||
"integrity": "sha512-/Veqm+QKsyMY3kqi4faWplnY1u+VuKO3dD2binyPIybP31DRO29bPF+1mszgLnrR2KqSLceFLBNw0zmvDzN1QQ==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.cjs"
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/natural-compare": {
|
||||
@@ -4073,9 +4083,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nock": {
|
||||
"version": "13.3.8",
|
||||
"resolved": "https://registry.npmjs.org/nock/-/nock-13.3.8.tgz",
|
||||
"integrity": "sha512-96yVFal0c/W1lG7mmfRe7eO+hovrhJYd2obzzOZ90f6fjpeU/XNvd9cYHZKZAQJumDfhXgoTpkpJ9pvMj+hqHw==",
|
||||
"version": "13.5.1",
|
||||
"resolved": "https://registry.npmjs.org/nock/-/nock-13.5.1.tgz",
|
||||
"integrity": "sha512-+s7b73fzj5KnxbKH4Oaqz07tQ8degcMilU4rrmnKvI//b0JMBU4wEXFQ8zqr+3+L4eWSfU3H/UoIVGUV0tue1Q==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"debug": "^4.1.0",
|
||||
@@ -4147,9 +4157,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/nodemailer": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.7.tgz",
|
||||
"integrity": "sha512-rUtR77ksqex/eZRLmQ21LKVH5nAAsVicAtAYudK7JgwenEDZ0UIQ1adUGqErz7sMkWYxWTTU1aeP2Jga6WQyJw==",
|
||||
"version": "6.9.9",
|
||||
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-6.9.9.tgz",
|
||||
"integrity": "sha512-dexTll8zqQoVJEZPwQAKzxxtFn0qTnjdQTchoU6Re9BUUGBJiOy3YMn/0ShTW6J5M0dfQ1NeDeRTTl4oIWgQMA==",
|
||||
"engines": {
|
||||
"node": ">=6.0.0"
|
||||
}
|
||||
@@ -4198,19 +4208,19 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-provider": {
|
||||
"version": "8.4.1",
|
||||
"resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.4.1.tgz",
|
||||
"integrity": "sha512-8pABnyvEOjRkF3GdMDxW1JCO03z2IjP21xSuP0apdOE3zRnae1ObAj8KRBZVj14N7Yhtm75+XkmEy5mIEV3Bhw==",
|
||||
"version": "8.4.5",
|
||||
"resolved": "https://registry.npmjs.org/oidc-provider/-/oidc-provider-8.4.5.tgz",
|
||||
"integrity": "sha512-2NsPrvIAX1W4ZR41cGbz2Lt2Ci8iXvECh+x+LcKcM115s/h8iB1pwnNlCdIrvAA2iBGM4/TkO75Xg7xb2FCzWA==",
|
||||
"dependencies": {
|
||||
"@koa/cors": "^4.0.0",
|
||||
"@koa/cors": "^5.0.0",
|
||||
"@koa/router": "^12.0.1",
|
||||
"debug": "^4.3.4",
|
||||
"eta": "^3.1.1",
|
||||
"eta": "^3.2.0",
|
||||
"got": "^13.0.0",
|
||||
"jose": "^5.0.1",
|
||||
"jose": "^5.1.3",
|
||||
"jsesc": "^3.0.2",
|
||||
"koa": "^2.14.2",
|
||||
"nanoid": "^5.0.2",
|
||||
"nanoid": "^5.0.4",
|
||||
"object-hash": "^3.0.0",
|
||||
"oidc-token-hash": "^5.0.3",
|
||||
"quick-lru": "^7.0.0",
|
||||
@@ -4221,30 +4231,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-provider/node_modules/jose": {
|
||||
"version": "5.1.1",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.1.1.tgz",
|
||||
"integrity": "sha512-bfB+lNxowY49LfrBO0ITUn93JbUhxUN8I11K6oI5hJu/G6PO6fEUddVLjqdD0cQ9SXIHWXuWh7eJYwZF7Z0N/g==",
|
||||
"version": "5.2.1",
|
||||
"resolved": "https://registry.npmjs.org/jose/-/jose-5.2.1.tgz",
|
||||
"integrity": "sha512-qiaQhtQRw6YrOaOj0v59h3R6hUY9NvxBmmnMfKemkqYmBB0tEc97NbLP7ix44VP5p9/0YHG8Vyhzuo5YBNwviA==",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/panva"
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-provider/node_modules/nanoid": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-5.0.3.tgz",
|
||||
"integrity": "sha512-I7X2b22cxA4LIHXPSqbBCEQSL+1wv8TuoefejsX4HFWyC6jc5JG7CEaxOltiKjc1M+YCS2YkrZZcj4+dytw9GA==",
|
||||
"funding": [
|
||||
{
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ai"
|
||||
}
|
||||
],
|
||||
"bin": {
|
||||
"nanoid": "bin/nanoid.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18 || >=20"
|
||||
}
|
||||
},
|
||||
"node_modules/oidc-token-hash": {
|
||||
"version": "5.0.3",
|
||||
"resolved": "https://registry.npmjs.org/oidc-token-hash/-/oidc-token-hash-5.0.3.tgz",
|
||||
@@ -4921,9 +4914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/semver": {
|
||||
"version": "7.5.4",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz",
|
||||
"integrity": "sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==",
|
||||
"version": "7.6.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
|
||||
"integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
|
||||
"dependencies": {
|
||||
"lru-cache": "^6.0.0"
|
||||
},
|
||||
@@ -5106,9 +5099,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2": {
|
||||
"version": "1.14.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.14.0.tgz",
|
||||
"integrity": "sha512-AqzD1UCqit8tbOKoj6ztDDi1ffJZ2rV2SwlgrVVrHPkV5vWqGJOVp5pmtj18PunkPJAuKQsnInyKV+/Nb2bUnA==",
|
||||
"version": "1.15.0",
|
||||
"resolved": "https://registry.npmjs.org/ssh2/-/ssh2-1.15.0.tgz",
|
||||
"integrity": "sha512-C0PHgX4h6lBxYx7hcXwu3QWdh4tg6tZZsTfXcdvc5caW/EMxaB4H9dWsl7qk+F7LAW762hp8VbXOX7x4xUYvEw==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
@@ -5119,8 +5112,8 @@
|
||||
"node": ">=10.16.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"cpu-features": "~0.0.8",
|
||||
"nan": "^2.17.0"
|
||||
"cpu-features": "~0.0.9",
|
||||
"nan": "^2.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ssh2-streams": {
|
||||
@@ -5882,9 +5875,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/ws": {
|
||||
"version": "8.14.2",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.14.2.tgz",
|
||||
"integrity": "sha512-wEBG1ftX4jcglPxgFCMJmZ2PLtSbJ2Peg6TmpJFTbe9GZYOQCDPdMYu/Tm0/bGZkw8paZnJY45J4K2PZrLYq8g==",
|
||||
"version": "8.16.0",
|
||||
"resolved": "https://registry.npmjs.org/ws/-/ws-8.16.0.tgz",
|
||||
"integrity": "sha512-HS0c//TP7Ina87TfiPUz1rQzMhHrl/SG2guqRcTOIUYD2q8uhUdNHZYJUaQ8aTGPzCh+c6oawMKW35nFl1dxyQ==",
|
||||
"engines": {
|
||||
"node": ">=10.0.0"
|
||||
},
|
||||
|
||||
+13
-13
@@ -19,15 +19,15 @@
|
||||
"@google-cloud/dns": "^3.0.2",
|
||||
"@google-cloud/storage": "^6.12.0",
|
||||
"async": "^3.2.5",
|
||||
"aws-sdk": "^2.1502.0",
|
||||
"aws-sdk": "^2.1554.0",
|
||||
"basic-auth": "^2.0.1",
|
||||
"body-parser": "^1.20.2",
|
||||
"cloudron-manifestformat": "^5.21.0",
|
||||
"cloudron-manifestformat": "^5.22.1",
|
||||
"connect": "^3.7.0",
|
||||
"connect-lastmile": "^2.2.0",
|
||||
"connect-timeout": "^1.9.0",
|
||||
"cookie-parser": "^1.4.6",
|
||||
"cookie-session": "^2.0.0",
|
||||
"cookie-session": "^2.1.0",
|
||||
"cron": "^2.4.4",
|
||||
"db-migrate": "^0.11.14",
|
||||
"db-migrate-mysql": "^2.3.2",
|
||||
@@ -41,18 +41,18 @@
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"ldapjs": "^2.3.3",
|
||||
"marked": "^7.0.5",
|
||||
"moment": "^2.29.4",
|
||||
"moment-timezone": "^0.5.43",
|
||||
"moment": "^2.30.1",
|
||||
"moment-timezone": "^0.5.45",
|
||||
"multiparty": "^4.2.3",
|
||||
"mysql": "^2.18.1",
|
||||
"nodemailer": "^6.9.7",
|
||||
"nodemailer": "^6.9.9",
|
||||
"nsyslog-parser": "^0.10.1",
|
||||
"oidc-provider": "^8.4.1",
|
||||
"oidc-provider": "^8.4.5",
|
||||
"ovh": "^2.0.3",
|
||||
"qrcode": "^1.5.3",
|
||||
"readdirp": "^3.6.0",
|
||||
"safetydance": "^2.4.0",
|
||||
"semver": "^7.5.4",
|
||||
"semver": "^7.6.0",
|
||||
"speakeasy": "^2.0.0",
|
||||
"superagent": "^8.1.2",
|
||||
"tar-fs": "github:cloudron-io/tar-fs#ignore_stat_error",
|
||||
@@ -61,20 +61,20 @@
|
||||
"underscore": "^1.13.6",
|
||||
"uuid": "^9.0.1",
|
||||
"validator": "^13.11.0",
|
||||
"ws": "^8.14.2",
|
||||
"ws": "^8.16.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"commander": "^11.1.0",
|
||||
"easy-table": "^1.2.0",
|
||||
"eslint": "^8.54.0",
|
||||
"eslint": "^8.56.0",
|
||||
"expect.js": "*",
|
||||
"hock": "^1.4.1",
|
||||
"js2xmlparser": "^5.0.0",
|
||||
"mocha": "^10.2.0",
|
||||
"mocha": "^10.3.0",
|
||||
"mock-aws-s3": "git+https://github.com/cloudron-io/mock-aws-s3.git",
|
||||
"nock": "^13.3.8",
|
||||
"ssh2": "^1.14.0",
|
||||
"nock": "^13.5.1",
|
||||
"ssh2": "^1.15.0",
|
||||
"yesno": "^0.4.0"
|
||||
},
|
||||
"scripts": {
|
||||
|
||||
@@ -148,6 +148,11 @@ printf "**********************************************************************\n
|
||||
EOF
|
||||
chmod +x /etc/update-motd.d/91-cloudron-install-in-progress
|
||||
|
||||
# workaround netcup setting immutable bit. can be removed in 8.0
|
||||
if lsattr -l /etc/resolv.conf 2>/dev/null | grep -q Immutable; then
|
||||
chattr -i /etc/resolv.conf
|
||||
fi
|
||||
|
||||
# Can only write after we have confirmed script has root access
|
||||
echo "Running cloudron-setup with args : $@" > "${LOG_FILE}"
|
||||
|
||||
|
||||
+113
-14
@@ -41,7 +41,7 @@ function warn() {
|
||||
}
|
||||
|
||||
function fail() {
|
||||
echo -e "[${RED}FAIL${DONE}]\t${1}"
|
||||
echo -e "[${RED}FAIL${DONE}]\t${1}" >&2
|
||||
}
|
||||
|
||||
function enable_remote_access() {
|
||||
@@ -94,7 +94,7 @@ function check_box() {
|
||||
}
|
||||
|
||||
function owner_login() {
|
||||
check_host_mysql
|
||||
check_host_mysql >/dev/null
|
||||
|
||||
local -r owner_username=$(mysql -NB -uroot -ppassword -e "SELECT username FROM box.users WHERE role='owner' AND username IS NOT NULL AND active=1 ORDER BY creationTime LIMIT 1" 2>/dev/null)
|
||||
local -r owner_password=$(pwgen -1s 12)
|
||||
@@ -116,16 +116,25 @@ function send_diagnostics() {
|
||||
echo -e $LINE"Ubuntu"$LINE >> $log
|
||||
lsb_release -a &>> $log
|
||||
|
||||
echo -e $LINE"Dashboard Domain"$LINE >> $log
|
||||
mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" &>> $log 2>/dev/null || true
|
||||
echo -e $LINE"Cloudron"$LINE >> $log
|
||||
cloudron_version=$(cat /home/yellowtent/box/VERSION || true)
|
||||
echo -e "Cloudron version: ${cloudron_version}" >> $log
|
||||
dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null || true)
|
||||
echo -e "Dashboard domain: ${dashboard_domain}" >> $log
|
||||
|
||||
echo -e $LINE"Docker"$LINE >> $log
|
||||
if ! timeout --kill-after 10s 15s docker system info &>> $log 2>&1; then
|
||||
echo -e "Docker (system info) is not responding" >> $log
|
||||
fi
|
||||
|
||||
echo -e $LINE"Docker containers"$LINE >> $log
|
||||
if ! timeout --kill-after 10s 15s docker ps -a &>> $log 2>&1; then
|
||||
echo -e "Docker is not responding" >> $log
|
||||
echo -e "Docker (ps) is not responding" >> $log
|
||||
fi
|
||||
|
||||
echo -e $LINE"Filesystem stats"$LINE >> $log
|
||||
df -h &>> $log
|
||||
if ! timeout --kill-after 10s 15s df -h &>> $log 2>&1; then
|
||||
echo -e "df is not responding" >> $log
|
||||
fi
|
||||
|
||||
echo -e $LINE"Appsdata stats"$LINE >> $log
|
||||
du -hcsL /home/yellowtent/appsdata/* &>> $log || true
|
||||
@@ -181,6 +190,23 @@ function check_unbound() {
|
||||
success "unbound is running"
|
||||
}
|
||||
|
||||
function check_dashboard_cert() {
|
||||
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
|
||||
local -r nginx_conf_file="/home/yellowtent/platformdata/nginx/applications/dashboard/my.${dashboard_domain}.conf"
|
||||
local -r cert_file=$(sed -n -e 's/.*ssl_certificate [[:space:]]\+\(.*\);/\1/p' "${nginx_conf_file}")
|
||||
|
||||
local -r cert_expiry_date=$(openssl x509 -enddate -noout -in "${cert_file}" | sed -e 's/notAfter=//')
|
||||
|
||||
if ! openssl x509 -checkend 100 -noout -in "${cert_file}" >/dev/null 2>&1; then
|
||||
fail "Certificate has expired. Certificate expired at ${cert_expiry_date}"
|
||||
|
||||
local -r task_id=$(mysql -NB -uroot -ppassword -e "SELECT id FROM box.tasks WHERE type='checkCerts' ORDER BY id DESC LIMIT 1" 2>/dev/null)
|
||||
echo -e "\tPlease check /home/yellowtent/platformdata/logs/tasks/${task_id}.log for last cert renewal logs"
|
||||
echo -e "\tCommon issues include expiry of domain's API key OR incoming http port 80 not being open"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function check_nginx() {
|
||||
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
|
||||
|
||||
@@ -199,6 +225,32 @@ function check_nginx() {
|
||||
success "nginx is running"
|
||||
}
|
||||
|
||||
# this confirms that https works properly without any proxy (cloudflare) involved
|
||||
function check_dashboard_site_loopback() {
|
||||
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
|
||||
|
||||
if ! curl --fail -s --resolve "my.${dashboard_domain}:443:127.0.0.1" "https://my.${dashboard_domain}" >/dev/null; then
|
||||
fail "Could not load dashboard website with loopback check"
|
||||
exit 1
|
||||
fi
|
||||
}
|
||||
|
||||
function check_node() {
|
||||
expected_node_version="$(sed -ne 's/readonly node_version=\(.*\)/\1/p' /home/yellowtent/box/scripts/installer.sh)"
|
||||
current_node_version="$(node --version | tr -d '\n' | cut -c2-)" # strip trailing newline and 'v' prefix
|
||||
|
||||
if [[ "${current_node_version}" != "${expected_node_version}" ]]; then
|
||||
fail "node version is incorrect. Expecting ${expected_node_version}. Got ${current_node_version}."
|
||||
echo "You can try the following to fix the problem:"
|
||||
echo " ln -sf /usr/local/node-${expected_node_version}/bin/node /usr/bin/node"
|
||||
echo " ln -sf /usr/local/node-${expected_node_version}/bin/npm /usr/bin/npm"
|
||||
echo " systemctl restart box"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "node version is correct"
|
||||
}
|
||||
|
||||
function check_docker() {
|
||||
if ! systemctl is-active -q docker; then
|
||||
info "Docker is down. Trying to restart docker ..."
|
||||
@@ -213,14 +265,58 @@ function check_docker() {
|
||||
success "docker is running"
|
||||
}
|
||||
|
||||
function check_hairpin_nat() {
|
||||
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
|
||||
if ! curl --fail -s https://my.${dashboard_domain} >/dev/null; then
|
||||
fail "Could not reach dashboard domain. Is Hairpin NAT functional?"
|
||||
function check_node() {
|
||||
expected_node_version="$(sed -ne 's/readonly node_version=\(.*\)/\1/p' /home/yellowtent/box/scripts/installer.sh)"
|
||||
current_node_version="$(node --version | tr -d '\n' | cut -c2-)" # strip trailing newline and 'v' prefix
|
||||
|
||||
if [[ "${current_node_version}" != "${expected_node_version}" ]]; then
|
||||
fail "node version is incorrect. Expecting ${expected_node_version}. Got ${current_node_version}."
|
||||
echo "You can try the following to fix the problem:"
|
||||
echo " ln -sf /usr/local/node-${expected_node_version}/bin/node /usr/bin/node"
|
||||
echo " ln -sf /usr/local/node-${expected_node_version}/bin/npm /usr/bin/npm"
|
||||
echo " systemctl restart box"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Hairpin NAT is good"
|
||||
success "node version is correct"
|
||||
}
|
||||
|
||||
function check_docker() {
|
||||
if ! systemctl is-active -q docker; then
|
||||
info "Docker is down. Trying to restart docker ..."
|
||||
systemctl restart docker
|
||||
|
||||
if ! systemctl is-active -q docker; then
|
||||
fail "Docker is still down, please investigate the error using 'journalctl -u docker'"
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
success "docker is running"
|
||||
}
|
||||
|
||||
function check_dashboard_site_domain() {
|
||||
local -r dashboard_domain=$(mysql -NB -uroot -ppassword -e "SELECT value FROM box.settings WHERE name='dashboard_domain'" 2>/dev/null)
|
||||
local -r domain_provider=$(mysql -NB -uroot -ppassword -e "SELECT provider FROM box.domains WHERE domain='${dashboard_domain}'" 2>/dev/null)
|
||||
|
||||
# TODO: check ipv4 and ipv6
|
||||
if ! output=$(curl --fail -s https://my.${dashboard_domain}); then
|
||||
fail "Could not load dashboard domain."
|
||||
if [[ "${domain_provider}" == "cloudflare" ]]; then
|
||||
echo "Maybe cloudflare proxying is not working. Delete the domain in Cloudflare dashboard and re-add it. This sometimes re-establishes the proxying"
|
||||
else
|
||||
echo "Hairpin NAT is not working. Please check if your router supports it"
|
||||
fi
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! echo $output | grep -q "Cloudron Dashboard"; then
|
||||
fail "https://my.${dashboard_domain} is not the dashboard domain. Check if DNS is set properly to this server"
|
||||
host my.${dashboard_domain} 127.0.0.1 # could also result in cloudflare
|
||||
exit 1
|
||||
fi
|
||||
|
||||
success "Dashboard is reachable via domain name"
|
||||
}
|
||||
|
||||
function check_expired_domain() {
|
||||
@@ -282,12 +378,15 @@ EOF
|
||||
|
||||
function troubleshoot() {
|
||||
# note: disk space test has already been run globally
|
||||
check_nginx
|
||||
check_node
|
||||
check_docker
|
||||
check_host_mysql
|
||||
check_nginx # requires mysql to be checked
|
||||
check_dashboard_site_loopback # checks website via loopback
|
||||
check_box
|
||||
check_unbound
|
||||
check_hairpin_nat # requires mysql to be checked
|
||||
check_dashboard_cert
|
||||
check_dashboard_site_domain # check website via domain name
|
||||
check_expired_domain
|
||||
}
|
||||
|
||||
|
||||
@@ -25,6 +25,12 @@ apt-get -o Dpkg::Options::="--force-confdef" update -y
|
||||
apt-get -o Dpkg::Options::="--force-confdef" upgrade -y
|
||||
apt-mark unhold grub* >/dev/null
|
||||
|
||||
# workaround netcup setting immutable bit. can be removed in 8.0
|
||||
if lsattr -l /etc/resolv.conf 2>/dev/null | grep -q Immutable; then
|
||||
echo "==> Fixing up /etc/resolv.conf"
|
||||
chattr -i /etc/resolv.conf
|
||||
fi
|
||||
|
||||
echo "==> Installing required packages"
|
||||
|
||||
debconf-set-selections <<< 'mysql-server mysql-server/root_password password password'
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
+8
-1
@@ -40,7 +40,14 @@ usermod ${USER} -a -G docker
|
||||
|
||||
if ! grep -q ip6tables /etc/systemd/system/docker.service.d/cloudron.conf; then
|
||||
log "Adding ip6tables flag to docker" # https://github.com/moby/moby/pull/41622
|
||||
echo -e "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd -H fd:// --log-driver=journald --exec-opt native.cgroupdriver=cgroupfs --storage-driver=overlay2 --experimental --ip6tables" > /etc/systemd/system/docker.service.d/cloudron.conf
|
||||
echo -e "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd -H fd:// --log-driver=journald --exec-opt native.cgroupdriver=cgroupfs --storage-driver=overlay2 --experimental --ip6tables --userland-proxy=false" > /etc/systemd/system/docker.service.d/cloudron.conf
|
||||
systemctl daemon-reload
|
||||
systemctl restart docker
|
||||
fi
|
||||
|
||||
if ! grep -q userland-proxy /etc/systemd/system/docker.service.d/cloudron.conf; then
|
||||
log "Adding userland-proxy=false to docker" # https://github.com/moby/moby/pull/41622
|
||||
echo -e "[Service]\nExecStart=\nExecStart=/usr/bin/dockerd -H fd:// --log-driver=journald --exec-opt native.cgroupdriver=cgroupfs --storage-driver=overlay2 --experimental --ip6tables --userland-proxy=false" > /etc/systemd/system/docker.service.d/cloudron.conf
|
||||
systemctl daemon-reload
|
||||
systemctl restart docker
|
||||
fi
|
||||
|
||||
@@ -73,11 +73,11 @@ if ! ipset list cloudron_ldap_allowlist6 >/dev/null 2>&1; then
|
||||
fi
|
||||
ipset flush cloudron_ldap_allowlist6
|
||||
|
||||
ldap_allowlist_json="/home/yellowtent/platformdata/firewall/ldap_allowlist.txt"
|
||||
ldap_allowlist="/home/yellowtent/platformdata/firewall/ldap_allowlist.txt"
|
||||
# delete any existing redirect rule
|
||||
$iptables -t nat -D PREROUTING -p tcp --dport 636 -j REDIRECT --to-ports 3004 2>/dev/null || true
|
||||
$ip6tables -t nat -D PREROUTING -p tcp --dport 636 -j REDIRECT --to-ports 3004 >/dev/null || true
|
||||
if [[ -f "${ldap_allowlist_json}" ]]; then
|
||||
if [[ -f "${ldap_allowlist}" ]]; then
|
||||
# without the -n block, any last line without a new line won't be read it!
|
||||
while read -r line || [[ -n "$line" ]]; do
|
||||
[[ -z "${line}" ]] && continue # ignore empty lines
|
||||
@@ -87,7 +87,7 @@ if [[ -f "${ldap_allowlist_json}" ]]; then
|
||||
else
|
||||
ipset add -! cloudron_ldap_allowlist "${line}" # the -! ignore duplicates
|
||||
fi
|
||||
done < "${ldap_allowlist_json}"
|
||||
done < "${ldap_allowlist}"
|
||||
|
||||
# ldap server we expose 3004 and also redirect from standard ldaps port 636
|
||||
$iptables -t nat -I PREROUTING -p tcp --dport 636 -j REDIRECT --to-ports 3004
|
||||
|
||||
@@ -68,4 +68,7 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/du.sh
|
||||
Defaults!/home/yellowtent/box/src/scripts/hdparm.sh env_keep="HOME BOX_ENV"
|
||||
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/hdparm.sh
|
||||
|
||||
Defaults!/home/yellowtent/box/src/scripts/hdparm.sh env_keep="HOME BOX_ENV"
|
||||
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/logtail.sh
|
||||
|
||||
cloudron-support ALL=(ALL) NOPASSWD: ALL
|
||||
|
||||
+17
-21
@@ -19,8 +19,9 @@ const assert = require('assert'),
|
||||
path = require('path'),
|
||||
paths = require('./paths.js'),
|
||||
promiseRetry = require('./promise-retry.js'),
|
||||
superagent = require('superagent'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('./shell.js'),
|
||||
superagent = require('superagent'),
|
||||
users = require('./users.js');
|
||||
|
||||
const CA_PROD_DIRECTORY_URL = 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
@@ -70,13 +71,12 @@ function b64(str) {
|
||||
return urlBase64Encode(buf.toString('base64'));
|
||||
}
|
||||
|
||||
function getModulus(pem) {
|
||||
async function getModulus(pem) {
|
||||
assert.strictEqual(typeof pem, 'string');
|
||||
|
||||
const stdout = safe.child_process.execSync('openssl rsa -modulus -noout', { input: pem, encoding: 'utf8' });
|
||||
if (!stdout) return null;
|
||||
const stdout = await shell.exec('getModulus', 'openssl rsa -modulus -noout', { input: pem });
|
||||
const match = stdout.match(/Modulus=([0-9a-fA-F]+)$/m);
|
||||
if (!match) return null;
|
||||
if (!match) throw new BoxError(BoxError.OPENSSL_ERROR, 'Could not get modulus');
|
||||
return Buffer.from(match[1], 'hex');
|
||||
}
|
||||
|
||||
@@ -98,7 +98,7 @@ Acme2.prototype.sendSignedRequest = async function (url, payload) {
|
||||
header.jwk = {
|
||||
e: b64(Buffer.from([0x01, 0x00, 0x01])), // exponent - 65537
|
||||
kty: 'RSA',
|
||||
n: b64(getModulus(this.accountKey))
|
||||
n: b64(await getModulus(this.accountKey))
|
||||
};
|
||||
}
|
||||
|
||||
@@ -153,8 +153,7 @@ Acme2.prototype.updateContact = async function (registrationUri) {
|
||||
};
|
||||
|
||||
async function generateAccountKey() {
|
||||
const acmeAccountKey = safe.child_process.execSync('openssl genrsa 4096', { encoding: 'utf8' });
|
||||
if (!acmeAccountKey) throw new BoxError(BoxError.OPENSSL_ERROR, `Could not generate acme account key: ${safe.error.message}`);
|
||||
const acmeAccountKey = await shell.exec('generateAccountKey', 'openssl genrsa 4096', {});
|
||||
return acmeAccountKey;
|
||||
}
|
||||
|
||||
@@ -237,13 +236,13 @@ Acme2.prototype.waitForOrder = async function (orderUrl) {
|
||||
});
|
||||
};
|
||||
|
||||
Acme2.prototype.getKeyAuthorization = function (token) {
|
||||
Acme2.prototype.getKeyAuthorization = async function (token) {
|
||||
assert(typeof this.accountKey, 'string');
|
||||
|
||||
const jwk = {
|
||||
e: b64(Buffer.from([0x01, 0x00, 0x01])), // Exponent - 65537
|
||||
kty: 'RSA',
|
||||
n: b64(getModulus(this.accountKey))
|
||||
n: b64(await getModulus(this.accountKey))
|
||||
};
|
||||
|
||||
const shasum = crypto.createHash('sha256');
|
||||
@@ -257,7 +256,7 @@ Acme2.prototype.notifyChallengeReady = async function (challenge) {
|
||||
|
||||
debug(`notifyChallengeReady: ${challenge.url} was met`);
|
||||
|
||||
const keyAuthorization = this.getKeyAuthorization(challenge.token);
|
||||
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
|
||||
|
||||
const payload = {
|
||||
resource: 'challenge',
|
||||
@@ -295,8 +294,7 @@ Acme2.prototype.signCertificate = async function (finalizationUrl, csrPem) {
|
||||
assert.strictEqual(typeof finalizationUrl, 'string');
|
||||
assert.strictEqual(typeof csrPem, 'string');
|
||||
|
||||
const csrDer = safe.child_process.execSync('openssl req -inform pem -outform der', { input: csrPem });
|
||||
if (!csrDer) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
||||
const csrDer = await shell.exec('signCertificate', 'openssl req -inform pem -outform der', { input: csrPem, encoding: 'buffer' });
|
||||
|
||||
const payload = {
|
||||
csr: b64(csrDer)
|
||||
@@ -317,8 +315,8 @@ Acme2.prototype.ensureKey = async function () {
|
||||
}
|
||||
|
||||
debug(`ensureKey: generating new key for ${this.cn}`);
|
||||
const newKey = safe.child_process.execSync('openssl ecparam -genkey -name secp384r1', { encoding: 'utf8' }); // openssl ecparam -list_curves
|
||||
if (!newKey) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
||||
// same as prime256v1. openssl ecparam -list_curves. we used to use secp384r1 but it doesn't seem to be accepted by few mail servers
|
||||
const newKey = await shell.exec('ensureKey', 'openssl ecparam -genkey -name secp256r1', {});
|
||||
return newKey;
|
||||
};
|
||||
|
||||
@@ -346,9 +344,7 @@ Acme2.prototype.createCsr = async function (key) {
|
||||
if (!safe.fs.writeFileSync(opensslConfigFile, conf)) throw new BoxError(BoxError.FS_ERROR, `Failed to write openssl config: ${safe.error.message}`);
|
||||
|
||||
// while we pass the CN anyways, subjectAltName takes precedence
|
||||
const csrPem = safe.child_process.execSync(`openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, { encoding: 'utf8' });
|
||||
if (!csrPem) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
||||
|
||||
const csrPem = await shell.exec('createCsr', `openssl req -new -key ${keyFilePath} -outform PEM -subj /CN=${this.cn} -config ${opensslConfigFile}`, {});
|
||||
await safe(fs.promises.rm(tmpdir, { recursive: true, force: true }));
|
||||
debug(`createCsr: csr file created for ${this.cn}`);
|
||||
return csrPem; // inspect with openssl req -text -noout -in hostname.csr -inform pem
|
||||
@@ -374,7 +370,7 @@ Acme2.prototype.prepareHttpChallenge = async function (challenge) {
|
||||
|
||||
debug(`prepareHttpChallenge: preparing for challenge ${JSON.stringify(challenge)}`);
|
||||
|
||||
const keyAuthorization = this.getKeyAuthorization(challenge.token);
|
||||
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
|
||||
|
||||
const challengeFilePath = path.join(paths.ACME_CHALLENGES_DIR, challenge.token);
|
||||
debug(`prepareHttpChallenge: writing ${keyAuthorization} to ${challengeFilePath}`);
|
||||
@@ -413,7 +409,7 @@ Acme2.prototype.prepareDnsChallenge = async function (cn, challenge) {
|
||||
|
||||
debug(`prepareDnsChallenge: preparing for challenge: ${JSON.stringify(challenge)}`);
|
||||
|
||||
const keyAuthorization = this.getKeyAuthorization(challenge.token);
|
||||
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
|
||||
const shasum = crypto.createHash('sha256');
|
||||
shasum.update(keyAuthorization);
|
||||
|
||||
@@ -431,7 +427,7 @@ Acme2.prototype.cleanupDnsChallenge = async function (cn, challenge) {
|
||||
assert.strictEqual(typeof cn, 'string');
|
||||
assert.strictEqual(typeof challenge, 'object');
|
||||
|
||||
const keyAuthorization = this.getKeyAuthorization(challenge.token);
|
||||
const keyAuthorization = await this.getKeyAuthorization(challenge.token);
|
||||
const shasum = crypto.createHash('sha256');
|
||||
shasum.update(keyAuthorization);
|
||||
|
||||
|
||||
@@ -98,7 +98,7 @@ async function checkAppHealth(app, options) {
|
||||
if (healthCheckError) {
|
||||
await apps.appendLogLine(app, `=> Healtheck error: ${healthCheckError}`);
|
||||
await setHealth(app, apps.HEALTH_UNHEALTHY);
|
||||
} else if (response.status > 403) { // 2xx and 3xx are ok. even 401 and 403 are ok for now (for WP sites)
|
||||
} else if (response.status > 499) { // 2xx, 3xx and 4xx are ok. maybe 503 can be excluded?
|
||||
await apps.appendLogLine(app, `=> Healtheck error got response status ${response.status}`);
|
||||
await setHealth(app, apps.HEALTH_UNHEALTHY);
|
||||
} else {
|
||||
|
||||
+93
-52
@@ -95,8 +95,8 @@ exports = module.exports = {
|
||||
downloadFile,
|
||||
uploadFile,
|
||||
|
||||
backupConfig,
|
||||
restoreConfig,
|
||||
writeConfig,
|
||||
loadConfig,
|
||||
|
||||
PORT_TYPE_TCP: 'tcp',
|
||||
PORT_TYPE_UDP: 'udp',
|
||||
@@ -133,6 +133,7 @@ exports = module.exports = {
|
||||
HEALTH_DEAD: 'dead',
|
||||
|
||||
// exported for testing
|
||||
_checkForPortBindingConflict: checkForPortBindingConflict,
|
||||
_validatePortBindings: validatePortBindings,
|
||||
_validateAccessRestriction: validateAccessRestriction,
|
||||
_validateUpstreamUri: validateUpstreamUri,
|
||||
@@ -240,11 +241,11 @@ function validatePortBindings(portBindings, manifest) {
|
||||
|
||||
if (!portBindings) return null;
|
||||
|
||||
const tcpPorts = manifest.tcpPorts || { };
|
||||
const udpPorts = manifest.udpPorts || { };
|
||||
const tcpPorts = manifest.tcpPorts || {};
|
||||
const udpPorts = manifest.udpPorts || {};
|
||||
|
||||
for (const portName in portBindings) {
|
||||
if (!/^[a-zA-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in portBindings`);
|
||||
if (!/^[A-Z0-9_]+$/.test(portName)) return new BoxError(BoxError.BAD_FIELD, `${portName} is not a valid environment variable in portBindings`);
|
||||
|
||||
const hostPort = portBindings[portName];
|
||||
if (!Number.isInteger(hostPort)) return new BoxError(BoxError.BAD_FIELD, `${hostPort} is not an integer in ${portName} portBindings`);
|
||||
@@ -272,8 +273,10 @@ function translatePortBindings(portBindings, manifest) {
|
||||
|
||||
for (let portName in portBindings) {
|
||||
const portType = portName in tcpPorts ? exports.PORT_TYPE_TCP : exports.PORT_TYPE_UDP;
|
||||
result[portName] = { hostPort: portBindings[portName], type: portType };
|
||||
const portCount = portBindings[portName].portCount || (portName in tcpPorts ? manifest.tcpPorts[portName].portCount : manifest.udpPorts[portName].portCount);
|
||||
result[portName] = { hostPort: portBindings[portName], type: portType, portCount: portCount || 1 };
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -793,6 +796,39 @@ function accessLevel(app, user) {
|
||||
return canAccess(app, user) ? 'user' : null;
|
||||
}
|
||||
|
||||
async function checkForPortBindingConflict(portBindings, id = '') {
|
||||
assert.strictEqual(typeof portBindings, 'object');
|
||||
assert.strictEqual(typeof id, 'string');
|
||||
|
||||
let existingPortBindings;
|
||||
if (id) existingPortBindings = await database.query('SELECT * FROM appPortBindings WHERE appId != ?', [ id ]);
|
||||
else existingPortBindings = await database.query('SELECT * FROM appPortBindings', []);
|
||||
|
||||
if (existingPortBindings.length === 0) return;
|
||||
|
||||
const tcpPorts = existingPortBindings.filter((p) => p.type === 'tcp');
|
||||
const udpPorts = existingPortBindings.filter((p) => p.type === 'udp');
|
||||
|
||||
for (let portName in portBindings) {
|
||||
const p = portBindings[portName];
|
||||
const testPorts = p.type === 'tcp' ? tcpPorts : udpPorts;
|
||||
|
||||
const found = testPorts.find((e) => {
|
||||
// if one is true we dont have a conflict
|
||||
// a1 <----> a2 b1 <-------> b2
|
||||
// b1 <------> b2 a1 <-----> a2
|
||||
const a2 = (e.hostPort + e.count - 1);
|
||||
const b1 = p.hostPort;
|
||||
const b2 = (p.hostPort + p.portCount -1);
|
||||
const a1 = e.hostPort;
|
||||
|
||||
return !((a2 < b1) || (b2 < a1));
|
||||
});
|
||||
|
||||
if (found) throw new BoxError(BoxError.CONFLICT, `Conflicting port ${p.hostPort}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function add(id, appStoreId, manifest, subdomain, domain, portBindings, data) {
|
||||
assert.strictEqual(typeof id, 'string');
|
||||
assert.strictEqual(typeof appStoreId, 'string');
|
||||
@@ -828,6 +864,8 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
|
||||
enableRedis = 'enableRedis' in data ? data.enableRedis : true,
|
||||
icon = data.icon || null;
|
||||
|
||||
await checkForPortBindingConflict(portBindings);
|
||||
|
||||
const queries = [];
|
||||
|
||||
queries.push({
|
||||
@@ -847,8 +885,8 @@ async function add(id, appStoreId, manifest, subdomain, domain, portBindings, da
|
||||
|
||||
Object.keys(portBindings).forEach(function (env) {
|
||||
queries.push({
|
||||
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, type, appId) VALUES (?, ?, ?, ?)',
|
||||
args: [ env, portBindings[env].hostPort, portBindings[env].type, id ]
|
||||
query: 'INSERT INTO appPortBindings (environmentVariable, hostPort, type, appId, count) VALUES (?, ?, ?, ?, ?)',
|
||||
args: [ env, portBindings[env].hostPort, portBindings[env].type, id, portBindings[env].portCount ]
|
||||
});
|
||||
});
|
||||
|
||||
@@ -912,15 +950,19 @@ async function updateWithConstraints(id, app, constraints) {
|
||||
assert(!('tags' in app) || Array.isArray(app.tags));
|
||||
assert(!('env' in app) || typeof app.env === 'object');
|
||||
|
||||
|
||||
const queries = [ ];
|
||||
|
||||
if ('portBindings' in app) {
|
||||
const portBindings = app.portBindings || { };
|
||||
|
||||
await checkForPortBindingConflict(portBindings, id);
|
||||
|
||||
// replace entries by app id
|
||||
queries.push({ query: 'DELETE FROM appPortBindings WHERE appId = ?', args: [ id ] });
|
||||
Object.keys(portBindings).forEach(function (env) {
|
||||
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id ];
|
||||
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId) VALUES(?, ?, ?, ?)', args: values });
|
||||
const values = [ portBindings[env].hostPort, portBindings[env].type, env, id, portBindings[env].portCount ];
|
||||
queries.push({ query: 'INSERT INTO appPortBindings (hostPort, type, environmentVariable, appId, count) VALUES(?, ?, ?, ?, ?)', args: values });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1285,7 +1327,6 @@ async function install(data, auditSource) {
|
||||
|
||||
const subdomain = data.subdomain.toLowerCase(),
|
||||
domain = data.domain.toLowerCase(),
|
||||
portBindings = data.portBindings || null,
|
||||
accessRestriction = data.accessRestriction || null,
|
||||
memoryLimit = data.memoryLimit || 0,
|
||||
debugMode = data.debugMode || null,
|
||||
@@ -1310,8 +1351,9 @@ async function install(data, auditSource) {
|
||||
error = checkManifestConstraints(manifest);
|
||||
if (error) throw error;
|
||||
|
||||
error = validatePortBindings(portBindings, manifest);
|
||||
error = validatePortBindings(data.portBindings || null, manifest);
|
||||
if (error) throw error;
|
||||
const portBindings = translatePortBindings(data.portBindings || null, manifest);
|
||||
|
||||
error = validateAccessRestriction(accessRestriction);
|
||||
if (error) throw error;
|
||||
@@ -1393,7 +1435,7 @@ async function install(data, auditSource) {
|
||||
installationState: exports.ISTATE_PENDING_INSTALL
|
||||
};
|
||||
|
||||
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), app));
|
||||
const [addError] = await safe(add(appId, appStoreId, manifest, subdomain, domain, portBindings, app));
|
||||
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
|
||||
if (addError) throw addError;
|
||||
|
||||
@@ -1818,10 +1860,7 @@ async function setCertificate(app, data, auditSource) {
|
||||
const domainObject = await domains.get(domain);
|
||||
if (domainObject === null) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
|
||||
|
||||
if (cert && key) {
|
||||
const error = reverseProxy.validateCertificate(subdomain, domain, { cert, key });
|
||||
if (error) throw error;
|
||||
}
|
||||
if (cert && key) await reverseProxy.validateCertificate(subdomain, domain, { cert, key });
|
||||
|
||||
const certificate = cert && key ? { cert, key } : null;
|
||||
const result = await database.query('UPDATE locations SET certificateJson=? WHERE location=? AND domain=?', [ certificate ? JSON.stringify(certificate) : null, subdomain, domain ]);
|
||||
@@ -1852,9 +1891,8 @@ async function setLocation(app, data, auditSource) {
|
||||
};
|
||||
|
||||
if ('portBindings' in data) {
|
||||
error = validatePortBindings(data.portBindings, app.manifest);
|
||||
error = validatePortBindings(data.portBindings || null, app.manifest);
|
||||
if (error) throw error;
|
||||
|
||||
values.portBindings = translatePortBindings(data.portBindings || null, app.manifest);
|
||||
}
|
||||
|
||||
@@ -1996,6 +2034,11 @@ async function updateApp(app, data, auditSource) {
|
||||
values.mailboxDomain = app.domain;
|
||||
}
|
||||
|
||||
if (!manifest.addons?.recvmail) { // clear if the update removed addon. required for fk constraint
|
||||
values.enableInbox = false;
|
||||
values.inboxName = values.inboxDomain = null;
|
||||
}
|
||||
|
||||
const hasSso = !!updateConfig.manifest.addons?.proxyAuth || !!updateConfig.manifest.addons?.ldap || !!manifest.addons?.oidc;
|
||||
if (!hasSso && app.sso) values.sso = false; // turn off sso flag, if the update removes sso options
|
||||
|
||||
@@ -2053,7 +2096,7 @@ async function getLogs(app, options) {
|
||||
const cp = logs.tail(logPaths, { lines: options.lines, follow: options.follow });
|
||||
|
||||
const logStream = new logs.LogStream({ format: options.format || 'json', source: appId });
|
||||
logStream.close = cp.kill.bind(cp, 'SIGKILL'); // hook for caller. closing stream kills the child process
|
||||
logStream.on('close', () => cp.terminate()); // the caller has to call destroy() on logStream. destroy() of Transform emits 'close'
|
||||
|
||||
cp.stdout.pipe(logStream);
|
||||
|
||||
@@ -2134,22 +2177,28 @@ async function restore(app, backupId, auditSource) {
|
||||
|
||||
// for empty or null backupId, use existing manifest to mimic a reinstall
|
||||
const backupInfo = backupId ? await backups.get(backupId) : { manifest: app.manifest };
|
||||
const manifest = backupInfo.manifest;
|
||||
|
||||
if (!backupInfo.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not get restore manifest');
|
||||
if (!manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not get restore manifest');
|
||||
if (backupInfo.encryptionVersion === 1) throw new BoxError(BoxError.BAD_FIELD, 'This encrypted backup was created with an older Cloudron version and has to be restored using the CLI tool');
|
||||
|
||||
// re-validate because this new box version may not accept old configs
|
||||
error = checkManifestConstraints(backupInfo.manifest);
|
||||
error = checkManifestConstraints(manifest);
|
||||
if (error) throw error;
|
||||
|
||||
let values = { manifest: backupInfo.manifest };
|
||||
if (!backupInfo.manifest.addons?.sendmail) { // clear if restore removed addon
|
||||
const values = { manifest };
|
||||
if (!manifest.addons?.sendmail) { // clear if restore removed addon
|
||||
values.mailboxName = values.mailboxDomain = null;
|
||||
} else if (!app.mailboxName || app.mailboxName.endsWith('.app')) { // allocate since restore added the addon
|
||||
values.mailboxName = mailboxNameForSubdomain(app.subdomain, backupInfo.manifest);
|
||||
values.mailboxName = mailboxNameForSubdomain(app.subdomain, manifest);
|
||||
values.mailboxDomain = app.domain;
|
||||
}
|
||||
|
||||
if (!manifest.addons?.recvmail) { // recvmail is always optional. clear if restore removed addon
|
||||
values.enableInbox = false;
|
||||
values.inboxName = values.inboxDomain = null;
|
||||
}
|
||||
|
||||
const restoreConfig = { remotePath: backupInfo.remotePath, backupFormat: backupInfo.format };
|
||||
|
||||
const task = {
|
||||
@@ -2164,7 +2213,7 @@ async function restore(app, backupId, auditSource) {
|
||||
|
||||
const taskId = await addTask(appId, exports.ISTATE_PENDING_RESTORE, task, auditSource);
|
||||
|
||||
await eventlog.add(eventlog.ACTION_APP_RESTORE, auditSource, { app, backupId: backupInfo.id, remotePath: backupInfo.remotePath, fromManifest: app.manifest, toManifest: backupInfo.manifest, taskId });
|
||||
await eventlog.add(eventlog.ACTION_APP_RESTORE, auditSource, { app, backupId: backupInfo.id, remotePath: backupInfo.remotePath, fromManifest: app.manifest, toManifest: manifest, taskId });
|
||||
|
||||
return { taskId };
|
||||
}
|
||||
@@ -2258,21 +2307,18 @@ async function clone(app, data, user, auditSource) {
|
||||
|
||||
const subdomain = data.subdomain.toLowerCase(),
|
||||
domain = data.domain.toLowerCase(),
|
||||
portBindings = data.portBindings || null,
|
||||
backupId = data.backupId,
|
||||
overwriteDns = 'overwriteDns' in data ? data.overwriteDns : false,
|
||||
skipDnsSetup = 'skipDnsSetup' in data ? data.skipDnsSetup : false,
|
||||
appId = app.id;
|
||||
skipDnsSetup = 'skipDnsSetup' in data ? data.skipDnsSetup : false;
|
||||
|
||||
assert.strictEqual(typeof backupId, 'string');
|
||||
assert.strictEqual(typeof subdomain, 'string');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
assert.strictEqual(typeof portBindings, 'object');
|
||||
|
||||
const backupInfo = await backups.get(backupId);
|
||||
|
||||
if (!backupInfo) throw new BoxError(BoxError.NOT_FOUND, 'Backup not found');
|
||||
if (!backupInfo.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not get restore config');
|
||||
if (!backupInfo.manifest) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Could not detect restore manifest');
|
||||
if (backupInfo.encryptionVersion === 1) throw new BoxError(BoxError.BAD_FIELD, 'This encrypted backup was created with an older Cloudron version and cannot be cloned');
|
||||
|
||||
const manifest = backupInfo.manifest, appStoreId = app.appStoreId;
|
||||
@@ -2291,8 +2337,9 @@ async function clone(app, data, user, auditSource) {
|
||||
error = checkManifestConstraints(manifest);
|
||||
if (error) throw error;
|
||||
|
||||
error = validatePortBindings(portBindings, manifest);
|
||||
error = validatePortBindings(data.portBindings || null, manifest);
|
||||
if (error) throw error;
|
||||
const portBindings = translatePortBindings(data.portBindings || null, manifest);
|
||||
|
||||
// should we copy the original app's mailbox settings instead?
|
||||
const mailboxName = manifest.addons?.sendmail ? mailboxNameForSubdomain(subdomain, manifest) : null;
|
||||
@@ -2302,31 +2349,25 @@ async function clone(app, data, user, auditSource) {
|
||||
|
||||
const icons = await getIcons(app.id);
|
||||
|
||||
const obj = {
|
||||
const dolly = _.pick(app, 'memoryLimit', 'cpuShares', 'crontab', 'reverseProxyConfig', 'env', 'servicesConfig', 'tags',
|
||||
'enableMailbox', 'mailboxDisplayName', 'mailboxName', 'mailboxDomain', 'enableInbox', 'inboxName', 'inboxDomain',
|
||||
'enableTurn', 'enableRedis', 'mounts', 'enableBackup', 'enableAutomaticUpdate', 'accessRestriction', 'operators', 'sso');
|
||||
|
||||
if (!manifest.addons?.recvmail) dolly.inboxDomain = null; // needed because we are cloning _current_ app settings with old manifest
|
||||
|
||||
const obj = Object.assign(dolly, {
|
||||
installationState: exports.ISTATE_PENDING_CLONE,
|
||||
runState: exports.RSTATE_RUNNING,
|
||||
memoryLimit: app.memoryLimit,
|
||||
cpuShares: app.cpuShares,
|
||||
accessRestriction: app.accessRestriction,
|
||||
sso: !!app.sso,
|
||||
mailboxName,
|
||||
mailboxDomain,
|
||||
enableBackup: app.enableBackup,
|
||||
reverseProxyConfig: app.reverseProxyConfig,
|
||||
env: app.env,
|
||||
secondaryDomains,
|
||||
redirectDomains: [],
|
||||
aliasDomains: [],
|
||||
servicesConfig: app.servicesConfig,
|
||||
label: app.label ? `${app.label}-clone` : '',
|
||||
tags: app.tags,
|
||||
enableAutomaticUpdate: app.enableAutomaticUpdate,
|
||||
icon: icons.icon,
|
||||
enableMailbox: app.enableMailbox,
|
||||
mailboxDisplayName: app.mailboxDisplayName
|
||||
};
|
||||
});
|
||||
|
||||
const [addError] = await safe(add(newAppId, appStoreId, manifest, subdomain, domain, translatePortBindings(portBindings, manifest), obj));
|
||||
const [addError] = await safe(add(newAppId, appStoreId, manifest, subdomain, domain, portBindings, obj));
|
||||
if (addError && addError.reason === BoxError.ALREADY_EXISTS) throw getDuplicateErrorDetails(addError.message, locations, portBindings);
|
||||
if (addError) throw addError;
|
||||
|
||||
@@ -2346,7 +2387,7 @@ async function clone(app, data, user, auditSource) {
|
||||
newApp.redirectDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
newApp.aliasDomains.forEach(function (ad) { ad.fqdn = dns.fqdn(ad.subdomain, ad.domain); });
|
||||
|
||||
await eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: appId, backupId, remotePath: backupInfo.remotePath, oldApp: app, newApp, taskId });
|
||||
await eventlog.add(eventlog.ACTION_APP_CLONE, auditSource, { appId: newAppId, oldAppId: app.id, backupId, remotePath: backupInfo.remotePath, oldApp: app, newApp, taskId });
|
||||
|
||||
return { id: newAppId, taskId };
|
||||
}
|
||||
@@ -2835,7 +2876,7 @@ async function uploadFile(app, sourceFilePath, destFilePath) {
|
||||
|
||||
// the built-in bash printf understands "%q" but not /usr/bin/printf.
|
||||
// ' gets replaced with '\'' . the first closes the quote and last one starts a new one
|
||||
const escapedDestFilePath = safe.child_process.execSync(`printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { shell: '/bin/bash', encoding: 'utf8' });
|
||||
const escapedDestFilePath = await shell.exec('uploadFile', `printf %q '${destFilePath.replace(/'/g, '\'\\\'\'')}'`, { shell: '/bin/bash' });
|
||||
debug(`uploadFile: ${sourceFilePath} -> ${escapedDestFilePath}`);
|
||||
|
||||
const execId = await createExec(app, { cmd: [ 'bash', '-c', `cat - > ${escapedDestFilePath}` ], tty: false });
|
||||
@@ -2854,10 +2895,10 @@ async function uploadFile(app, sourceFilePath, destFilePath) {
|
||||
});
|
||||
}
|
||||
|
||||
async function backupConfig(app) {
|
||||
async function writeConfig(app) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
|
||||
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(app))) {
|
||||
if (!safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json'), JSON.stringify(app, null, 4))) {
|
||||
throw new BoxError(BoxError.FS_ERROR, 'Error creating config.json: ' + safe.error.message);
|
||||
}
|
||||
|
||||
@@ -2865,7 +2906,7 @@ async function backupConfig(app) {
|
||||
if (!error && icons.icon) safe.fs.writeFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/icon.png'), icons.icon);
|
||||
}
|
||||
|
||||
async function restoreConfig(app) {
|
||||
async function loadConfig(app) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
|
||||
const appConfig = safe.JSON.parse(safe.fs.readFileSync(path.join(paths.APPS_DATA_DIR, app.id + '/config.json')));
|
||||
|
||||
+8
-11
@@ -47,6 +47,7 @@ const apps = require('./apps.js'),
|
||||
safe = require('safetydance'),
|
||||
semver = require('semver'),
|
||||
settings = require('./settings.js'),
|
||||
shell = require('./shell.js'),
|
||||
superagent = require('superagent'),
|
||||
support = require('./support.js');
|
||||
|
||||
@@ -297,7 +298,6 @@ async function registerCloudron(data) {
|
||||
if (response.status === 401) throw new BoxError(BoxError.LICENSE_ERROR, 'Setup token invalid');
|
||||
if (response.status !== 201) throw new BoxError(BoxError.EXTERNAL_ERROR, `Unable to register cloudron: ${response.statusCode} ${error.message}`);
|
||||
|
||||
// cloudronId, token
|
||||
if (!response.body.cloudronId) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no cloudron id');
|
||||
if (!response.body.cloudronToken) throw new BoxError(BoxError.EXTERNAL_ERROR, 'Invalid response - no token');
|
||||
|
||||
@@ -305,6 +305,11 @@ async function registerCloudron(data) {
|
||||
await settings.set(settings.APPSTORE_API_TOKEN_KEY, response.body.cloudronToken);
|
||||
|
||||
debug(`registerCloudron: Cloudron registered with id ${response.body.cloudronId}`);
|
||||
|
||||
// app could already have been installed if we deleted the cloudron.io record and user re-registers
|
||||
for (const app of await apps.list()) {
|
||||
await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' });
|
||||
}
|
||||
}
|
||||
|
||||
async function updateCloudron(data) {
|
||||
@@ -338,10 +343,6 @@ async function registerCloudronWithSetupToken(options) {
|
||||
const { domain } = await dashboard.getLocation();
|
||||
|
||||
await registerCloudron({ domain, setupToken: options.setupToken, version: constants.VERSION });
|
||||
|
||||
for (const app of await apps.list()) {
|
||||
await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' });
|
||||
}
|
||||
}
|
||||
|
||||
async function registerCloudronWithLogin(options) {
|
||||
@@ -353,10 +354,6 @@ async function registerCloudronWithLogin(options) {
|
||||
const { domain } = await dashboard.getLocation();
|
||||
|
||||
await registerCloudron({ domain, accessToken: result.accessToken, version: constants.VERSION });
|
||||
|
||||
for (const app of await apps.list()) {
|
||||
await purchaseApp({ appId: app.id, appstoreId: app.appStoreId, manifestId: app.manifest.id || 'customapp' });
|
||||
}
|
||||
}
|
||||
|
||||
async function unregister() {
|
||||
@@ -395,8 +392,8 @@ async function createTicket(info, auditSource) {
|
||||
|
||||
const logPaths = await apps.getLogPaths(info.app);
|
||||
for (const logPath of logPaths) {
|
||||
const logs = safe.child_process.execSync(`tail --lines=1000 ${logPath}`);
|
||||
if (logs) request.attach(path.basename(logPath), logs, path.basename(logPath));
|
||||
const [error, logs] = await safe(shell.exec('createTicket', `tail --lines=1000 ${logPath}`, {}));
|
||||
if (!error && logs) request.attach(path.basename(logPath), logs, path.basename(logPath));
|
||||
}
|
||||
} else {
|
||||
request.send(info);
|
||||
|
||||
+3
-2
@@ -24,6 +24,7 @@ const apps = require('./apps.js'),
|
||||
dns = require('./dns.js'),
|
||||
docker = require('./docker.js'),
|
||||
ejs = require('ejs'),
|
||||
execSync = require('child_process').execSync,
|
||||
fs = require('fs'),
|
||||
iputils = require('./iputils.js'),
|
||||
manifestFormat = require('cloudron-manifestformat'),
|
||||
@@ -313,7 +314,7 @@ async function install(app, args, progressCallback) {
|
||||
await progressCallback({ percent: 60, message: 'Importing addons in-place' });
|
||||
await services.setupAddons(app, app.manifest.addons);
|
||||
await services.clearAddons(app, _.omit(app.manifest.addons, 'localstorage'));
|
||||
await apps.restoreConfig(app);
|
||||
await apps.loadConfig(app);
|
||||
await services.restoreAddons(app, app.manifest.addons);
|
||||
} else if (app.installationState === apps.ISTATE_PENDING_IMPORT) { // import
|
||||
await progressCallback({ percent: 65, message: 'Downloading backup and restoring addons' });
|
||||
@@ -324,7 +325,7 @@ async function install(app, args, progressCallback) {
|
||||
if (mountObject) await progressCallback({ percent: 70, message: 'Setting up mount for importing' });
|
||||
backupConfig.rootPath = backups.getRootPath(backupConfig, `/mnt/appimport-${app.id}`);
|
||||
await backuptask.downloadApp(app, restoreConfig, (progress) => { progressCallback({ percent: 75, message: progress.message }); });
|
||||
await apps.restoreConfig(app);
|
||||
await apps.loadConfig(app);
|
||||
if (mountObject) await mounts.removeMount(mountObject);
|
||||
await progressCallback({ percent: 75, message: 'Restoring addons' });
|
||||
await services.restoreAddons(app, app.manifest.addons);
|
||||
|
||||
@@ -22,6 +22,7 @@ const assert = require('assert'),
|
||||
ProgressStream = require('../progress-stream.js'),
|
||||
promiseRetry = require('../promise-retry.js'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('../shell.js'),
|
||||
storage = require('../storage.js'),
|
||||
stream = require('stream'),
|
||||
syncer = require('../syncer.js'),
|
||||
@@ -109,17 +110,14 @@ async function saveFsMetadata(dataLayout, metadataFile) {
|
||||
|
||||
// we assume small number of files. spawnSync will raise a ENOBUFS error after maxBuffer
|
||||
for (let lp of dataLayout.localPaths()) {
|
||||
const emptyDirs = safe.child_process.execSync(`find ${lp} -type d -empty`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
|
||||
if (emptyDirs === null) throw new BoxError(BoxError.FS_ERROR, `Error finding empty dirs: ${safe.error.message}`);
|
||||
const emptyDirs = await shell.exec('saveFsMetadata', `find ${lp} -type d -empty`, { maxBuffer: 1024 * 1024 * 80 });
|
||||
if (emptyDirs.length) metadata.emptyDirs = metadata.emptyDirs.concat(emptyDirs.trim().split('\n').map((ed) => dataLayout.toRemotePath(ed)));
|
||||
|
||||
const execFiles = safe.child_process.execSync(`find ${lp} -type f -executable`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
|
||||
if (execFiles === null) throw new BoxError(BoxError.FS_ERROR, `Error finding executables: ${safe.error.message}`);
|
||||
const execFiles = await shell.exec('saveFsMetadata', `find ${lp} -type f -executable`, { maxBuffer: 1024 * 1024 * 80 });
|
||||
if (execFiles.length) metadata.execFiles = metadata.execFiles.concat(execFiles.trim().split('\n').map((ef) => dataLayout.toRemotePath(ef)));
|
||||
|
||||
const symlinks = safe.child_process.execSync(`find ${lp} -type l`, { encoding: 'utf8', maxBuffer: 1024 * 1024 * 30 });
|
||||
if (symlinks === null) throw new BoxError(BoxError.FS_ERROR, `Error finding symlinks: ${safe.error.message}`);
|
||||
if (symlinks.length) metadata.symlinks = metadata.symlinks.concat(symlinks.trim().split('\n').map((sl) => {
|
||||
const symlinkFiles = await shell.exec('safeFsMetadata', `find ${lp} -type l`, { maxBuffer: 1024 * 1024 * 30 });
|
||||
if (symlinkFiles.length) metadata.symlinks = metadata.symlinks.concat(symlinkFiles.trim().split('\n').map((sl) => {
|
||||
const target = safe.fs.readlinkSync(sl);
|
||||
return { path: dataLayout.toRemotePath(sl), target };
|
||||
}));
|
||||
|
||||
+1
-1
@@ -244,7 +244,7 @@ async function startBackupTask(auditSource) {
|
||||
|
||||
const backupConfig = await getConfig();
|
||||
|
||||
const memoryLimit = backupConfig.limits?.memoryLimit ? Math.max(backupConfig.limits.memoryLimit/1024/1024, 800) : 800;
|
||||
const memoryLimit = backupConfig.limits?.memoryLimit ? Math.max(backupConfig.limits.memoryLimit/1024/1024, 1024) : 1024;
|
||||
|
||||
const taskId = await tasks.add(tasks.TASK_BACKUP, [ { /* options */ } ]);
|
||||
|
||||
|
||||
+2
-3
@@ -60,8 +60,7 @@ async function checkPreconditions(backupConfig, dataLayout) {
|
||||
let used = 0;
|
||||
for (const localPath of dataLayout.localPaths()) {
|
||||
debug(`checkPreconditions: getting disk usage of ${localPath}`);
|
||||
const result = safe.child_process.execSync(`du -Dsb --exclude='*.lock' --exclude='dovecot.list.index.log.*' "${localPath}"`, { encoding: 'utf8' });
|
||||
if (!result) throw new BoxError(BoxError.FS_ERROR, `du error: ${safe.error.message}`);
|
||||
const result = await shell.execArgs('checkPreconditions', 'du', [ '-Dsb', '--exclude=*.lock', '--exclude=dovecot.list.index.log.*', localPath], {});
|
||||
used += parseInt(result, 10);
|
||||
}
|
||||
|
||||
@@ -328,7 +327,7 @@ async function snapshotApp(app, progressCallback) {
|
||||
const startTime = new Date();
|
||||
progressCallback({ message: `Snapshotting app ${app.fqdn}` });
|
||||
|
||||
await apps.backupConfig(app);
|
||||
await apps.writeConfig(app);
|
||||
await services.backupAddons(app, app.manifest.addons);
|
||||
|
||||
debug(`snapshotApp: ${app.fqdn} took ${(new Date() - startTime)/1000} seconds`);
|
||||
|
||||
+1
-1
@@ -63,7 +63,7 @@ BoxError.NOT_SIGNED = 'Not Signed';
|
||||
BoxError.NOT_SUPPORTED = 'Not Supported';
|
||||
BoxError.OPENSSL_ERROR = 'OpenSSL Error';
|
||||
BoxError.PLAN_LIMIT = 'Plan Limit';
|
||||
BoxError.SPAWN_ERROR = 'Spawn Error';
|
||||
BoxError.SHELL_ERROR = 'Shell Error'; // exec or spawn cmd failed
|
||||
BoxError.TASK_ERROR = 'Task Error';
|
||||
BoxError.TIMEOUT = 'Timeout';
|
||||
BoxError.TRY_AGAIN = 'Try Again';
|
||||
|
||||
+33
-12
@@ -15,6 +15,7 @@ exports = module.exports = {
|
||||
handleTimeZoneChanged,
|
||||
handleAutoupdatePatternChanged,
|
||||
handleDynamicDnsChanged,
|
||||
handleExternalLdapChanged,
|
||||
|
||||
DEFAULT_AUTOUPDATE_PATTERN,
|
||||
};
|
||||
@@ -29,6 +30,7 @@ const appHealthMonitor = require('./apphealthmonitor.js'),
|
||||
CronJob = require('cron').CronJob,
|
||||
debug = require('debug')('box:cron'),
|
||||
dyndns = require('./dyndns.js'),
|
||||
externalLdap = require('./externalldap.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
janitor = require('./janitor.js'),
|
||||
mail = require('./mail.js'),
|
||||
@@ -57,7 +59,8 @@ const gJobs = {
|
||||
dynamicDns: null,
|
||||
schedulerSync: null,
|
||||
appHealthMonitor: null,
|
||||
diskUsage: null
|
||||
diskUsage: null,
|
||||
externalLdapSyncer: null
|
||||
};
|
||||
|
||||
// cron format
|
||||
@@ -173,6 +176,7 @@ async function startJobs() {
|
||||
await handleBackupPolicyChanged(await backups.getPolicy());
|
||||
await handleAutoupdatePatternChanged(await updater.getAutoupdatePattern());
|
||||
await handleDynamicDnsChanged(await network.getDynamicDns());
|
||||
await handleExternalLdapChanged(await externalLdap.getConfig());
|
||||
}
|
||||
|
||||
async function handleBackupPolicyChanged(value) {
|
||||
@@ -183,6 +187,7 @@ async function handleBackupPolicyChanged(value) {
|
||||
debug(`backupPolicyChanged: schedule ${value.schedule} (${tz})`);
|
||||
|
||||
if (gJobs.backup) gJobs.backup.stop();
|
||||
gJobs.backup = null;
|
||||
|
||||
gJobs.backup = new CronJob({
|
||||
cronTime: value.schedule,
|
||||
@@ -208,6 +213,7 @@ async function handleAutoupdatePatternChanged(pattern) {
|
||||
debug(`autoupdatePatternChanged: pattern - ${pattern} (${tz})`);
|
||||
|
||||
if (gJobs.autoUpdater) gJobs.autoUpdater.stop();
|
||||
gJobs.autoUpdater = null;
|
||||
|
||||
if (pattern === constants.AUTOUPDATE_PATTERN_NEVER) return;
|
||||
|
||||
@@ -244,17 +250,32 @@ function handleDynamicDnsChanged(enabled) {
|
||||
|
||||
debug('Dynamic DNS setting changed to %s', enabled);
|
||||
|
||||
if (enabled) {
|
||||
gJobs.dynamicDns = new CronJob({
|
||||
// until we can be smarter about actual IP changes, lets ensure it every 10minutes
|
||||
cronTime: '00 */10 * * * *',
|
||||
onTick: async () => { await safe(dyndns.refreshDns(AuditSource.CRON), { debug }); },
|
||||
start: true
|
||||
});
|
||||
} else {
|
||||
if (gJobs.dynamicDns) gJobs.dynamicDns.stop();
|
||||
gJobs.dynamicDns = null;
|
||||
}
|
||||
if (gJobs.dynamicDns) gJobs.dynamicDns.stop();
|
||||
gJobs.dynamicDns = null;
|
||||
|
||||
if (!enabled) return;
|
||||
|
||||
gJobs.dynamicDns = new CronJob({
|
||||
// until we can be smarter about actual IP changes, lets ensure it every 10minutes
|
||||
cronTime: '00 */10 * * * *',
|
||||
onTick: async () => { await safe(dyndns.refreshDns(AuditSource.CRON), { debug }); },
|
||||
start: true
|
||||
});
|
||||
}
|
||||
|
||||
async function handleExternalLdapChanged(config) {
|
||||
assert.strictEqual(typeof config, 'object');
|
||||
|
||||
if (gJobs.externalLdapSyncer) gJobs.externalLdapSyncer.stop();
|
||||
gJobs.externalLdapSyncer = null;
|
||||
|
||||
if (config.provider === 'noop') return;
|
||||
|
||||
gJobs.externalLdapSyncer = new CronJob({
|
||||
cronTime: '00 00 */4 * * *', // every 4 hours
|
||||
onTick: async () => await safe(externalLdap.startSyncer(AuditSource.CRON), { debug }),
|
||||
start: true
|
||||
});
|
||||
}
|
||||
|
||||
async function stopJobs() {
|
||||
|
||||
+8
-3
@@ -21,8 +21,9 @@ const apps = require('./apps.js'),
|
||||
branding = require('./branding.js'),
|
||||
constants = require('./constants.js'),
|
||||
debug = require('debug')('box:dashboard'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
dns = require('./dns.js'),
|
||||
externalLdap = require('./externalldap.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
Location = require('./location.js'),
|
||||
mailServer = require('./mailserver.js'),
|
||||
platform = require('./platform.js'),
|
||||
@@ -56,6 +57,8 @@ async function clearLocation() {
|
||||
async function getConfig() {
|
||||
const ubuntuVersion = await system.getUbuntuVersion();
|
||||
const profileConfig = await users.getProfileConfig();
|
||||
const externalLdapConfig = await externalLdap.getConfig();
|
||||
|
||||
const { fqdn:adminFqdn, domain:adminDomain } = await getLocation();
|
||||
|
||||
// be picky about what we send out here since this is sent for 'normal' users as well
|
||||
@@ -74,6 +77,8 @@ async function getConfig() {
|
||||
features: appstore.getFeatures(),
|
||||
profileLocked: profileConfig.lockUserProfiles,
|
||||
mandatory2FA: profileConfig.mandatory2FA,
|
||||
external2FA: externalLdap.supports2FA(externalLdapConfig),
|
||||
ldapGroupsSynced: externalLdapConfig.syncGroups
|
||||
};
|
||||
}
|
||||
|
||||
@@ -83,7 +88,7 @@ async function startPrepareLocation(domain, auditSource) {
|
||||
|
||||
debug(`prepareLocation: ${domain}`);
|
||||
|
||||
if (constants.DEMO) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
const fqdn = dns.fqdn(constants.DASHBOARD_SUBDOMAIN, domain);
|
||||
const result = await apps.list();
|
||||
@@ -118,7 +123,7 @@ async function setupLocation(subdomain, domain, auditSource) {
|
||||
|
||||
debug(`setupLocation: ${domain}`);
|
||||
|
||||
if (constants.DEMO) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
await reverseProxy.writeDashboardConfig(domain);
|
||||
await setLocation(subdomain, domain);
|
||||
|
||||
+4
-5
@@ -78,7 +78,7 @@ async function clear() {
|
||||
await fs.promises.writeFile('/tmp/extra.cnf', `[client]\nhost=${gDatabase.hostname}\nuser=${gDatabase.username}\npassword=${gDatabase.password}\ndatabase=${gDatabase.name}`, 'utf8');
|
||||
|
||||
const cmd = 'mysql --defaults-extra-file=/tmp/extra.cnf -Nse "SHOW TABLES" | grep -v "^migrations$" | while read table; do mysql --defaults-extra-file=/tmp/extra.cnf -e "SET FOREIGN_KEY_CHECKS = 0; TRUNCATE TABLE $table"; done';
|
||||
await shell.promises.exec('clear_database', cmd);
|
||||
await shell.exec('clear_database', cmd, { shell: '/bin/bash' });
|
||||
}
|
||||
|
||||
async function query() {
|
||||
@@ -136,7 +136,7 @@ async function importFromFile(file) {
|
||||
const cmd = `/usr/bin/mysql -h "${gDatabase.hostname}" -u ${gDatabase.username} -p${gDatabase.password} ${gDatabase.name} < ${file}`;
|
||||
|
||||
await query('CREATE DATABASE IF NOT EXISTS box');
|
||||
const [error] = await safe(shell.promises.exec('importFromFile', cmd));
|
||||
const [error] = await safe(shell.exec('importFromFile', cmd, { shell: '/bin/bash' }));
|
||||
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
|
||||
}
|
||||
|
||||
@@ -144,13 +144,12 @@ async function exportToFile(file) {
|
||||
assert.strictEqual(typeof file, 'string');
|
||||
|
||||
// latest mysqldump enables column stats by default which is not present in 5.7 util
|
||||
const mysqlDumpHelp = safe.child_process.execSync('/usr/bin/mysqldump --help', { encoding: 'utf8' });
|
||||
if (!mysqlDumpHelp) throw new BoxError(BoxError.DATABASE_ERROR, safe.error);
|
||||
const mysqlDumpHelp = await shell.exec('exportToFile', '/usr/bin/mysqldump --help', {});
|
||||
const hasColStats = mysqlDumpHelp.includes('column-statistics');
|
||||
const colStats = hasColStats ? '--column-statistics=0' : '';
|
||||
|
||||
const cmd = `/usr/bin/mysqldump -h "${gDatabase.hostname}" -u root -p${gDatabase.password} ${colStats} --single-transaction --routines --triggers ${gDatabase.name} > "${file}"`;
|
||||
|
||||
const [error] = await safe(shell.promises.exec('exportToFile', cmd));
|
||||
const [error] = await safe(shell.exec('exportToFile', cmd, { shell: '/bin/bash' }));
|
||||
if (error) throw new BoxError(BoxError.DATABASE_ERROR, error);
|
||||
}
|
||||
|
||||
@@ -8,7 +8,9 @@ exports = module.exports = {
|
||||
|
||||
const assert = require('assert'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
safe = require('safetydance');
|
||||
debug = require('debug')('box:df'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('./shell.js');
|
||||
|
||||
// binary units (non SI) 1024 based
|
||||
function prettyBytes(bytes) {
|
||||
@@ -35,8 +37,11 @@ function parseLine(line) {
|
||||
}
|
||||
|
||||
async function disks() {
|
||||
const output = safe.child_process.execSync('df -B1 --output=source,fstype,size,used,avail,pcent,target', { encoding: 'utf8' });
|
||||
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
|
||||
const [error, output] = await safe(shell.exec('disks', 'df -B1 --output=source,fstype,size,used,avail,pcent,target', { timeout: 5000 }));
|
||||
if (error) {
|
||||
debug(`disks: df command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`);
|
||||
throw new BoxError(BoxError.FS_ERROR, `Error running df: ${error.message}`);
|
||||
}
|
||||
|
||||
const lines = output.trim().split('\n').slice(1); // discard header
|
||||
const result = [];
|
||||
@@ -49,8 +54,11 @@ async function disks() {
|
||||
async function file(filename) {
|
||||
assert.strictEqual(typeof filename, 'string');
|
||||
|
||||
const output = safe.child_process.execSync(`df -B1 --output=source,fstype,size,used,avail,pcent,target ${filename}`, { encoding: 'utf8' });
|
||||
if (!output) throw new BoxError(BoxError.FS_ERROR, `Error running df: ${safe.error.message}`);
|
||||
const [error, output] = await safe(shell.exec('file', `df -B1 --output=source,fstype,size,used,avail,pcent,target ${filename}`, { timeout: 5000 }));
|
||||
if (error) {
|
||||
debug(`file: df command failed. error: ${error}\n stdout: ${error.stdout}\n stderr: ${error.stderr}`);
|
||||
throw new BoxError(BoxError.FS_ERROR, `Error running df: ${error.message}`);
|
||||
}
|
||||
|
||||
const lines = output.trim().split('\n').slice(1); // discard header
|
||||
return parseLine(lines[0]);
|
||||
|
||||
+31
-26
@@ -39,7 +39,7 @@ async function getConfig() {
|
||||
if (value === null) return {
|
||||
enabled: false,
|
||||
secret: '',
|
||||
allowlist: '' // empty means allow all
|
||||
allowlist: ''
|
||||
};
|
||||
|
||||
return JSON.parse(value);
|
||||
@@ -86,21 +86,25 @@ async function applyConfig(config) {
|
||||
if (!gServer) await start();
|
||||
}
|
||||
|
||||
async function setConfig(directoryServerConfig) {
|
||||
async function setConfig(directoryServerConfig, auditSource) {
|
||||
assert.strictEqual(typeof directoryServerConfig, 'object');
|
||||
assert(auditSource && typeof auditSource === 'object');
|
||||
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
const oldConfig = await getConfig();
|
||||
|
||||
const config = {
|
||||
enabled: directoryServerConfig.enabled,
|
||||
secret: directoryServerConfig.secret,
|
||||
// if list is empty, we allow all IPs
|
||||
allowlist: directoryServerConfig.allowlist || ''
|
||||
allowlist: directoryServerConfig.allowlist
|
||||
};
|
||||
|
||||
await validateConfig(config);
|
||||
await settings.setJson(settings.DIRECTORY_SERVER_KEY, config);
|
||||
await applyConfig(config);
|
||||
|
||||
await eventlog.add(eventlog.ACTION_DIRECTORY_SERVER_CONFIGURE, auditSource, { fromEnabled: oldConfig.enabled, toEnabled: config.enabled });
|
||||
}
|
||||
|
||||
// helper function to deal with pagination
|
||||
@@ -199,10 +203,10 @@ async function userSearch(req, res, next) {
|
||||
debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
|
||||
|
||||
const [error, allUsers] = await safe(users.list());
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
const [groupsError, allGroups] = await safe(groups.listWithMembers());
|
||||
if (groupsError) return next(new ldap.OperationsError(error.toString()));
|
||||
if (groupsError) return next(new ldap.OperationsError(groupsError.message));
|
||||
|
||||
let results = [];
|
||||
|
||||
@@ -214,10 +218,9 @@ async function userSearch(req, res, next) {
|
||||
const dn = ldap.parseDN(`cn=${user.id},ou=users,dc=cloudron`);
|
||||
|
||||
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
|
||||
const nameParts = displayName.split(' ');
|
||||
const firstName = nameParts[0];
|
||||
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists
|
||||
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2798
|
||||
const obj = {
|
||||
dn: dn.toString(),
|
||||
attributes: {
|
||||
@@ -230,6 +233,8 @@ async function userSearch(req, res, next) {
|
||||
mailAlternateAddress: user.fallbackEmail,
|
||||
displayname: displayName,
|
||||
givenName: firstName,
|
||||
sn: lastName,
|
||||
middleName: middleName,
|
||||
username: user.username,
|
||||
samaccountname: user.username, // to support ActiveDirectory clients
|
||||
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
|
||||
@@ -238,13 +243,9 @@ async function userSearch(req, res, next) {
|
||||
|
||||
if (user.twoFactorAuthenticationEnabled) obj.attributes.twoFactorAuthenticationEnabled = true;
|
||||
|
||||
// http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString
|
||||
// which is required to have atleast one character if present
|
||||
if (lastName.length !== 0) obj.attributes.sn = lastName;
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
|
||||
results.push(obj);
|
||||
@@ -258,12 +259,12 @@ async function groupSearch(req, res, next) {
|
||||
debug('group search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
|
||||
|
||||
const [error, allUsers] = await safe(users.list());
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
const results = [];
|
||||
|
||||
let [errorGroups, allGroups] = await safe(groups.listWithMembers());
|
||||
if (errorGroups) return next(new ldap.OperationsError(errorGroups.toString()));
|
||||
if (errorGroups) return next(new ldap.OperationsError(errorGroups.message));
|
||||
|
||||
for (const group of allGroups) {
|
||||
const dn = ldap.parseDN(`cn=${group.name},ou=groups,dc=cloudron`);
|
||||
@@ -283,7 +284,7 @@ async function groupSearch(req, res, next) {
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
|
||||
results.push(obj);
|
||||
@@ -298,10 +299,14 @@ async function userAuth(req, res, next) {
|
||||
// extract the common name which might have different attribute names
|
||||
const cnAttributeName = Object.keys(req.dn.rdns[0].attrs)[0];
|
||||
const commonName = req.dn.rdns[0].attrs[cnAttributeName].value;
|
||||
if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));
|
||||
|
||||
// totptoken is passed as the "attribute" using the '+' separator in the first RDNS of the request DN
|
||||
// when totptoken attribute is present, it signals that we must enforce totp check
|
||||
// totp check is currently requested by the client. this is the only way to auth against external cloudron dashboard, external cloudron app and external apps
|
||||
const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken'; // This has to be in-sync with externalldap.js
|
||||
const totpToken = req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME] ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
|
||||
const totpToken = TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
|
||||
const skipTotpCheck = !(TOTPTOKEN_ATTRIBUTE_NAME in req.dn.rdns[0].attrs);
|
||||
|
||||
let verifyFunc;
|
||||
if (cnAttributeName === 'mail') {
|
||||
@@ -314,9 +319,9 @@ async function userAuth(req, res, next) {
|
||||
verifyFunc = users.verifyWithUsername;
|
||||
}
|
||||
|
||||
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', '', { relaxedTotpCheck: true, totpToken }));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
|
||||
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', '', { totpToken, skipTotpCheck }));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(error.message));
|
||||
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(error.message));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
req.user = user;
|
||||
@@ -353,8 +358,8 @@ async function start() {
|
||||
|
||||
const config = await getConfig();
|
||||
|
||||
if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
|
||||
if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
|
||||
if (!req.dn.equals(constants.USER_DIRECTORY_LDAP_DN)) return next(new ldap.InvalidCredentialsError('Invalid DN'));
|
||||
if (req.credentials !== config.secret) return next(new ldap.InvalidCredentialsError('Invalid Secret'));
|
||||
|
||||
req.user = { user: 'directoryServerAdmin' };
|
||||
|
||||
@@ -391,7 +396,7 @@ async function stop() {
|
||||
|
||||
debug('stopping server');
|
||||
|
||||
gServer.close(); // has no callback
|
||||
await util.promisify(gServer.close.bind(gServer))();
|
||||
gServer = null;
|
||||
}
|
||||
|
||||
|
||||
+13
-6
@@ -77,9 +77,16 @@ async function getZoneByName(domainConfig, zoneName) {
|
||||
const [error, response] = await safe(createRequest('GET', `${CLOUDFLARE_ENDPOINT}/zones?name=${zoneName}&status=active`, domainConfig));
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
if (response.statusCode !== 200 || response.body.success !== true) throw translateRequestError(response);
|
||||
if (!response.body.result.length) throw new BoxError(BoxError.NOT_FOUND, util.format('%s %j', response.statusCode, response.body));
|
||||
if (!response.body.result || !response.body.result.length) throw new BoxError(BoxError.NOT_FOUND, `${response.statusCode} ${response.text}`);
|
||||
|
||||
return response.body.result[0];
|
||||
// check 'id' and 'name_servers' exist in the response
|
||||
const zone = response.body.result[0];
|
||||
const zoneId = safe.query(zone, 'id');
|
||||
if (typeof zoneId !== 'string') throw new BoxError(BoxError.EXTERNAL_ERROR, `No zone id in response: ${response.statusCode} ${response.text}`);
|
||||
const name_servers = safe.query(zone, 'name_servers');
|
||||
if (!Array.isArray(name_servers)) throw new BoxError(BoxError.EXTERNAL_ERROR, `name_servers is not an array: ${response.statusCode} ${response.text}`);
|
||||
|
||||
return zone;
|
||||
}
|
||||
|
||||
// gets records filtered by zone, type and fqdn
|
||||
@@ -110,8 +117,8 @@ async function upsert(domainObject, location, type, values) {
|
||||
|
||||
debug('upsert: %s for zone %s of type %s with values %j', fqdn, zoneName, type, values);
|
||||
|
||||
const result = await getZoneByName(domainConfig, zoneName);
|
||||
const zoneId = result.id;
|
||||
const zone = await getZoneByName(domainConfig, zoneName);
|
||||
const zoneId = zone.id;
|
||||
|
||||
const records = await getDnsRecords(domainConfig, zoneId, fqdn, type);
|
||||
|
||||
@@ -223,8 +230,8 @@ async function wait(domainObject, subdomain, type, value, options) {
|
||||
|
||||
debug('wait: %s for zone %s of type %s', fqdn, zoneName, type);
|
||||
|
||||
const result = await getZoneByName(domainConfig, zoneName);
|
||||
const zoneId = result.id;
|
||||
const zone = await getZoneByName(domainConfig, zoneName);
|
||||
const zoneId = zone.id;
|
||||
|
||||
const dnsRecords = await getDnsRecords(domainConfig, zoneId, fqdn, type);
|
||||
if (dnsRecords.length === 0) throw new BoxError(BoxError.NOT_FOUND, 'Domain not found');
|
||||
|
||||
@@ -92,6 +92,8 @@ async function upsert(domainObject, location, type, values) {
|
||||
if (type === 'MX') {
|
||||
priority = value.split(' ')[0];
|
||||
value = value.split(' ')[1];
|
||||
} else if (type === 'TXT') {
|
||||
value = value.replace(/^"(.*)"$/, '$1'); // strip any double quotes
|
||||
}
|
||||
|
||||
const data = {
|
||||
|
||||
@@ -19,6 +19,7 @@ const assert = require('assert'),
|
||||
network = require('../network.js'),
|
||||
safe = require('safetydance'),
|
||||
superagent = require('superagent'),
|
||||
timers = require('timers/promises'),
|
||||
util = require('util'),
|
||||
waitForDns = require('./waitfordns.js'),
|
||||
xml2js = require('xml2js');
|
||||
@@ -39,6 +40,9 @@ async function getQuery(domainConfig) {
|
||||
|
||||
const ip = await network.getIPv4(); // only supports ipv4
|
||||
|
||||
// https://www.namecheap.com/support/knowledgebase/article.aspx/9739/63/api-faq/#z . 50 / minute
|
||||
await timers.setTimeout(5000); // limits to 12req/min for this process. we can have 3 apptasks in parallel
|
||||
|
||||
return {
|
||||
ApiUser: domainConfig.username,
|
||||
ApiKey: domainConfig.token,
|
||||
@@ -53,8 +57,8 @@ async function getZone(domainConfig, zoneName) {
|
||||
|
||||
const query = await getQuery(domainConfig);
|
||||
query.Command = 'namecheap.domains.dns.getHosts';
|
||||
query.SLD = zoneName.split('.')[0];
|
||||
query.TLD = zoneName.split('.')[1];
|
||||
query.SLD = zoneName.split('.', 1)[0];
|
||||
query.TLD = zoneName.slice(query.SLD.length + 1);
|
||||
|
||||
const [error, response] = await safe(superagent.get(ENDPOINT).query(query).ok(() => true));
|
||||
if (error) throw new BoxError(BoxError.NETWORK_ERROR, error.message);
|
||||
@@ -85,8 +89,8 @@ async function setZone(domainConfig, zoneName, hosts) {
|
||||
|
||||
const query = await getQuery(domainConfig);
|
||||
query.Command = 'namecheap.domains.dns.setHosts';
|
||||
query.SLD = zoneName.split('.')[0];
|
||||
query.TLD = zoneName.split('.')[1];
|
||||
query.SLD = zoneName.split('.', 1)[0];
|
||||
query.TLD = zoneName.slice(query.SLD.length + 1);
|
||||
|
||||
// Map to query params https://www.namecheap.com/support/api/methods/domains-dns/set-hosts.aspx
|
||||
hosts.forEach(function (host, i) {
|
||||
|
||||
+17
-13
@@ -41,6 +41,7 @@ const apps = require('./apps.js'),
|
||||
dashboard = require('./dashboard.js'),
|
||||
debug = require('debug')('box:docker'),
|
||||
Docker = require('dockerode'),
|
||||
fs = require('fs'),
|
||||
paths = require('./paths.js'),
|
||||
promiseRetry = require('./promise-retry.js'),
|
||||
services = require('./services.js'),
|
||||
@@ -237,16 +238,18 @@ async function getMounts(app) {
|
||||
|
||||
// This only returns ipv4 addresses
|
||||
// We dont bind to ipv6 interfaces, public prefix changes and container restarts wont work
|
||||
function getAddressesForPort53() {
|
||||
const deviceLinks = safe.fs.readdirSync('/sys/class/net'); // https://man7.org/linux/man-pages/man5/sysfs.5.html
|
||||
if (!deviceLinks) return [];
|
||||
async function getAddressesForPort53() {
|
||||
const [error, deviceLinks] = await safe(fs.promises.readdir('/sys/class/net')); // https://man7.org/linux/man-pages/man5/sysfs.5.html
|
||||
if (error) return [];
|
||||
|
||||
const devices = deviceLinks.map(d => { return { name: d, link: safe.fs.readlinkSync(`/sys/class/net/${d}`) }; });
|
||||
const physicalDevices = devices.filter(d => d.link && !d.link.includes('virtual'));
|
||||
|
||||
const addresses = [];
|
||||
for (const phy of physicalDevices) {
|
||||
const inet = safe.JSON.parse(safe.child_process.execSync(`ip -f inet -j addr show dev ${phy.name} scope global`, { encoding: 'utf8' }));
|
||||
const [error, output] = await safe(shell.exec('getAddressesForPort53', `ip -f inet -j addr show dev ${phy.name} scope global`, {}));
|
||||
if (error) continue;
|
||||
const inet = safe.JSON.parse(output) || [];
|
||||
for (const r of inet) {
|
||||
const address = safe.query(r, 'addr_info[0].local');
|
||||
if (address) addresses.push(address);
|
||||
@@ -288,15 +291,18 @@ async function createSubcontainer(app, name, cmd, options) {
|
||||
const hostPort = app.portBindings[portName];
|
||||
const portType = (manifest.tcpPorts && portName in manifest.tcpPorts) ? 'tcp' : 'udp';
|
||||
const ports = portType == 'tcp' ? manifest.tcpPorts : manifest.udpPorts;
|
||||
|
||||
const containerPort = ports[portName].containerPort || hostPort;
|
||||
const portCount = ports[portName].portCount || 1;
|
||||
const hostIps = hostPort === 53 ? await getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
|
||||
|
||||
portEnv.push(`${portName}=${hostPort}`);
|
||||
if (portCount > 1) portEnv.push(`${portName}_COUNT=${portCount}`);
|
||||
|
||||
// docker portBindings requires ports to be exposed
|
||||
exposedPorts[`${containerPort}/${portType}`] = {};
|
||||
portEnv.push(`${portName}=${hostPort}`);
|
||||
|
||||
const hostIps = hostPort === 53 ? getAddressesForPort53() : [ '0.0.0.0', '::0' ]; // port 53 is special because it is possibly taken by systemd-resolved
|
||||
dockerPortBindings[`${containerPort}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: hostPort + '' }; });
|
||||
for (let i = 0; i < portCount; ++i) {
|
||||
exposedPorts[`${containerPort+i}/${portType}`] = {};
|
||||
dockerPortBindings[`${containerPort+i}/${portType}`] = hostIps.map(hip => { return { HostIp: hip, HostPort: (hostPort + i) + '' }; });
|
||||
}
|
||||
}
|
||||
|
||||
const appEnv = [];
|
||||
@@ -642,12 +648,10 @@ async function update(name, memory, memorySwap) {
|
||||
assert.strictEqual(typeof memory, 'number');
|
||||
assert.strictEqual(typeof memorySwap, 'number');
|
||||
|
||||
const args = `update --memory ${memory} --memory-swap ${memorySwap} ${name}`.split(' ');
|
||||
// scale back db containers, if possible. this is retried because updating memory constraints can fail
|
||||
// with failed to write to memory.memsw.limit_in_bytes: write /sys/fs/cgroup/memory/docker/xx/memory.memsw.limit_in_bytes: device or resource busy
|
||||
|
||||
for (let times = 0; times < 10; times++) {
|
||||
const [error] = await safe(shell.promises.spawn(`update(${name})`, '/usr/bin/docker', args, { }));
|
||||
const [error] = await safe(shell.exec(`update(${name})`, `docker update --memory ${memory} --memory-swap ${memorySwap} ${name}`, {}));
|
||||
if (!error) return;
|
||||
await timers.setTimeout(60 * 1000);
|
||||
}
|
||||
|
||||
+2
-1
@@ -181,7 +181,8 @@ async function start() {
|
||||
}
|
||||
|
||||
async function stop() {
|
||||
if (gHttpServer) gHttpServer.close();
|
||||
if (!gHttpServer) return;
|
||||
|
||||
await util.promisify(gHttpServer.close.bind(gHttpServer))();
|
||||
gHttpServer = null;
|
||||
}
|
||||
|
||||
+4
-7
@@ -142,8 +142,7 @@ async function add(domain, data, auditSource) {
|
||||
}
|
||||
|
||||
if (fallbackCertificate) {
|
||||
let error = reverseProxy.validateCertificate('test', domain, fallbackCertificate);
|
||||
if (error) throw error;
|
||||
await reverseProxy.validateCertificate('test', domain, fallbackCertificate);
|
||||
} else {
|
||||
fallbackCertificate = await reverseProxy.generateFallbackCertificate(domain);
|
||||
}
|
||||
@@ -205,7 +204,7 @@ async function setConfig(domain, data, auditSource) {
|
||||
let { zoneName, provider, config, fallbackCertificate, tlsConfig } = data;
|
||||
|
||||
const { domain:dashboardDomain } = await dashboard.getLocation();
|
||||
if (constants.DEMO && (domain === dashboardDomain)) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
|
||||
if (constants.DEMO && (domain === dashboardDomain)) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
const domainObject = await get(domain);
|
||||
if (zoneName) {
|
||||
@@ -214,10 +213,7 @@ async function setConfig(domain, data, auditSource) {
|
||||
zoneName = domainObject.zoneName;
|
||||
}
|
||||
|
||||
if (fallbackCertificate) {
|
||||
let error = reverseProxy.validateCertificate('test', domain, fallbackCertificate);
|
||||
if (error) throw error;
|
||||
}
|
||||
if (fallbackCertificate) await reverseProxy.validateCertificate('test', domain, fallbackCertificate);
|
||||
|
||||
const tlsConfigError = validateTlsConfig(tlsConfig, provider);
|
||||
if (tlsConfigError) throw tlsConfigError;
|
||||
@@ -287,6 +283,7 @@ async function del(domain, auditSource) {
|
||||
|
||||
const [error, results] = await safe(database.transaction(queries));
|
||||
if (error && error.code === 'ER_ROW_IS_REFERENCED_2') {
|
||||
if (error.message.includes('mailboxes_aliasDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
|
||||
if (error.message.includes('mailboxes_domain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in a mailbox, list or an alias');
|
||||
if (error.message.includes('apps_mailDomain_constraint')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s mailbox section');
|
||||
if (error.message.includes('locations')) throw new BoxError(BoxError.CONFLICT, 'Domain is in use in an app\'s location');
|
||||
|
||||
@@ -41,10 +41,14 @@ exports = module.exports = {
|
||||
|
||||
ACTION_DASHBOARD_DOMAIN_UPDATE: 'dashboard.domain.update',
|
||||
|
||||
ACTION_DIRECTORY_SERVER_CONFIGURE: 'directoryserver.configure',
|
||||
|
||||
ACTION_DOMAIN_ADD: 'domain.add',
|
||||
ACTION_DOMAIN_UPDATE: 'domain.update',
|
||||
ACTION_DOMAIN_REMOVE: 'domain.remove',
|
||||
|
||||
ACTION_EXTERNAL_LDAP_CONFIGURE: 'externalldap.configure',
|
||||
|
||||
ACTION_INSTALL_FINISH: 'cloudron.install.finish',
|
||||
|
||||
ACTION_MAIL_LOCATION: 'mail.location',
|
||||
|
||||
+55
-36
@@ -7,6 +7,8 @@ exports = module.exports = {
|
||||
verifyPassword,
|
||||
maybeCreateUser,
|
||||
|
||||
supports2FA,
|
||||
|
||||
startSyncer,
|
||||
|
||||
removePrivateFields,
|
||||
@@ -18,10 +20,11 @@ const assert = require('assert'),
|
||||
AuditSource = require('./auditsource.js'),
|
||||
BoxError = require('./boxerror.js'),
|
||||
constants = require('./constants.js'),
|
||||
cron = require('./cron.js'),
|
||||
debug = require('debug')('box:externalldap'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
groups = require('./groups.js'),
|
||||
ldap = require('ldapjs'),
|
||||
once = require('./once.js'),
|
||||
safe = require('safetydance'),
|
||||
settings = require('./settings.js'),
|
||||
tasks = require('./tasks.js'),
|
||||
@@ -68,10 +71,11 @@ async function getConfig() {
|
||||
return config;
|
||||
}
|
||||
|
||||
async function setConfig(newConfig) {
|
||||
async function setConfig(newConfig, auditSource) {
|
||||
assert.strictEqual(typeof newConfig, 'object');
|
||||
assert(auditSource && typeof auditSource === 'object');
|
||||
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
const currentConfig = await getConfig();
|
||||
|
||||
@@ -81,6 +85,19 @@ async function setConfig(newConfig) {
|
||||
if (error) throw error;
|
||||
|
||||
await settings.setJson(settings.EXTERNAL_LDAP_KEY, newConfig);
|
||||
|
||||
if (newConfig.provider === 'noop') {
|
||||
await users.resetSource(); // otherwise, the owner could be 'ldap' source and lock themselves out
|
||||
await groups.resetSource();
|
||||
}
|
||||
|
||||
await eventlog.add(eventlog.ACTION_EXTERNAL_LDAP_CONFIGURE, auditSource, { oldConfig: removePrivateFields(currentConfig), config: removePrivateFields(newConfig) });
|
||||
|
||||
await cron.handleExternalLdapChanged(newConfig);
|
||||
}
|
||||
|
||||
function supports2FA(config) {
|
||||
return config.provider === 'cloudron';
|
||||
}
|
||||
|
||||
// performs service bind if required
|
||||
@@ -98,7 +115,10 @@ async function getClient(config, options) {
|
||||
url: config.url,
|
||||
tlsOptions: {
|
||||
rejectUnauthorized: config.acceptSelfSignedCerts ? false : true
|
||||
}
|
||||
},
|
||||
// https://github.com/ldapjs/node-ldapjs/issues/486
|
||||
timeout: 60000,
|
||||
connectTimeout: 10000
|
||||
};
|
||||
|
||||
client = ldap.createClient(ldapConfig);
|
||||
@@ -108,12 +128,9 @@ async function getClient(config, options) {
|
||||
}
|
||||
|
||||
return await new Promise((resolve, reject) => {
|
||||
reject = once(reject);
|
||||
|
||||
// ensure we don't just crash
|
||||
client.on('error', function (error) {
|
||||
client.on('error', function (error) { // don't reject, we must have gotten a bind error
|
||||
debug('getClient: ExternalLdap client error:', error);
|
||||
reject(new BoxError(BoxError.EXTERNAL_ERROR, error));
|
||||
});
|
||||
|
||||
// skip bind auth if none exist or if not wanted
|
||||
@@ -278,32 +295,32 @@ async function maybeCreateUser(identifier) {
|
||||
return await users.add(user.email, { username: user.username, password: null, displayName: user.displayName, source: 'ldap' }, AuditSource.EXTERNAL_LDAP);
|
||||
}
|
||||
|
||||
async function verifyPassword(user, password, totpToken) {
|
||||
assert.strictEqual(typeof user, 'object');
|
||||
async function verifyPassword(username, password, options) {
|
||||
assert.strictEqual(typeof username, 'string');
|
||||
assert.strictEqual(typeof password, 'string');
|
||||
assert(totpToken === null || typeof totpToken === 'string');
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
const config = await getConfig();
|
||||
if (config.provider === 'noop') throw new BoxError(BoxError.BAD_STATE, 'not enabled');
|
||||
|
||||
const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${user.username}` });
|
||||
const ldapUsers = await ldapUserSearch(config, { filter: `${config.usernameField}=${username}` });
|
||||
if (ldapUsers.length === 0) throw new BoxError(BoxError.NOT_FOUND);
|
||||
if (ldapUsers.length > 1) throw new BoxError(BoxError.CONFLICT);
|
||||
|
||||
const client = await getClient(config, { bind: false });
|
||||
|
||||
let userAuthDn;
|
||||
if (totpToken) {
|
||||
// inject totptoken into first attribute
|
||||
if (!options.skipTotpCheck && supports2FA(config)) {
|
||||
// inject totptoken into first attribute. in ldap, '+' is the attribute separator in a RDNS
|
||||
const rdns = ldapUsers[0].dn.split(',');
|
||||
userAuthDn = `${rdns[0]}+totptoken=${totpToken},` + rdns.slice(1).join(',');
|
||||
userAuthDn = `${rdns[0]}+totptoken=${options.totpToken},` + rdns.slice(1).join(',');
|
||||
} else {
|
||||
userAuthDn = ldapUsers[0].dn;
|
||||
}
|
||||
|
||||
const [error] = await safe(util.promisify(client.bind.bind(client))(userAuthDn, password));
|
||||
client.unbind();
|
||||
if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS);
|
||||
if (error instanceof ldap.InvalidCredentialsError) throw new BoxError(BoxError.INVALID_CREDENTIALS, error.lde_message);
|
||||
if (error) throw new BoxError(BoxError.EXTERNAL_ERROR, error);
|
||||
|
||||
return translateUser(config, ldapUsers[0]);
|
||||
@@ -384,14 +401,11 @@ async function syncGroups(config, progressCallback) {
|
||||
let percent = 40;
|
||||
let step = 30/(ldapGroups.length+1); // ensure no divide by 0
|
||||
|
||||
// we ignore all non internal errors here and just log them for now
|
||||
for (const ldapGroup of ldapGroups) {
|
||||
let groupName = ldapGroup[config.groupnameField];
|
||||
if (!groupName) return;
|
||||
// some servers return empty array for unknown properties :-/
|
||||
if (typeof groupName !== 'string') return;
|
||||
if (typeof groupName !== 'string') return; // some servers return empty array for unknown properties :-/
|
||||
|
||||
// groups are lowercase
|
||||
groupName = groupName.toLowerCase();
|
||||
|
||||
percent += step;
|
||||
@@ -401,10 +415,13 @@ async function syncGroups(config, progressCallback) {
|
||||
|
||||
if (!result) {
|
||||
debug(`syncGroups: [adding group] groupname=${groupName}`);
|
||||
|
||||
const [error] = await safe(groups.add({ name: groupName, source: 'ldap' }));
|
||||
if (error) debug('syncGroups: Failed to create group', groupName, error);
|
||||
} else {
|
||||
// convert local group to ldap group. 2 reasons:
|
||||
// 1. we reset source flag when externalldap is disabled. if we renable, it automatically coverts
|
||||
// 2. externalldap connector usually implies user wants to user external users/groups.
|
||||
groups.update(result.id, { source: 'ldap' });
|
||||
debug(`syncGroups: [up-to-date group] groupname=${groupName}`);
|
||||
}
|
||||
}
|
||||
@@ -412,26 +429,26 @@ async function syncGroups(config, progressCallback) {
|
||||
debug('syncGroups: sync done');
|
||||
}
|
||||
|
||||
async function syncGroupUsers(config, progressCallback) {
|
||||
async function syncGroupMembers(config, progressCallback) {
|
||||
assert.strictEqual(typeof config, 'object');
|
||||
assert.strictEqual(typeof progressCallback, 'function');
|
||||
|
||||
if (!config.syncGroups) {
|
||||
debug('syncGroupUsers: Group users sync is disabled');
|
||||
debug('syncGroupMembers: Group users sync is disabled');
|
||||
progressCallback({ percent: 99, message: 'Skipping group users sync...' });
|
||||
return [];
|
||||
}
|
||||
|
||||
const allGroups = await groups.list();
|
||||
const ldapGroups = allGroups.filter(function (g) { return g.source === 'ldap'; });
|
||||
debug(`syncGroupUsers: Found ${ldapGroups.length} groups to sync users`);
|
||||
debug(`syncGroupMembers: Found ${ldapGroups.length} groups to sync users`);
|
||||
|
||||
for (const group of ldapGroups) {
|
||||
debug(`syncGroupUsers: Sync users for group ${group.name}`);
|
||||
debug(`syncGroupMembers: Sync users for group ${group.name}`);
|
||||
|
||||
const result = await ldapGroupSearch(config, {});
|
||||
if (!result || result.length === 0) {
|
||||
debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`);
|
||||
debug(`syncGroupMembers: Unable to find group ${group.name} ignoring for now.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -442,7 +459,7 @@ async function syncGroupUsers(config, progressCallback) {
|
||||
});
|
||||
|
||||
if (!found) {
|
||||
debug(`syncGroupUsers: Unable to find group ${group.name} ignoring for now.`);
|
||||
debug(`syncGroupMembers: Unable to find group ${group.name} ignoring for now.`);
|
||||
continue;
|
||||
}
|
||||
|
||||
@@ -451,32 +468,34 @@ async function syncGroupUsers(config, progressCallback) {
|
||||
// if only one entry is in the group ldap returns a string, not an array!
|
||||
if (typeof ldapGroupMembers === 'string') ldapGroupMembers = [ ldapGroupMembers ];
|
||||
|
||||
debug(`syncGroupUsers: Group ${group.name} has ${ldapGroupMembers.length} members.`);
|
||||
debug(`syncGroupMembers: Group ${group.name} has ${ldapGroupMembers.length} members.`);
|
||||
|
||||
const userIds = [];
|
||||
for (const memberDn of ldapGroupMembers) {
|
||||
const [ldapError, result] = await safe(ldapGetByDN(config, memberDn));
|
||||
if (ldapError) {
|
||||
debug(`syncGroupUsers: Failed to get ${memberDn}: %o`, ldapError);
|
||||
debug(`syncGroupMembers: Group ${group.name} failed to get ${memberDn}: %o`, ldapError);
|
||||
continue;
|
||||
}
|
||||
|
||||
debug(`syncGroupUsers: Found member object at ${memberDn} adding to group ${group.name}`);
|
||||
debug(`syncGroupMembers: Group ${group.name} has member object ${memberDn}`);
|
||||
|
||||
const username = result[config.usernameField].toLowerCase();
|
||||
const username = result[config.usernameField]?.toLowerCase();
|
||||
if (!username) continue;
|
||||
|
||||
const [getError, userObject] = await safe(users.getByUsername(username));
|
||||
if (getError || !userObject) {
|
||||
debug(`syncGroupUsers: Failed to get user by username ${username}. %o`, getError ? getError : 'User not found');
|
||||
debug(`syncGroupMembers: Failed to get user by username ${username}. %o`, getError ? getError : 'User not found');
|
||||
continue;
|
||||
}
|
||||
|
||||
const [addError] = await safe(groups.addMember(group.id, userObject.id));
|
||||
if (addError && addError.reason !== BoxError.ALREADY_EXISTS) debug('syncGroupUsers: Failed to add member. %o', addError);
|
||||
userIds.push(userObject.id);
|
||||
}
|
||||
const [setError] = await safe(groups.setMembers(group, userIds, { skipSourceCheck: true }));
|
||||
if (setError) debug(`syncGroupMembers: Failed to set members of group ${group.name}. %o`, setError);
|
||||
}
|
||||
|
||||
debug('syncGroupUsers: done');
|
||||
debug('syncGroupMembers: done');
|
||||
}
|
||||
|
||||
async function sync(progressCallback) {
|
||||
@@ -489,7 +508,7 @@ async function sync(progressCallback) {
|
||||
|
||||
await syncUsers(config, progressCallback);
|
||||
await syncGroups(config, progressCallback);
|
||||
await syncGroupUsers(config, progressCallback);
|
||||
await syncGroupMembers(config, progressCallback);
|
||||
|
||||
progressCallback({ percent: 100, message: 'Done' });
|
||||
|
||||
|
||||
+60
-36
@@ -5,19 +5,24 @@ exports = module.exports = {
|
||||
remove,
|
||||
get,
|
||||
getByName,
|
||||
|
||||
update,
|
||||
setName,
|
||||
|
||||
getWithMembers,
|
||||
list,
|
||||
listWithMembers,
|
||||
|
||||
getMembers,
|
||||
addMember,
|
||||
setMembers,
|
||||
removeMember,
|
||||
isMember,
|
||||
|
||||
setMembership,
|
||||
getMembership,
|
||||
setLocalMembership,
|
||||
resetSource,
|
||||
|
||||
// exported for testing
|
||||
_getMembership: getMembership
|
||||
};
|
||||
|
||||
const assert = require('assert'),
|
||||
@@ -30,7 +35,7 @@ const assert = require('assert'),
|
||||
const GROUPS_FIELDS = [ 'id', 'name', 'source' ].join(',');
|
||||
|
||||
// keep this in sync with validateUsername
|
||||
function validateGroupname(name) {
|
||||
function validateName(name) {
|
||||
assert.strictEqual(typeof name, 'string');
|
||||
|
||||
if (name.length < 1) return new BoxError(BoxError.BAD_FIELD, 'name must be atleast 1 char');
|
||||
@@ -44,7 +49,7 @@ function validateGroupname(name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function validateGroupSource(source) {
|
||||
function validateSource(source) {
|
||||
assert.strictEqual(typeof source, 'string');
|
||||
|
||||
if (source !== '' && source !== 'ldap') return new BoxError(BoxError.BAD_FIELD, 'source must be "" or "ldap"');
|
||||
@@ -60,10 +65,10 @@ async function add(group) {
|
||||
name = name.toLowerCase(); // we store names in lowercase
|
||||
source = source || '';
|
||||
|
||||
let error = validateGroupname(name);
|
||||
let error = validateName(name);
|
||||
if (error) throw error;
|
||||
|
||||
error = validateGroupSource(source);
|
||||
error = validateSource(source);
|
||||
if (error) throw error;
|
||||
|
||||
const id = `gid-${uuid.v4()}`;
|
||||
@@ -72,7 +77,7 @@ async function add(group) {
|
||||
if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, error);
|
||||
if (error) throw error;
|
||||
|
||||
return { id, name };
|
||||
return { id, name, source };
|
||||
}
|
||||
|
||||
async function remove(id) {
|
||||
@@ -151,15 +156,23 @@ async function getMembership(userId) {
|
||||
return result.map(function (r) { return r.groupId; });
|
||||
}
|
||||
|
||||
async function setMembership(userId, groupIds) {
|
||||
assert.strictEqual(typeof userId, 'string');
|
||||
assert(Array.isArray(groupIds));
|
||||
async function setLocalMembership(user, localGroupIds) {
|
||||
assert.strictEqual(typeof user, 'object'); // can be local or external
|
||||
assert(Array.isArray(localGroupIds));
|
||||
|
||||
let queries = [ ];
|
||||
queries.push({ query: 'DELETE from groupMembers WHERE userId = ?', args: [ userId ] });
|
||||
groupIds.forEach(function (gid) {
|
||||
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (? , ?)', args: [ gid, userId ] });
|
||||
});
|
||||
// ensure groups are actually local
|
||||
for (const groupId of localGroupIds) {
|
||||
const group = await get(groupId);
|
||||
if (!group) throw new BoxError(BoxError.NOT_FOUND, `Group ${groupId} not found`);
|
||||
if (group.source) throw new BoxError(BoxError.BAD_STATE, 'Cannot set members of external group');
|
||||
}
|
||||
|
||||
const queries = [];
|
||||
// a remote user may already be part of some external groups. do not clear those because remote groups are non-editable
|
||||
queries.push({ query: 'DELETE FROM groupMembers WHERE userId = ? AND groupId IN (SELECT id FROM userGroups WHERE source = ?)', args: [ user.id, '' ] });
|
||||
for (const gid of localGroupIds) {
|
||||
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (? , ?)', args: [ gid, user.id ] });
|
||||
}
|
||||
|
||||
const [error] = await safe(database.transaction(queries));
|
||||
if (error && error.code === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
|
||||
@@ -167,25 +180,17 @@ async function setMembership(userId, groupIds) {
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async function addMember(groupId, userId) {
|
||||
assert.strictEqual(typeof groupId, 'string');
|
||||
assert.strictEqual(typeof userId, 'string');
|
||||
|
||||
const [error] = await safe(database.query('INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', [ groupId, userId ]));
|
||||
if (error && error.code === 'ER_DUP_ENTRY') throw new BoxError(BoxError.ALREADY_EXISTS, error);
|
||||
if (error && error.code === 'ER_NO_REFERENCED_ROW_2' && error.sqlMessage.includes('userId')) throw new BoxError(BoxError.NOT_FOUND, 'User not found');
|
||||
if (error && error.code === 'ER_NO_REFERENCED_ROW_2') throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
async function setMembers(groupId, userIds) {
|
||||
assert.strictEqual(typeof groupId, 'string');
|
||||
async function setMembers(group, userIds, options) {
|
||||
assert.strictEqual(typeof group, 'object');
|
||||
assert(Array.isArray(userIds));
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
let queries = [];
|
||||
queries.push({ query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ groupId ] });
|
||||
if (!options.skipSourceCheck && group.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot set members of external group');
|
||||
|
||||
const queries = [];
|
||||
queries.push({ query: 'DELETE FROM groupMembers WHERE groupId = ?', args: [ group.id ] });
|
||||
for (let i = 0; i < userIds.length; i++) {
|
||||
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', args: [ groupId, userIds[i] ] });
|
||||
queries.push({ query: 'INSERT INTO groupMembers (groupId, userId) VALUES (?, ?)', args: [ group.id, userIds[i] ] });
|
||||
}
|
||||
|
||||
const [error] = await safe(database.transaction(queries));
|
||||
@@ -216,17 +221,23 @@ async function update(id, data) {
|
||||
|
||||
if ('name' in data) {
|
||||
assert.strictEqual(typeof data.name, 'string');
|
||||
const error = validateGroupname(data.name);
|
||||
data.name = data.name.toLowerCase();
|
||||
const error = validateName(data.name);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
if ('source' in data) {
|
||||
assert.strictEqual(typeof data.source, 'string');
|
||||
const error = validateSource(data.source);
|
||||
if (error) throw error;
|
||||
}
|
||||
|
||||
const args = [];
|
||||
const fields = [];
|
||||
for (const k in data) {
|
||||
if (k === 'name') {
|
||||
assert.strictEqual(typeof data.name, 'string');
|
||||
if (k === 'name' || k === 'source') {
|
||||
fields.push(k + ' = ?');
|
||||
args.push(data.name);
|
||||
args.push(data[k]);
|
||||
}
|
||||
}
|
||||
args.push(id);
|
||||
@@ -236,3 +247,16 @@ async function update(id, data) {
|
||||
if (updateError) throw updateError;
|
||||
if (result.affectedRows !== 1) throw new BoxError(BoxError.NOT_FOUND, 'Group not found');
|
||||
}
|
||||
|
||||
async function setName(group, name) {
|
||||
assert.strictEqual(typeof group, 'object');
|
||||
assert.strictEqual(typeof name, 'string');
|
||||
|
||||
if (group.source === 'ldap') throw new BoxError(BoxError.BAD_STATE, 'Cannot set name of external group');
|
||||
|
||||
await update(group.id, { name });
|
||||
}
|
||||
|
||||
async function resetSource() {
|
||||
await database.query('UPDATE userGroups SET source = ?', [ '' ]);
|
||||
}
|
||||
|
||||
@@ -13,12 +13,12 @@ exports = module.exports = {
|
||||
'images': {
|
||||
'base': 'registry.docker.com/cloudron/base:4.2.0@sha256:46da2fffb36353ef714f97ae8e962bd2c212ca091108d768ba473078319a47f4',
|
||||
'graphite': 'registry.docker.com/cloudron/graphite:3.4.3@sha256:75df420ece34b31a7ce8d45b932246b7f524c123e1854f5e8f115a9e94e33f20',
|
||||
'mail': 'registry.docker.com/cloudron/mail:3.11.3@sha256:0e8ec3ba14482e2256ab0fb75021da11f437e6f269cd9dc0232426aca1b9361a',
|
||||
'mongodb': 'registry.docker.com/cloudron/mongodb:5.1.2@sha256:897bea3cae08c8c10f9f5adaff853be314ab94aa98d96a8d0caa502babd983aa',
|
||||
'mail': 'registry.docker.com/cloudron/mail:3.12.1@sha256:f539bea6c7360d3c0aa604323847172359593f109b304bb2d2c5152ca56be05c',
|
||||
'mongodb': 'registry.docker.com/cloudron/mongodb:6.0.0@sha256:1108319805acfb66115aa96a8fdbf2cded28d46da0e04d171a87ec734b453d1e',
|
||||
'mysql': 'registry.docker.com/cloudron/mysql:3.4.2@sha256:379749708186a89f4ae09d6b23b58bc6d99a2005bac32e812b4b1dafa47071e4',
|
||||
'postgresql': 'registry.docker.com/cloudron/postgresql:5.1.6@sha256:a89231a7835955767893a83b2d993764f59da24e292385b06470c8e42a1ffa0e',
|
||||
'postgresql': 'registry.docker.com/cloudron/postgresql:5.2.1@sha256:5ef3aea8873da25ea5e682e458b11c99fc8df25ae90c7695a6f40bda8d120057',
|
||||
'redis': 'registry.docker.com/cloudron/redis:3.5.2@sha256:5c3d9a912d3ad723b195cfcbe9f44956a2aa88f9e29f7da3ef725162f8e2829a',
|
||||
'sftp': 'registry.docker.com/cloudron/sftp:3.8.3@sha256:e00d8ef884b8657b57499d397d9db7f141f3d17253eec2752cdef5d15fff51da',
|
||||
'sftp': 'registry.docker.com/cloudron/sftp:3.8.6@sha256:6b4e3f192c23eadb21d2035ba05f8432d7961330edb93921f36a4eaa60c4a4aa',
|
||||
'turn': 'registry.docker.com/cloudron/turn:1.7.2@sha256:9ed8da613c1edc5cb8700657cf6e49f0f285b446222a8f459f80919945352f6d',
|
||||
}
|
||||
};
|
||||
|
||||
@@ -51,10 +51,7 @@ async function userAuthInternal(appId, req, res, next) {
|
||||
// extract the common name which might have different attribute names
|
||||
const attributeName = Object.keys(req.dn.rdns[0].attrs)[0];
|
||||
const commonName = req.dn.rdns[0].attrs[attributeName].value;
|
||||
if (!commonName) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
|
||||
const TOTPTOKEN_ATTRIBUTE_NAME = 'totptoken';
|
||||
const totpToken = req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME] ? req.dn.rdns[0].attrs[TOTPTOKEN_ATTRIBUTE_NAME].value : null;
|
||||
if (!commonName) return next(new ldap.NoSuchObjectError('Missing CN'));
|
||||
|
||||
let verifyFunc;
|
||||
if (attributeName === 'mail') {
|
||||
@@ -67,9 +64,9 @@ async function userAuthInternal(appId, req, res, next) {
|
||||
verifyFunc = users.verifyWithUsername;
|
||||
}
|
||||
|
||||
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', appId || '', { relaxedTotpCheck: true, totpToken }));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
|
||||
const [error, user] = await safe(verifyFunc(commonName, req.credentials || '', appId || '', { skipTotpCheck: true }));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(error.message));
|
||||
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(error.message));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
req.user = user;
|
||||
@@ -149,10 +146,10 @@ async function userSearch(req, res, next) {
|
||||
debug('user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
|
||||
|
||||
const [error, result] = await safe(getUsersWithAccessToApp(req));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
const [groupsError, allGroups] = await safe(groups.listWithMembers());
|
||||
if (groupsError) return next(new ldap.OperationsError(error.toString()));
|
||||
if (groupsError) return next(new ldap.OperationsError(groupsError.message));
|
||||
|
||||
let results = [];
|
||||
|
||||
@@ -164,10 +161,9 @@ async function userSearch(req, res, next) {
|
||||
const dn = ldap.parseDN('cn=' + user.id + ',ou=users,dc=cloudron');
|
||||
|
||||
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
|
||||
const nameParts = displayName.split(' ');
|
||||
const firstName = nameParts[0];
|
||||
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists
|
||||
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
|
||||
|
||||
// https://datatracker.ietf.org/doc/html/rfc2798
|
||||
const obj = {
|
||||
dn: dn.toString(),
|
||||
attributes: {
|
||||
@@ -180,19 +176,16 @@ async function userSearch(req, res, next) {
|
||||
mailAlternateAddress: user.fallbackEmail,
|
||||
displayname: displayName,
|
||||
givenName: firstName,
|
||||
sn: lastName,
|
||||
middleName: middleName,
|
||||
username: user.username,
|
||||
samaccountname: user.username, // to support ActiveDirectory clients
|
||||
memberof: allGroups.filter(function (g) { return g.userIds.indexOf(user.id) !== -1; }).map(function (g) { return `cn=${g.name},ou=groups,dc=cloudron`; })
|
||||
}
|
||||
};
|
||||
|
||||
// http://www.zytrax.com/books/ldap/ape/core-schema.html#sn has 'name' as SUP which is a DirectoryString
|
||||
// which is required to have atleast one character if present
|
||||
if (lastName.length !== 0) obj.attributes.sn = lastName;
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
|
||||
results.push(obj);
|
||||
@@ -207,8 +200,8 @@ async function groupSearch(req, res, next) {
|
||||
|
||||
const results = [];
|
||||
|
||||
let [errorGroups, resultGroups] = await safe(groups.listWithMembers());
|
||||
if (errorGroups) return next(new ldap.OperationsError(errorGroups.toString()));
|
||||
let [groupsListError, resultGroups] = await safe(groups.listWithMembers());
|
||||
if (groupsListError) return next(new ldap.OperationsError(groupsListError.message));
|
||||
|
||||
if (req.app.accessRestriction && req.app.accessRestriction.groups) {
|
||||
resultGroups = resultGroups.filter(function (g) { return req.app.accessRestriction.groups.indexOf(g.id) !== -1; });
|
||||
@@ -229,7 +222,7 @@ async function groupSearch(req, res, next) {
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
|
||||
results.push(obj);
|
||||
@@ -243,7 +236,7 @@ async function groupUsersCompare(req, res, next) {
|
||||
debug('group users compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
|
||||
|
||||
const [error, result] = await safe(getUsersWithAccessToApp(req));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
// we only support memberuid here, if we add new group attributes later add them here
|
||||
if (req.attribute === 'memberuid') {
|
||||
@@ -258,7 +251,7 @@ async function groupAdminsCompare(req, res, next) {
|
||||
debug('group admins compare: dn %s, attribute %s, value %s (from %s)', req.dn.toString(), req.attribute, req.value, req.connection.ldap.id);
|
||||
|
||||
const [error, result] = await safe(getUsersWithAccessToApp(req));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
// we only support memberuid here, if we add new group attributes later add them here
|
||||
if (req.attribute === 'memberuid') {
|
||||
@@ -287,9 +280,9 @@ async function mailboxSearch(req, res, next) {
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(dn.toString()));
|
||||
|
||||
const [error, mailbox] = await safe(mail.getMailbox(parts[0], parts[1]));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
if (!mailbox) return next(new ldap.NoSuchObjectError(dn.toString()));
|
||||
if (!mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString()));
|
||||
if (!mailbox.active) return next(new ldap.NoSuchObjectError('Mailbox is not active'));
|
||||
|
||||
const obj = {
|
||||
dn: dn.toString(),
|
||||
@@ -306,7 +299,7 @@ async function mailboxSearch(req, res, next) {
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if (lowerCaseFilter.matches(obj.attributes)) {
|
||||
finalSend([ obj ], req, res, next);
|
||||
@@ -316,7 +309,7 @@ async function mailboxSearch(req, res, next) {
|
||||
} else { // new sogo and dovecot listing (doveadm -A)
|
||||
// TODO figure out how proper pagination here could work
|
||||
let [error, mailboxes] = await safe(mail.listAllMailboxes(1, 100000));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
mailboxes = mailboxes.filter(m => m.active);
|
||||
|
||||
@@ -349,7 +342,7 @@ async function mailboxSearch(req, res, next) {
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && lowerCaseFilter.matches(obj.attributes)) {
|
||||
results.push(obj);
|
||||
@@ -363,17 +356,17 @@ async function mailboxSearch(req, res, next) {
|
||||
async function mailAliasSearch(req, res, next) {
|
||||
debug('mail alias get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
|
||||
|
||||
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
|
||||
|
||||
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
|
||||
const parts = email.split('@');
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
|
||||
|
||||
const [error, alias] = await safe(mail.searchAlias(parts[0], parts[1]));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (!alias) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
if (!alias) return next(new ldap.NoSuchObjectError('No such alias'));
|
||||
|
||||
if (!alias.active) return next(new ldap.NoSuchObjectError(req.dn.toString())); // there is no way to disable an alias. this is just here for completeness
|
||||
if (!alias.active) return next(new ldap.NoSuchObjectError('Mailbox is not active')); // there is no way to disable an alias. this is just here for completeness
|
||||
|
||||
// https://wiki.debian.org/LDAP/MigrationTools/Examples
|
||||
// https://docs.oracle.com/cd/E19455-01/806-5580/6jej518pp/index.html
|
||||
@@ -390,7 +383,7 @@ async function mailAliasSearch(req, res, next) {
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if (lowerCaseFilter.matches(obj.attributes)) {
|
||||
finalSend([ obj ], req, res, next);
|
||||
@@ -402,20 +395,20 @@ async function mailAliasSearch(req, res, next) {
|
||||
async function mailingListSearch(req, res, next) {
|
||||
debug('mailing list get: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
|
||||
|
||||
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
|
||||
|
||||
let email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
|
||||
let parts = email.split('@');
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
|
||||
const name = parts[0], domain = parts[1];
|
||||
|
||||
const [error, result] = await safe(mail.resolveList(parts[0], parts[1]));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError('No such list'));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
const { resolvedMembers, list } = result;
|
||||
|
||||
if (!list.active) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (!list.active) return next(new ldap.NoSuchObjectError('List is not active'));
|
||||
|
||||
// http://ldapwiki.willeke.com/wiki/Original%20Mailgroup%20Schema%20From%20Netscape
|
||||
// members are fully qualified (https://docs.oracle.com/cd/E19444-01/816-6018-10/groups.htm#13356)
|
||||
@@ -433,7 +426,7 @@ async function mailingListSearch(req, res, next) {
|
||||
|
||||
// ensure all filter values are also lowercase
|
||||
const lowerCaseFilter = safe(function () { return ldap.parseFilter(req.filter.toString().toLowerCase()); }, null);
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.toString()));
|
||||
if (!lowerCaseFilter) return next(new ldap.OperationsError(safe.error.message));
|
||||
|
||||
if (lowerCaseFilter.matches(obj.attributes)) {
|
||||
finalSend([ obj ], req, res, next);
|
||||
@@ -457,7 +450,7 @@ async function authorizeUserForApp(req, res, next) {
|
||||
|
||||
const canAccess = apps.canAccess(req.app, req.user);
|
||||
// we return no such object, to avoid leakage of a users existence
|
||||
if (!canAccess) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (!canAccess) return next(new ldap.NoSuchObjectError('Invalid user or insufficient previleges'));
|
||||
|
||||
await eventlog.upsertLoginEvent(req.user.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.LDAP, { appId: req.app.id, userId: req.user.id, user: users.removePrivateFields(req.user) });
|
||||
|
||||
@@ -469,13 +462,13 @@ async function verifyMailboxPassword(mailbox, password) {
|
||||
assert.strictEqual(typeof password, 'string');
|
||||
|
||||
if (mailbox.ownerType === mail.OWNERTYPE_USER) {
|
||||
return await users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */, { relaxedTotpCheck: true });
|
||||
return await users.verify(mailbox.ownerId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true });
|
||||
} else if (mailbox.ownerType === mail.OWNERTYPE_GROUP) {
|
||||
const userIds = await groups.getMembers(mailbox.ownerId);
|
||||
|
||||
let verifiedUser = null;
|
||||
for (const userId of userIds) {
|
||||
const [error, result] = await safe(users.verify(userId, password, users.AP_MAIL /* identifier */, { relaxedTotpCheck: true }));
|
||||
const [error, result] = await safe(users.verify(userId, password, users.AP_MAIL /* identifier */, { skipTotpCheck: true }));
|
||||
if (error) continue; // try the next user
|
||||
verifiedUser = result;
|
||||
break; // found a matching validated user
|
||||
@@ -491,17 +484,17 @@ async function verifyMailboxPassword(mailbox, password) {
|
||||
async function authenticateSftp(req, res, next) {
|
||||
debug('sftp auth: %s (from %s)', req.dn.toString(), req.connection.ldap.id);
|
||||
|
||||
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (!req.dn.rdns[0].attrs.cn) return next(new ldap.NoSuchObjectError('Missing CN'));
|
||||
|
||||
const email = req.dn.rdns[0].attrs.cn.value.toLowerCase();
|
||||
const parts = email.split('@');
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError('Invalid CN'));
|
||||
|
||||
let [error, app] = await safe(apps.getByFqdn(parts[1]));
|
||||
if (error || !app) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
|
||||
if (error || !app) return next(new ldap.InvalidCredentialsError());
|
||||
|
||||
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { relaxedTotpCheck: true }));
|
||||
if (error) return next(new ldap.InvalidCredentialsError(req.dn.toString()));
|
||||
[error] = await safe(users.verifyWithUsername(parts[0], req.credentials, app.id, { skipTotpCheck: true }));
|
||||
if (error) return next(new ldap.InvalidCredentialsError(error.message));
|
||||
|
||||
debug('sftp auth: success');
|
||||
|
||||
@@ -511,16 +504,16 @@ async function authenticateSftp(req, res, next) {
|
||||
async function userSearchSftp(req, res, next) {
|
||||
debug('sftp user search: dn %s, scope %s, filter %s (from %s)', req.dn.toString(), req.scope, req.filter.toString(), req.connection.ldap.id);
|
||||
|
||||
if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (req.filter.attribute !== 'username' || !req.filter.value) return next(new ldap.NoSuchObjectError());
|
||||
|
||||
const parts = req.filter.value.split('@');
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (parts.length !== 2) return next(new ldap.NoSuchObjectError());
|
||||
|
||||
const username = parts[0];
|
||||
const appFqdn = parts[1];
|
||||
|
||||
const [error, app] = await safe(apps.getByFqdn(appFqdn));
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
if (error) return next(new ldap.OperationsError(error.message));
|
||||
|
||||
// only allow apps which specify "ftp" support in the localstorage addon
|
||||
if (!safe.query(app.manifest.addons, 'localstorage.ftp.uid')) return next(new ldap.UnavailableError('Not supported'));
|
||||
@@ -529,7 +522,7 @@ async function userSearchSftp(req, res, next) {
|
||||
const uidNumber = app.manifest.addons.localstorage.ftp.uid;
|
||||
|
||||
const [userGetError, user] = await safe(users.getByUsername(username));
|
||||
if (userGetError) return next(new ldap.OperationsError(userGetError.toString()));
|
||||
if (userGetError) return next(new ldap.OperationsError(userGetError.message));
|
||||
if (!user) return next(new ldap.OperationsError('Invalid username'));
|
||||
|
||||
if (!apps.isOperator(app, user)) return next(new ldap.InsufficientAccessRightsError('Not authorized'));
|
||||
@@ -595,13 +588,13 @@ async function authenticateService(serviceId, dn, req, res, next) {
|
||||
const [appPasswordError] = await safe(verifyAppMailboxPassword(serviceId, email, req.credentials || ''));
|
||||
if (!appPasswordError) return res.end(); // validated as app
|
||||
|
||||
if (appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(dn.toString()));
|
||||
if (appPasswordError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(appPasswordError.message));
|
||||
if (appPasswordError.reason !== BoxError.NOT_FOUND) return next(new ldap.OperationsError(appPasswordError.message));
|
||||
|
||||
if (!mailbox || !mailbox.active) return next(new ldap.NoSuchObjectError(dn.toString())); // user auth requires active mailbox
|
||||
const [verifyError, result] = await safe(verifyMailboxPassword(mailbox, req.credentials || ''));
|
||||
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(dn.toString()));
|
||||
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(dn.toString()));
|
||||
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new ldap.NoSuchObjectError(verifyError.message));
|
||||
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new ldap.InvalidCredentialsError(verifyError.message));
|
||||
if (verifyError) return next(new ldap.OperationsError(verifyError.message));
|
||||
|
||||
eventlog.upsertLoginEvent(result.ghost ? eventlog.ACTION_USER_LOGIN_GHOST : eventlog.ACTION_USER_LOGIN, AuditSource.MAIL, { mailboxId: email, userId: result.id, user: users.removePrivateFields(result) });
|
||||
@@ -610,7 +603,7 @@ async function authenticateService(serviceId, dn, req, res, next) {
|
||||
}
|
||||
|
||||
async function authenticateMail(req, res, next) {
|
||||
if (!req.dn.rdns[1].attrs.ou) return next(new ldap.NoSuchObjectError(req.dn.toString()));
|
||||
if (!req.dn.rdns[1].attrs.ou) return next(new ldap.NoSuchObjectError());
|
||||
await authenticateService(req.dn.rdns[1].attrs.ou.value.toLowerCase(), req.dn, req, res, next);
|
||||
}
|
||||
|
||||
@@ -710,6 +703,6 @@ async function start() {
|
||||
async function stop() {
|
||||
if (!gServer) return;
|
||||
|
||||
gServer.close();
|
||||
await util.promisify(gServer.close.bind(gServer))();
|
||||
gServer = null;
|
||||
}
|
||||
+13
-16
@@ -2,6 +2,7 @@
|
||||
|
||||
const assert = require('assert'),
|
||||
path = require('path'),
|
||||
shell = require('./shell.js'),
|
||||
spawn = require('child_process').spawn,
|
||||
stream = require('stream'),
|
||||
{ StringDecoder } = require('string_decoder'),
|
||||
@@ -25,12 +26,9 @@ class LogStream extends TransformStream {
|
||||
if (isNaN(timestamp)) timestamp = 0;
|
||||
const message = line.slice(data[0].length+1);
|
||||
|
||||
// ignore faulty empty logs
|
||||
if (!timestamp && !message) return;
|
||||
|
||||
return JSON.stringify({
|
||||
realtimeTimestamp: timestamp * 1000,
|
||||
message: message,
|
||||
realtimeTimestamp: timestamp * 1000, // timestamp info can be missing (0) for app logs via logPaths
|
||||
message: message || line, // send the line if message parsing failed
|
||||
source: this._options.source
|
||||
}) + '\n';
|
||||
}
|
||||
@@ -58,24 +56,23 @@ function tail(filePaths, options) {
|
||||
assert(Array.isArray(filePaths));
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
const lines = options.lines === -1 ? '+1' : options.lines,
|
||||
follow = options.follow;
|
||||
const lines = options.lines === -1 ? '+1' : options.lines;
|
||||
const args = [ LOGTAIL_CMD, '--lines=' + lines ];
|
||||
if (options.follow) args.push('--follow');
|
||||
|
||||
const args = [ '--lines=' + lines ];
|
||||
if (follow) args.push('--follow');
|
||||
|
||||
return spawn(LOGTAIL_CMD, args.concat(filePaths));
|
||||
return shell.sudo('tail', args.concat(filePaths), { streamStdout: true }, () => {});
|
||||
}
|
||||
|
||||
function journalctl(unit, options) {
|
||||
assert.strictEqual(typeof unit, 'string');
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
const args = [];
|
||||
args.push('--lines=' + (options.lines === -1 ? 'all' : options.lines));
|
||||
args.push(`--unit=${unit}`);
|
||||
args.push('--no-pager');
|
||||
args.push('--output=short-iso');
|
||||
const args = [
|
||||
'--lines=' + (options.lines === -1 ? 'all' : options.lines),
|
||||
`--unit=${unit}`,
|
||||
'--no-pager',
|
||||
'--output=short-iso'
|
||||
];
|
||||
|
||||
if (options.follow) args.push('--follow');
|
||||
|
||||
|
||||
+1
-1
@@ -179,7 +179,7 @@ async function checkOutboundPort25() {
|
||||
return await new Promise((resolve) => {
|
||||
const client = new net.Socket();
|
||||
client.setTimeout(5000);
|
||||
client.connect(25, constants.PORT25_CHECK_SERVER);
|
||||
client.connect({ port: 25, host: constants.PORT25_CHECK_SERVER, family: 4 }); // family is 4 to keep it predictable
|
||||
client.on('connect', function () {
|
||||
relay.status = true;
|
||||
relay.value = 'OK';
|
||||
|
||||
+17
-11
@@ -31,6 +31,7 @@ const assert = require('assert'),
|
||||
docker = require('./docker.js'),
|
||||
domains = require('./domains.js'),
|
||||
eventlog = require('./eventlog.js'),
|
||||
fs = require('fs'),
|
||||
hat = require('./hat.js'),
|
||||
infra = require('./infra_version.js'),
|
||||
Location = require('./location.js'),
|
||||
@@ -53,8 +54,8 @@ async function generateDkimKey() {
|
||||
const privateKeyFilePath = path.join(os.tmpdir(), `dkim-${crypto.randomBytes(4).readUInt32LE(0)}.private`);
|
||||
|
||||
// https://www.unlocktheinbox.com/dkim-key-length-statistics/ and https://docs.aws.amazon.com/ses/latest/DeveloperGuide/send-email-authentication-dkim-easy.html for key size
|
||||
if (!safe.child_process.execSync(`openssl genrsa -out ${privateKeyFilePath} 1024`)) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
||||
if (!safe.child_process.execSync(`openssl rsa -in ${privateKeyFilePath} -out ${publicKeyFilePath} -pubout -outform PEM`)) return new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
||||
await shell.exec('generateDkimKey', `openssl genrsa -out ${privateKeyFilePath} 1024`, {});
|
||||
await shell.exec('generateDkimKey', `openssl rsa -in ${privateKeyFilePath} -out ${publicKeyFilePath} -pubout -outform PEM`, {});
|
||||
|
||||
const publicKey = safe.fs.readFileSync(publicKeyFilePath, 'utf8');
|
||||
if (!publicKey) throw new BoxError(BoxError.FS_ERROR, safe.error.message);
|
||||
@@ -153,15 +154,19 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
|
||||
const mailCertFilePath = `${paths.MAIL_CONFIG_DIR}/tls_cert.pem`;
|
||||
const mailKeyFilePath = `${paths.MAIL_CONFIG_DIR}/tls_key.pem`;
|
||||
|
||||
if (!safe.child_process.execSync(`cp ${paths.DHPARAMS_FILE} ${dhparamsFilePath}`)) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${safe.error.message}`);
|
||||
const [readError, dhparams] = await safe(fs.promises.readFile(paths.DHPARAMS_FILE));
|
||||
if (readError) throw new BoxError(BoxError.FS_ERROR, `Could not read dhparams: ${readError.message}`);
|
||||
const [copyError] = await safe(fs.promises.writeFile(dhparamsFilePath, dhparams));
|
||||
if (copyError) throw new BoxError(BoxError.FS_ERROR, `Could not copy dhparams: ${copyError.message}`);
|
||||
if (!safe.fs.writeFileSync(mailCertFilePath, certificate.cert)) throw new BoxError(BoxError.FS_ERROR, `Could not create cert file: ${safe.error.message}`);
|
||||
if (!safe.fs.writeFileSync(mailKeyFilePath, certificate.key)) throw new BoxError(BoxError.FS_ERROR, `Could not create key file: ${safe.error.message}`);
|
||||
|
||||
// if the 'yellowtent' user of OS and the 'cloudron' user of mail container don't match, the keys become inaccessible by mail code
|
||||
if (!safe.fs.chmodSync(mailKeyFilePath, 0o644)) throw new BoxError(BoxError.FS_ERROR, `Could not chmod key file: ${safe.error.message}`);
|
||||
|
||||
await shell.promises.exec('stopMail', 'docker stop mail || true');
|
||||
await shell.promises.exec('removeMail', 'docker rm -f mail || true');
|
||||
debug('configureMail: stopping and deleting previous mail container');
|
||||
await docker.stopContainer('mail');
|
||||
await docker.deleteContainer('mail');
|
||||
|
||||
const allowInbound = await createMailConfig(mailFqdn);
|
||||
|
||||
@@ -170,7 +175,7 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
|
||||
const cmd = serviceConfig.recoveryMode ? '/bin/bash -c \'echo "Debug mode. Sleeping" && sleep infinity\'' : '';
|
||||
const logLevel = serviceConfig.recoveryMode ? 'data' : 'info';
|
||||
|
||||
const runCmd = `docker run --restart=always -d --name="mail" \
|
||||
const runCmd = `docker run --restart=always -d --name=mail \
|
||||
--net cloudron \
|
||||
--net-alias mail \
|
||||
--log-driver syslog \
|
||||
@@ -181,16 +186,17 @@ async function configureMail(mailFqdn, mailDomain, serviceConfig) {
|
||||
--memory-swap ${memoryLimit} \
|
||||
--dns 172.18.0.1 \
|
||||
--dns-search=. \
|
||||
-e CLOUDRON_MAIL_TOKEN="${cloudronToken}" \
|
||||
-e CLOUDRON_RELAY_TOKEN="${relayToken}" \
|
||||
-e CLOUDRON_MAIL_TOKEN=${cloudronToken} \
|
||||
-e CLOUDRON_RELAY_TOKEN=${relayToken} \
|
||||
-e LOGLEVEL=${logLevel} \
|
||||
-v "${paths.MAIL_DATA_DIR}:/app/data" \
|
||||
-v "${paths.MAIL_CONFIG_DIR}:/etc/mail:ro" \
|
||||
-v ${paths.MAIL_DATA_DIR}:/app/data \
|
||||
-v ${paths.MAIL_CONFIG_DIR}:/etc/mail:ro \
|
||||
${ports} \
|
||||
--label isCloudronManaged=true \
|
||||
${readOnly} -v /run -v /tmp ${image} ${cmd}`;
|
||||
|
||||
await shell.promises.exec('startMail', runCmd);
|
||||
debug('configureMail: starting mail container');
|
||||
await shell.exec('configureMail', runCmd, { shell: '/bin/bash' });
|
||||
}
|
||||
|
||||
async function restart() {
|
||||
|
||||
+8
-8
@@ -71,7 +71,7 @@ function isManagedProvider(provider) {
|
||||
// nfs - no_root_squash is mode on server to map all root to 'nobody' user. all_squash does this for all users (making it like ftp)
|
||||
// sshfs - supports users/permissions
|
||||
// cifs - does not support permissions
|
||||
function renderMountFile(mount) {
|
||||
async function renderMountFile(mount) {
|
||||
assert.strictEqual(typeof mount, 'object');
|
||||
|
||||
const { name, hostPath, mountType, mountOptions } = mount;
|
||||
@@ -79,8 +79,7 @@ function renderMountFile(mount) {
|
||||
let options, what, type;
|
||||
switch (mountType) {
|
||||
case 'cifs': {
|
||||
const out = safe.child_process.execSync(`systemd-escape -p '${hostPath}'`, { encoding: 'utf8' }); // this ensures uniqueness of creds file
|
||||
if (!out) throw new BoxError(BoxError.FS_ERROR, `Could not determine credentials file name: ${safe.error.message}`);
|
||||
const out = await shell.execArgs('renderMountFile', 'systemd-escape', [ '-p', hostPath ], {}); // this ensures uniqueness of creds file
|
||||
const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
|
||||
if (!safe.fs.writeFileSync(credentialsFilePath, `username=${mountOptions.username}\npassword=${mountOptions.password}\n`, { mode: 0o600 })) throw new BoxError(BoxError.FS_ERROR, `Could not write credentials file: ${safe.error.message}`);
|
||||
|
||||
@@ -139,8 +138,7 @@ async function removeMount(mount) {
|
||||
const keyFilePath = path.join(paths.SSHFS_KEYS_DIR, `id_rsa_${mountOptions.host}`);
|
||||
safe.fs.unlinkSync(keyFilePath);
|
||||
} else if (mountType === 'cifs') {
|
||||
const out = safe.child_process.execSync(`systemd-escape -p '${hostPath}'`, { encoding: 'utf8' });
|
||||
if (!out) return;
|
||||
const out = await shell.execArgs('removeMount', 'systemd-escape', [ '-p', hostPath ], {});
|
||||
const credentialsFilePath = path.join(paths.CIFS_CREDENTIALS_DIR, `${out.trim()}.cred`);
|
||||
safe.fs.unlinkSync(credentialsFilePath);
|
||||
}
|
||||
@@ -152,7 +150,8 @@ async function getStatus(mountType, hostPath) {
|
||||
|
||||
if (mountType === 'filesystem') return { state: 'active', message: 'Mounted' };
|
||||
|
||||
const state = safe.child_process.execSync(`mountpoint -q -- ${hostPath}`) ? 'active' : 'inactive';
|
||||
const [error] = await safe(shell.execArgs('getVolumeStatus', 'mountpoint', [ '-q', '--', hostPath ], { timeout: 5000 }));
|
||||
const state = error ? 'inactive' : 'active';
|
||||
|
||||
if (mountType === 'mountpoint') return { state, message: state === 'active' ? 'Mounted' : 'Not mounted' };
|
||||
|
||||
@@ -160,7 +159,7 @@ async function getStatus(mountType, hostPath) {
|
||||
let message;
|
||||
|
||||
if (state !== 'active') { // find why it failed
|
||||
const logsJson = safe.child_process.execSync(`journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o json`, { encoding: 'utf8' });
|
||||
const logsJson = await shell.exec('getStatus', `journalctl -u $(systemd-escape -p --suffix=mount ${hostPath}) -n 10 --no-pager -o json`, { shell: '/bin/bash' });
|
||||
|
||||
if (logsJson) {
|
||||
const lines = logsJson.trim().split('\n').map(l => JSON.parse(l)); // array of json
|
||||
@@ -195,7 +194,8 @@ async function tryAddMount(mount, options) {
|
||||
|
||||
if (constants.TEST) return;
|
||||
|
||||
const [error] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, renderMountFile(mount), options.timeout ], {}));
|
||||
const mountFileContents = await renderMountFile(mount);
|
||||
const [error] = await safe(shell.promises.sudo('addMount', [ ADD_MOUNT_CMD, mountFileContents, options.timeout ], {}));
|
||||
if (error && error.code === 2) throw new BoxError(BoxError.MOUNT_ERROR, 'Failed to unmount existing mount'); // at this point, the old mount config is still there
|
||||
|
||||
if (options.skipCleanup) return;
|
||||
|
||||
+3
-3
@@ -93,7 +93,7 @@ async function setBlocklist(blocklist, auditSource) {
|
||||
}
|
||||
|
||||
if (count >= 262144) throw new BoxError(BoxError.CONFLICT, 'Blocklist is too large. Max 262144 entries are allowed'); // see the cloudron-firewall.sh
|
||||
if (constants.DEMO) throw new BoxError(BoxError.CONFLICT, 'Not allowed in demo mode');
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
// store in blob since the value field is TEXT and has 16kb size limit
|
||||
await settings.setBlob(settings.FIREWALL_BLOCKLIST_KEY, Buffer.from(blocklist));
|
||||
@@ -125,7 +125,7 @@ async function getIPv4Config() {
|
||||
async function setIPv4Config(ipv4Config) {
|
||||
assert.strictEqual(typeof ipv4Config, 'object');
|
||||
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
const error = await testIPv4Config(ipv4Config);
|
||||
if (error) throw error;
|
||||
@@ -141,7 +141,7 @@ async function getIPv6Config() {
|
||||
async function setIPv6Config(ipv6Config) {
|
||||
assert.strictEqual(typeof ipv6Config, 'object');
|
||||
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_FIELD, 'Not allowed in demo mode');
|
||||
if (constants.DEMO) throw new BoxError(BoxError.BAD_STATE, 'Not allowed in demo mode');
|
||||
|
||||
const error = await testIPv6Config(ipv6Config);
|
||||
if (error) throw error;
|
||||
|
||||
+9
-5
@@ -20,6 +20,7 @@ const assert = require('assert'),
|
||||
blobs = require('./blobs.js'),
|
||||
branding = require('./branding.js'),
|
||||
constants = require('./constants.js'),
|
||||
crypto = require('crypto'),
|
||||
dashboard = require('./dashboard.js'),
|
||||
database = require('./database.js'),
|
||||
debug = require('debug')('box:oidc'),
|
||||
@@ -522,7 +523,7 @@ function interactionLogin(provider) {
|
||||
|
||||
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
|
||||
|
||||
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken }));
|
||||
const [verifyError, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false }));
|
||||
if (verifyError && verifyError.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, verifyError.message));
|
||||
if (verifyError && verifyError.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Username and password does not match'));
|
||||
if (verifyError) return next(new HttpError(500, verifyError));
|
||||
@@ -647,18 +648,21 @@ async function claims(userId/*, use, scope*/) {
|
||||
if (error) return { error: 'user not found' };
|
||||
|
||||
const displayName = user.displayName || user.username || ''; // displayName can be empty and username can be null
|
||||
const nameParts = displayName.split(' ');
|
||||
const firstName = nameParts[0];
|
||||
const lastName = nameParts.length > 1 ? nameParts[nameParts.length - 1] : ''; // choose last part, if it exists
|
||||
const { firstName, lastName, middleName } = users.parseDisplayName(displayName);
|
||||
|
||||
const { fqdn:dashboardFqdn } = await dashboard.getLocation();
|
||||
|
||||
// https://openid.net/specs/openid-connect-core-1_0.html#StandardClaims
|
||||
const claims = {
|
||||
sub: user.username, // it is essential to always return a sub claim
|
||||
email: user.email,
|
||||
email_verified: true,
|
||||
family_name: lastName,
|
||||
middle_name: middleName,
|
||||
given_name: firstName,
|
||||
locale: 'en-US',
|
||||
name: user.displayName,
|
||||
picture: `https://${dashboardFqdn}/api/v1/profile/avatar/${user.id}`,
|
||||
preferred_username: user.username
|
||||
};
|
||||
|
||||
@@ -723,7 +727,7 @@ async function start() {
|
||||
let cookieSecret = await settings.get(settings.OIDC_COOKIE_SECRET_KEY);
|
||||
if (!cookieSecret) {
|
||||
debug('Generating new cookie secret');
|
||||
cookieSecret = require('crypto').randomBytes(256).toString('base64');
|
||||
cookieSecret = crypto.randomBytes(256).toString('base64');
|
||||
await settings.set(settings.OIDC_COOKIE_SECRET_KEY, cookieSecret);
|
||||
}
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@
|
||||
</div>
|
||||
<div class="card-form-bottom-bar">
|
||||
<a href="/passwordreset.html">{{ login.resetPasswordAction }}</a>
|
||||
<button class="btn btn-primary btn-outline" type="submit" id="loginSubmitButton">{{ login.signInAction }}</button>
|
||||
<button class="btn btn-primary btn-outline" type="submit" id="loginSubmitButton"><i id="busyIndicator" class="hide fa fa-circle-notch fa-spin"></i> {{ login.signInAction }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -103,6 +103,7 @@ document.getElementById('loginForm').addEventListener('submit', function (event)
|
||||
document.getElementById('passwordError').classList.add('hide');
|
||||
document.getElementById('totpError').classList.add('hide');
|
||||
document.getElementById('internalError').classList.add('hide');
|
||||
document.getElementById('busyIndicator').classList.remove('hide');
|
||||
|
||||
var apiUrl = '<%= submitUrl %>';
|
||||
console.log('submit', apiUrl);
|
||||
@@ -123,20 +124,20 @@ document.getElementById('loginForm').addEventListener('submit', function (event)
|
||||
return res.json(); // we always return objects
|
||||
}).then(function (data) {
|
||||
if (res.status === 401) {
|
||||
if (data.message === 'Username and password does not match') {
|
||||
document.getElementById('inputPassword').value = '';
|
||||
document.getElementById('inputPassword').focus();
|
||||
document.getElementById('passwordError').classList.remove('hide');
|
||||
return;
|
||||
} else if (data.message.indexOf('totpToken') !== -1) {
|
||||
if (data.message.indexOf('totpToken') !== -1) {
|
||||
document.getElementById('inputTotpToken').value = '';
|
||||
document.getElementById('inputTotpToken').focus();
|
||||
document.getElementById('totpError').classList.remove('hide');
|
||||
return;
|
||||
} else {
|
||||
throw new Error('Something went wrong');
|
||||
document.getElementById('inputPassword').value = '';
|
||||
document.getElementById('inputPassword').focus();
|
||||
document.getElementById('passwordError').classList.remove('hide');
|
||||
}
|
||||
|
||||
document.getElementById('busyIndicator').classList.add('hide');
|
||||
return;
|
||||
} else if (res.status !== 200) {
|
||||
document.getElementById('busyIndicator').classList.add('hide');
|
||||
throw new Error('Something went wrong');
|
||||
}
|
||||
|
||||
@@ -144,6 +145,7 @@ document.getElementById('loginForm').addEventListener('submit', function (event)
|
||||
else console.log('login success but missing redirectTo in data:', data);
|
||||
}).catch(function (error) {
|
||||
document.getElementById('internalError').classList.remove('hide');
|
||||
document.getElementById('busyIndicator').classList.add('hide');
|
||||
console.warn(error, res);
|
||||
});
|
||||
});
|
||||
|
||||
+23
-11
@@ -49,10 +49,10 @@ async function pruneInfraImages() {
|
||||
|
||||
// cannot blindly remove all unused images since redis image may not be used
|
||||
const imageNames = Object.keys(infra.images).map(addon => infra.images[addon]);
|
||||
const output = safe.child_process.execSync('docker images --digests --format "{{.ID}} {{.Repository}} {{.Tag}} {{.Digest}}"', { encoding: 'utf8' });
|
||||
if (output === null) {
|
||||
debug(`Failed to list images ${safe.error.message}`);
|
||||
throw safe.error;
|
||||
const [error, output] = await safe(shell.exec('pruneInfraImages', 'docker images --digests --format "{{.ID}} {{.Repository}} {{.Tag}} {{.Digest}}"', { shell: '/bin/bash' }));
|
||||
if (error) {
|
||||
debug(`Failed to list images ${error.message}`);
|
||||
throw error;
|
||||
}
|
||||
const lines = output.trim().split('\n');
|
||||
|
||||
@@ -69,8 +69,8 @@ async function pruneInfraImages() {
|
||||
const imageIdToPrune = tag === '<none>' ? `${repo}@${digest}` : `${repo}:${tag}`; // untagged, use digest
|
||||
console.log(`pruneInfraImages: removing unused image of ${imageName}: ${imageIdToPrune}`);
|
||||
|
||||
const result = safe.child_process.execSync(`docker rmi '${imageIdToPrune}'`, { encoding: 'utf8' });
|
||||
if (result === null) console.log(`Error removing image ${imageIdToPrune}: ${safe.error.mesage}`);
|
||||
const [error] = await safe(shell.execArgs('pruneInfraImages', 'docker', [ 'rmi', imageIdToPrune ], {}));
|
||||
if (error) console.log(`Error removing image ${imageIdToPrune}: ${error.mesage}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -78,16 +78,16 @@ async function pruneInfraImages() {
|
||||
async function createDockerNetwork() {
|
||||
debug('createDockerNetwork: recreating docker network');
|
||||
|
||||
await shell.promises.exec('createDockerNetwork', 'docker network rm cloudron || true');
|
||||
await shell.exec('createDockerNetwork', 'docker network rm -f cloudron', {});
|
||||
// the --ipv6 option will work even in ipv6 is disabled. fd00 is IPv6 ULA
|
||||
await shell.promises.exec('createDockerNetwork', `docker network create --subnet=${constants.DOCKER_IPv4_SUBNET} --ip-range=${constants.DOCKER_IPv4_RANGE} --gateway ${constants.DOCKER_IPv4_GATEWAY} --ipv6 --subnet=fd00:c107:d509::/64 cloudron`);
|
||||
await shell.exec('createDockerNetwork', `docker network create --subnet=${constants.DOCKER_IPv4_SUBNET} --ip-range=${constants.DOCKER_IPv4_RANGE} --gateway ${constants.DOCKER_IPv4_GATEWAY} --ipv6 --subnet=fd00:c107:d509::/64 cloudron`, {});
|
||||
}
|
||||
|
||||
async function removeAllContainers() {
|
||||
debug('removeAllContainers: removing all containers for infra upgrade');
|
||||
|
||||
await shell.promises.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker stop');
|
||||
await shell.promises.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker rm -f');
|
||||
await shell.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker stop', { shell: '/bin/bash' });
|
||||
await shell.exec('removeAllContainers', 'docker ps -qa --filter \'label=isCloudronManaged\' | xargs --no-run-if-empty docker rm -f', { shell: '/bin/bash' });
|
||||
}
|
||||
|
||||
async function markApps(existingInfra, restoreOptions) {
|
||||
@@ -181,7 +181,7 @@ async function startInfra(restoreOptions) {
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
debug('initializing platform');
|
||||
debug('initialize: start platform');
|
||||
|
||||
await database.initialize();
|
||||
await tasks.stopAllTasks();
|
||||
@@ -207,6 +207,8 @@ async function initialize() {
|
||||
async function uninitialize() {
|
||||
debug('uninitializing platform');
|
||||
|
||||
if (await users.isActivated()) await onDeactivated();
|
||||
|
||||
await cron.stopJobs();
|
||||
await dockerProxy.stop();
|
||||
await tasks.stopAllTasks();
|
||||
@@ -230,6 +232,16 @@ async function onActivated(restoreOptions) {
|
||||
// the UI some time to query the dashboard domain in the restore code path
|
||||
if (!constants.TEST) await timers.setTimeout(30000);
|
||||
await reverseProxy.writeDefaultConfig({ activated :true });
|
||||
|
||||
debug('onActivated: finished');
|
||||
}
|
||||
|
||||
async function onDeactivated() {
|
||||
debug('onDeactivated: stopping post activation services');
|
||||
|
||||
await cron.stopJobs();
|
||||
await dockerProxy.stop();
|
||||
await oidc.stop();
|
||||
}
|
||||
|
||||
async function onDashboardLocationChanged(auditSource) {
|
||||
|
||||
+2
-2
@@ -24,6 +24,7 @@ const assert = require('assert'),
|
||||
platform = require('./platform.js'),
|
||||
reverseProxy = require('./reverseproxy.js'),
|
||||
safe = require('safetydance'),
|
||||
shell = require('./shell.js'),
|
||||
semver = require('semver'),
|
||||
paths = require('./paths.js'),
|
||||
system = require('./system.js'),
|
||||
@@ -57,8 +58,7 @@ function setProgress(task, message) {
|
||||
async function ensureDhparams() {
|
||||
if (fs.existsSync(paths.DHPARAMS_FILE)) return;
|
||||
debug('ensureDhparams: generating dhparams');
|
||||
const dhparams = safe.child_process.execSync('openssl dhparam -dsaparam 2048');
|
||||
if (!dhparams) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
||||
const dhparams = await shell.exec('ensureDhParams', 'openssl dhparam -dsaparam 2048', {});
|
||||
if (!safe.fs.writeFileSync(paths.DHPARAMS_FILE, dhparams)) throw new BoxError(BoxError.FS_ERROR, `Could not save dhparams.pem: ${safe.error.message}`);
|
||||
}
|
||||
|
||||
|
||||
+2
-2
@@ -68,7 +68,7 @@ async function authorizationHeader(req, res, next) {
|
||||
if (!app.manifest.addons.proxyAuth.basicAuth) return next(); // this is a flag because this allows auth to bypass 2FA
|
||||
|
||||
const verifyFunc = credentials.name.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername;
|
||||
const [verifyError, user] = await safe(verifyFunc(credentials.name, credentials.pass, appId, { relaxedTotpCheck: true }));
|
||||
const [verifyError, user] = await safe(verifyFunc(credentials.name, credentials.pass, appId, { skipTotpCheck: true }));
|
||||
if (verifyError) return next(new HttpError(403, 'Invalid username or password' ));
|
||||
|
||||
req.user = user;
|
||||
@@ -166,7 +166,7 @@ async function passwordAuth(req, res, next) {
|
||||
const { username, password, totpToken } = req.body;
|
||||
|
||||
const verifyFunc = username.indexOf('@') !== -1 ? users.verifyWithEmail : users.verifyWithUsername;
|
||||
const [error, user] = await safe(verifyFunc(username, password, appId, { totpToken }));
|
||||
const [error, user] = await safe(verifyFunc(username, password, appId, { totpToken, skipTotpCheck: false }));
|
||||
if (error) return next(new HttpError(403, error.message));
|
||||
|
||||
req.user = user;
|
||||
|
||||
+32
-36
@@ -60,7 +60,6 @@ const acme2 = require('./acme2.js'),
|
||||
settings = require('./settings.js'),
|
||||
shell = require('./shell.js'),
|
||||
tasks = require('./tasks.js'),
|
||||
util = require('util'),
|
||||
validator = require('validator');
|
||||
|
||||
const NGINX_APPCONFIG_EJS = fs.readFileSync(__dirname + '/nginxconfig.ejs', { encoding: 'utf8' });
|
||||
@@ -73,13 +72,13 @@ function nginxLocation(s) {
|
||||
return `~ ^(?!(${re.slice(1)}))`; // negative regex assertion - https://stackoverflow.com/questions/16302897/nginx-location-not-equal-to-regex
|
||||
}
|
||||
|
||||
function getCertificateDatesSync(cert) {
|
||||
async function getCertificateDates(cert) {
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
|
||||
const result = safe.child_process.spawnSync('/usr/bin/openssl', [ 'x509', '-startdate', '-enddate', '-subject', '-noout' ], { input: cert, encoding: 'utf8' });
|
||||
if (!result) return { startDate: null, endDate: null } ; // some error
|
||||
const [error, result] = await safe(shell.exec('getCertificateDates', 'openssl x509 -startdate -enddate -subject -noout', { input: cert }));
|
||||
if (error) return { startDate: null, endDate: null } ; // some error
|
||||
|
||||
const lines = result.stdout.trim().split('\n');
|
||||
const lines = result.trim().split('\n');
|
||||
const notBefore = lines[0].split('=')[1];
|
||||
const notBeforeDate = new Date(notBefore);
|
||||
|
||||
@@ -104,17 +103,17 @@ async function isOcspEnabled(certFilePath) {
|
||||
|
||||
// We used to check for the must-staple in the cert using openssl x509 -text -noout -in ${certFilePath} | grep -q status_request
|
||||
// however, we cannot set the must-staple because first request to nginx fails because of it's OCSP caching behavior
|
||||
const result = safe.child_process.execSync(`openssl x509 -in ${certFilePath} -noout -ocsp_uri`, { encoding: 'utf8' });
|
||||
return result && result.length > 0; // no error and has uri
|
||||
const [error, result] = await safe(shell.exec('isOscpEnabled', `openssl x509 -in ${certFilePath} -noout -ocsp_uri`, {}));
|
||||
return !error && result.length > 0; // no error and has uri
|
||||
}
|
||||
|
||||
// checks if the certificate matches the options provided by user (like wildcard, le-staging etc)
|
||||
function providerMatchesSync(domainObject, cert) {
|
||||
async function providerMatches(domainObject, cert) {
|
||||
assert.strictEqual(typeof domainObject, 'object');
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
|
||||
const subjectAndIssuer = safe.child_process.execSync('/usr/bin/openssl x509 -noout -subject -issuer', { encoding: 'utf8', input: cert });
|
||||
if (!subjectAndIssuer) return false; // something bad happenned
|
||||
const [error, subjectAndIssuer] = await safe(shell.exec('providerMatches', 'openssl x509 -noout -subject -issuer', { input: cert }));
|
||||
if (error) return false; // something bad happenned
|
||||
|
||||
const subject = subjectAndIssuer.match(/^subject=(.*)$/m)[1];
|
||||
const domain = subject.substr(subject.indexOf('=') + 1).trim(); // subject can be /CN=, CN=, CN = and other forms
|
||||
@@ -131,7 +130,7 @@ function providerMatchesSync(domainObject, cert) {
|
||||
|
||||
const mismatch = issuerMismatch || wildcardMismatch;
|
||||
|
||||
debug(`providerMatchesSync: subject=${subject} domain=${domain} issuer=${issuer} `
|
||||
debug(`providerMatches: subject=${subject} domain=${domain} issuer=${issuer} `
|
||||
+ `wildcard=${isWildcardCert}/${wildcard} prod=${isLetsEncryptProd}/${prod} `
|
||||
+ `issuerMismatch=${issuerMismatch} wildcardMismatch=${wildcardMismatch} match=${!mismatch}`);
|
||||
|
||||
@@ -140,7 +139,7 @@ function providerMatchesSync(domainObject, cert) {
|
||||
|
||||
// note: https://tools.ietf.org/html/rfc4346#section-7.4.2 (certificate_list) requires that the
|
||||
// servers certificate appears first (and not the intermediate cert)
|
||||
function validateCertificate(subdomain, domain, certificate) {
|
||||
async function validateCertificate(subdomain, domain, certificate) {
|
||||
assert.strictEqual(typeof subdomain, 'string');
|
||||
assert.strictEqual(typeof domain, 'string');
|
||||
assert(certificate && typeof certificate, 'object');
|
||||
@@ -148,29 +147,27 @@ function validateCertificate(subdomain, domain, certificate) {
|
||||
const { cert, key } = certificate;
|
||||
|
||||
// check for empty cert and key strings
|
||||
if (!cert && key) return new BoxError(BoxError.BAD_FIELD, 'missing cert');
|
||||
if (cert && !key) return new BoxError(BoxError.BAD_FIELD, 'missing key');
|
||||
if (!cert && key) throw new BoxError(BoxError.BAD_FIELD, 'missing cert');
|
||||
if (cert && !key) throw new BoxError(BoxError.BAD_FIELD, 'missing key');
|
||||
|
||||
// -checkhost checks for SAN or CN exclusively. SAN takes precedence and if present, ignores the CN.
|
||||
const fqdn = dns.fqdn(subdomain, domain);
|
||||
|
||||
let result = safe.child_process.execSync(`openssl x509 -noout -checkhost "${fqdn}"`, { encoding: 'utf8', input: cert });
|
||||
if (result === null) return new BoxError(BoxError.BAD_FIELD, 'Unable to get certificate subject:' + safe.error.message);
|
||||
|
||||
if (result.indexOf('does match certificate') === -1) return new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`);
|
||||
const [checkHostError, checkHostOutput] = await safe(shell.exec('validateCertificate', `openssl x509 -noout -checkhost ${fqdn}`, { input: cert }));
|
||||
if (checkHostError) throw new BoxError(BoxError.BAD_FIELD, 'Could not validate certificate');
|
||||
if (checkHostOutput.indexOf('does match certificate') === -1) throw new BoxError(BoxError.BAD_FIELD, `Certificate is not valid for this domain. Expecting ${fqdn}`);
|
||||
|
||||
// check if public key in the cert and private key matches. pkey below works for RSA and ECDSA keys
|
||||
const pubKeyFromCert = safe.child_process.execSync('openssl x509 -noout -pubkey', { encoding: 'utf8', input: cert });
|
||||
if (pubKeyFromCert === null) return new BoxError(BoxError.BAD_FIELD, `Unable to get public key from certificate: ${safe.error.message}`);
|
||||
const [pubKeyError1, pubKeyFromCert] = await safe(shell.exec('validateCertificate', 'openssl x509 -noout -pubkey', { input: cert }));
|
||||
if (pubKeyError1) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from cert');
|
||||
const [pubKeyError2, pubKeyFromKey] = await safe(shell.exec('validateCertificate', 'openssl pkey -pubout', { input: key }));
|
||||
if (pubKeyError2) throw new BoxError(BoxError.BAD_FIELD, 'Could not get public key from private key');
|
||||
|
||||
const pubKeyFromKey = safe.child_process.execSync('openssl pkey -pubout', { encoding: 'utf8', input: key });
|
||||
if (pubKeyFromKey === null) return new BoxError(BoxError.BAD_FIELD, `Unable to get public key from private key: ${safe.error.message}`);
|
||||
|
||||
if (pubKeyFromCert !== pubKeyFromKey) return new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.');
|
||||
if (pubKeyFromCert !== pubKeyFromKey) throw new BoxError(BoxError.BAD_FIELD, 'Public key does not match the certificate.');
|
||||
|
||||
// check expiration
|
||||
result = safe.child_process.execSync('openssl x509 -checkend 0', { encoding: 'utf8', input: cert });
|
||||
if (!result) return new BoxError(BoxError.BAD_FIELD, 'Certificate has expired.');
|
||||
const [error] = await safe(shell.exec('validateCertificate', 'openssl x509 -checkend 0', { input: cert }));
|
||||
if (error) throw new BoxError(BoxError.BAD_FIELD, 'Certificate has expired');
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -209,8 +206,8 @@ async function generateFallbackCertificate(domain) {
|
||||
const configFile = path.join(os.tmpdir(), 'openssl-' + crypto.randomBytes(4).readUInt32LE(0) + '.conf');
|
||||
safe.fs.writeFileSync(configFile, opensslConfWithSan, 'utf8');
|
||||
// the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
|
||||
const certCommand = util.format(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=*.${cn} -extensions SAN -config ${configFile} -nodes`);
|
||||
if (!safe.child_process.execSync(certCommand)) throw new BoxError(BoxError.OPENSSL_ERROR, safe.error.message);
|
||||
const certCommand = `openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=*.${cn} -extensions SAN -config ${configFile} -nodes`;
|
||||
await shell.exec('generateFallbackCertificate', certCommand, {});
|
||||
safe.fs.unlinkSync(configFile);
|
||||
|
||||
const cert = safe.fs.readFileSync(certFilePath, 'utf8');
|
||||
@@ -267,11 +264,11 @@ function getAcmeCertificateNameSync(fqdn, domainObject) {
|
||||
}
|
||||
}
|
||||
|
||||
function needsRenewalSync(cert, options) {
|
||||
async function needsRenewal(cert, options) {
|
||||
assert.strictEqual(typeof cert, 'string');
|
||||
assert.strictEqual(typeof options, 'object');
|
||||
|
||||
const { startDate, endDate } = getCertificateDatesSync(cert);
|
||||
const { startDate, endDate } = await getCertificateDates(cert);
|
||||
const now = new Date();
|
||||
|
||||
let isExpiring;
|
||||
@@ -433,7 +430,9 @@ async function ensureCertificate(location, options, auditSource) {
|
||||
const cert = await blobs.getString(`${blobs.CERT_PREFIX}-${certName}.cert`);
|
||||
|
||||
if (key && cert) {
|
||||
if (providerMatchesSync(domainObject, cert) && !needsRenewalSync(cert, options)) {
|
||||
const sameProvider = await providerMatches(domainObject, cert);
|
||||
const outdated = await needsRenewal(cert, options);
|
||||
if (sameProvider && !outdated) {
|
||||
debug(`ensureCertificate: ${fqdn} acme cert exists and is up to date`);
|
||||
return;
|
||||
}
|
||||
@@ -629,7 +628,7 @@ async function cleanupCerts(locations, auditSource, progressCallback) {
|
||||
if (certNamesInUse.has(certName)) continue;
|
||||
|
||||
const cert = await blobs.getString(certId);
|
||||
const { endDate } = getCertificateDatesSync(cert);
|
||||
const { endDate } = await getCertificateDates(cert);
|
||||
if (!endDate) continue; // some error
|
||||
|
||||
if (now - endDate >= (60 * 60 * 24 * 30 * 6 * 1000)) { // expired 6 months ago and not in use
|
||||
@@ -736,10 +735,7 @@ async function writeDefaultConfig(options) {
|
||||
|
||||
const cn = 'cloudron-' + (new Date()).toISOString(); // randomize date a bit to keep firefox happy
|
||||
// the days field is chosen to be less than 825 days per apple requirement (https://support.apple.com/en-us/HT210176)
|
||||
if (!safe.child_process.execSync(`openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=${cn} -nodes`)) {
|
||||
debug(`writeDefaultConfig: could not generate certificate: ${safe.error.message}`);
|
||||
throw new BoxError(BoxError.OPENSSL_ERROR, safe.error);
|
||||
}
|
||||
await shell.exec('writeDefaultConfig', `openssl req -x509 -newkey rsa:2048 -keyout ${keyFilePath} -out ${certFilePath} -days 800 -subj /CN=${cn} -nodes`, {});
|
||||
}
|
||||
|
||||
const data = {
|
||||
|
||||
@@ -27,7 +27,7 @@ async function passwordAuth(req, res, next) {
|
||||
|
||||
const verifyFunc = username.indexOf('@') === -1 ? users.verifyWithUsername : users.verifyWithEmail;
|
||||
|
||||
let [error, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken }));
|
||||
let [error, user] = await safe(verifyFunc(username, password, users.AP_WEBADMIN, { totpToken, skipTotpCheck: false }));
|
||||
if (error && error.reason === BoxError.INVALID_CREDENTIALS) return next(new HttpError(401, error.message));
|
||||
if (error && error.reason === BoxError.NOT_FOUND) return next(new HttpError(401, 'Unauthorized'));
|
||||
if (error) return next(new HttpError(500, error));
|
||||
|
||||
+4
-4
@@ -669,8 +669,6 @@ async function getLogStream(req, res, next) {
|
||||
const lines = 'lines' in req.query ? parseInt(req.query.lines, 10) : 10; // we ignore last-event-id
|
||||
if (isNaN(lines)) return next(new HttpError(400, 'lines must be a valid number'));
|
||||
|
||||
function sse(id, data) { return 'id: ' + id + '\ndata: ' + data + '\n\n'; }
|
||||
|
||||
if (req.headers.accept !== 'text/event-stream') return next(new HttpError(400, 'This API call requires EventStream'));
|
||||
|
||||
const options = {
|
||||
@@ -690,10 +688,11 @@ async function getLogStream(req, res, next) {
|
||||
'Access-Control-Allow-Origin': '*'
|
||||
});
|
||||
res.write('retry: 3000\n');
|
||||
res.on('close', logStream.close);
|
||||
res.on('close', () => logStream.destroy());
|
||||
logStream.on('data', function (data) {
|
||||
const obj = JSON.parse(data);
|
||||
res.write(sse(obj.realtimeTimestamp, JSON.stringify(obj))); // send timestamp as id
|
||||
const sse = `data: ${JSON.stringify(obj)}\n\n`;
|
||||
res.write(sse);
|
||||
});
|
||||
logStream.on('end', res.end.bind(res));
|
||||
logStream.on('error', res.end.bind(res, null));
|
||||
@@ -720,6 +719,7 @@ async function getLogs(req, res, next) {
|
||||
'Cache-Control': 'no-cache',
|
||||
'X-Accel-Buffering': 'no' // disable nginx buffering
|
||||
});
|
||||
res.on('close', () => logStream.destroy());
|
||||
logStream.pipe(res);
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user