Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9266302c4c | |||
| 755dce7bc4 | |||
| dd3e38ae55 | |||
| 9dfaa2d20f | |||
| d6a4ff23e2 | |||
| c2ab7e2c1f | |||
| b9e4662dbb | |||
| 10df0a527f | |||
| 9aad3688e1 | |||
| e78dbcb5d4 | |||
| 5e8cd09f51 | |||
| 22f65a9364 | |||
| 81b7432044 | |||
| d49b90d9f2 | |||
| 9face9cf35 | |||
| 33ac34296e | |||
| 670ffcd489 | |||
| ec7b365c31 | |||
| 433d78c7ff | |||
| ed041fdca6 | |||
| b8e4ed2369 | |||
| d12f260d12 | |||
| ba7989b57b | |||
| 88df410f5b | |||
| 2436db3b1f | |||
| d15874df63 | |||
| 8fb90254cd | |||
| cbd712c20e | |||
| 8c004798f2 | |||
| c1b0cbe78d | |||
| 5ee72c8e98 | |||
| c125cc17dc | |||
| 18feff1bfb | |||
| f74f713bbd | |||
| 0ea14db172 | |||
| 74785a40d5 | |||
| dcfcd5be84 | |||
| 814674eac5 | |||
| 1a7fff9867 | |||
| 30b248a0f6 | |||
| 7168455de3 | |||
| 085f63e3c7 | |||
| 015be64923 | |||
| 2c2471811d | |||
| 1025249e93 | |||
| 41ffc4bcf3 | |||
| 2739d54cc1 | |||
| c4c463cbc2 | |||
| 8cd13bd43f | |||
| e4ef279759 | |||
| cf7fecb57b | |||
| 226041dcb1 | |||
| 7548025561 | |||
| fdbee427ee | |||
| d861d6d6e4 | |||
| 0a648edcaa | |||
| 18850c1fba | |||
| f6df4cab67 | |||
| 019d29c5b7 | |||
| 0b4256a992 | |||
| 7d58d69389 | |||
| 864dd5bf26 | |||
| abdde7a950 | |||
| 8bcbd860be | |||
| be61c42fe8 | |||
| 6d5afc2d75 | |||
| 88d905e8cc | |||
| d8ccc766b9 | |||
| d22e0f0483 | |||
| c8f6973312 | |||
| 3f0f0048bc | |||
| 88643f0875 | |||
| e11bb10bb8 | |||
| 7b9930c7f0 | |||
| da48e32bcc | |||
| 57e2803bd2 | |||
| 0d1ba01d65 | |||
| 95cbec19af | |||
| cc97654b23 | |||
| 5bb983f175 | |||
| 7cb6434de1 | |||
| cb1b495da2 |
+9
-34
@@ -7,48 +7,23 @@
|
||||
// !! No console.log() allowed
|
||||
// !! Do not set DEBUG
|
||||
|
||||
var supervisor = require('supervisord-eventlistener'),
|
||||
assert = require('assert'),
|
||||
exec = require('child_process').exec,
|
||||
util = require('util'),
|
||||
fs = require('fs'),
|
||||
mailer = require('./src/mailer.js');
|
||||
var assert = require('assert'),
|
||||
mailer = require('./src/mailer.js'),
|
||||
safe = require('safetydance'),
|
||||
supervisor = require('supervisord-eventlistener'),
|
||||
path = require('path'),
|
||||
util = require('util');
|
||||
|
||||
var gLastNotifyTime = {};
|
||||
var gCooldownTime = 1000 * 60 * 5; // 5 min
|
||||
var COLLECT_LOGS_CMD = path.join(__dirname, 'src/scripts/collectlogs.sh');
|
||||
|
||||
function collectLogs(program, callback) {
|
||||
assert.strictEqual(typeof program, 'string');
|
||||
assert.strictEqual(typeof callback, 'function');
|
||||
|
||||
var logFilePath = util.format('/var/log/supervisor/%s.log', program);
|
||||
|
||||
if (!fs.existsSync(logFilePath)) return callback(new Error(util.format('Log file %s does not exist.', logFilePath)));
|
||||
|
||||
fs.readFile(logFilePath, 'utf-8', function (error, data) {
|
||||
if (error) return callback(error);
|
||||
|
||||
var lines = data.split('\n');
|
||||
var boxLogLines = lines.slice(-100);
|
||||
|
||||
exec('dmesg', function (error, stdout /*, stderr */) {
|
||||
if (error) console.error(error);
|
||||
|
||||
var lines = stdout.split('\n');
|
||||
var dmesgLogLines = lines.slice(-100);
|
||||
|
||||
var result = '';
|
||||
result += program + '.log\n';
|
||||
result += '-------------------------------------\n';
|
||||
result += boxLogLines.join('\n');
|
||||
result += '\n\n';
|
||||
result += 'dmesg\n';
|
||||
result += '-------------------------------------\n';
|
||||
result += dmesgLogLines.join('\n');
|
||||
|
||||
callback(null, result);
|
||||
});
|
||||
});
|
||||
var logs = safe.child_process.execSync('sudo ' + COLLECT_LOGS_CMD + ' ' + program, { encoding: 'utf8' });
|
||||
callback(null, logs);
|
||||
}
|
||||
|
||||
supervisor.on('PROCESS_STATE_EXITED', function (headers, data) {
|
||||
|
||||
Generated
+747
-708
File diff suppressed because it is too large
Load Diff
+4
-5
@@ -18,9 +18,9 @@
|
||||
"dependencies": {
|
||||
"async": "^1.2.1",
|
||||
"body-parser": "^1.13.1",
|
||||
"cloudron-manifestformat": "^1.4.0",
|
||||
"cloudron-manifestformat": "^1.6.0",
|
||||
"connect-ensure-login": "^0.1.1",
|
||||
"connect-lastmile": "0.0.12",
|
||||
"connect-lastmile": "0.0.13",
|
||||
"connect-timeout": "^1.5.0",
|
||||
"cookie-parser": "^1.3.5",
|
||||
"cookie-session": "^1.1.0",
|
||||
@@ -35,7 +35,7 @@
|
||||
"express-session": "^1.11.3",
|
||||
"hat": "0.0.3",
|
||||
"json": "^9.0.3",
|
||||
"ldapjs": "git+https://github.com/mcavage/node-ldapjs.git#acc1ca8f4314fd9d67561feabc8ce4c235076a5e",
|
||||
"ldapjs": "^0.7.1",
|
||||
"memorystream": "^0.3.0",
|
||||
"mime": "^1.3.4",
|
||||
"morgan": "^1.6.0",
|
||||
@@ -43,7 +43,6 @@
|
||||
"mysql": "^2.7.0",
|
||||
"native-dns": "^0.7.0",
|
||||
"node-uuid": "^1.4.3",
|
||||
"nodejs-disks": "^0.2.1",
|
||||
"nodemailer": "^1.3.0",
|
||||
"nodemailer-smtp-transport": "^1.0.3",
|
||||
"oauth2orize": "^1.0.1",
|
||||
@@ -55,7 +54,7 @@
|
||||
"passport-oauth2-client-password": "^0.1.2",
|
||||
"password-generator": "^1.0.0",
|
||||
"proxy-middleware": "^0.13.0",
|
||||
"safetydance": "0.0.16",
|
||||
"safetydance": "0.0.19",
|
||||
"semver": "^4.3.6",
|
||||
"serve-favicon": "^2.2.0",
|
||||
"split": "^1.0.0",
|
||||
|
||||
+12
-1
@@ -3,4 +3,15 @@
|
||||
# If you change the infra version, be sure to put a warning
|
||||
# in the change log
|
||||
|
||||
INFRA_VERSION=4
|
||||
INFRA_VERSION=8
|
||||
|
||||
# WARNING WARNING WARNING WARNING WARNING WARNING WARNING WARNING
|
||||
# These constants are used in the installer script as well
|
||||
BASE_IMAGE=cloudron/base:0.3.1
|
||||
MYSQL_IMAGE=cloudron/mysql:0.3.2
|
||||
POSTGRESQL_IMAGE=cloudron/postgresql:0.3.1
|
||||
MONGODB_IMAGE=cloudron/mongodb:0.3.1
|
||||
REDIS_IMAGE=cloudron/redis:0.3.1 # if you change this, fix src/addons.js as well
|
||||
MAIL_IMAGE=cloudron/mail:0.3.1
|
||||
GRAPHITE_IMAGE=cloudron/graphite:0.3.3
|
||||
|
||||
|
||||
@@ -34,6 +34,9 @@ ln -sfF "${DATA_DIR}/collectd" /etc/collectd
|
||||
unlink /etc/nginx 2>/dev/null || rm -rf /etc/nginx
|
||||
ln -s "${DATA_DIR}/nginx" /etc/nginx
|
||||
|
||||
########## mysql
|
||||
cp "${container_files}/mysql.cnf" /etc/mysql/mysql.cnf
|
||||
|
||||
########## Enable services
|
||||
update-rc.d -f collectd defaults
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
!includedir /etc/mysql/conf.d/
|
||||
!includedir /etc/mysql/mysql.conf.d/
|
||||
|
||||
# http://bugs.mysql.com/bug.php?id=68514
|
||||
[mysqld]
|
||||
performance_schema=OFF
|
||||
max_connection=50
|
||||
@@ -24,3 +24,6 @@ yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/reloadcollectd.
|
||||
|
||||
Defaults!/home/yellowtent/box/src/scripts/backupswap.sh env_keep="HOME BOX_ENV"
|
||||
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/backupswap.sh
|
||||
|
||||
Defaults!/home/yellowtent/box/src/scripts/collectlogs.sh env_keep="HOME BOX_ENV"
|
||||
yellowtent ALL=(root) NOPASSWD: /home/yellowtent/box/src/scripts/collectlogs.sh
|
||||
|
||||
@@ -41,6 +41,9 @@ mkdir -p "${DATA_DIR}/box/appicons"
|
||||
mkdir -p "${DATA_DIR}/box/mail"
|
||||
mkdir -p "${DATA_DIR}/graphite"
|
||||
|
||||
mkdir -p "${DATA_DIR}/mysql"
|
||||
mkdir -p "${DATA_DIR}/postgresql"
|
||||
mkdir -p "${DATA_DIR}/mongodb"
|
||||
mkdir -p "${DATA_DIR}/snapshots"
|
||||
mkdir -p "${DATA_DIR}/addons"
|
||||
mkdir -p "${DATA_DIR}/collectd/collectd.conf.d"
|
||||
@@ -53,6 +56,9 @@ echo "{ \"version\": \"${arg_version}\", \"boxVersionsUrl\": \"${arg_box_version
|
||||
echo "Cleaning up snapshots"
|
||||
find "${DATA_DIR}/snapshots" -mindepth 1 -maxdepth 1 | xargs --no-run-if-empty btrfs subvolume delete
|
||||
|
||||
# restart mysql to make sure it has latest config
|
||||
service mysql restart
|
||||
|
||||
readonly mysql_root_password="password"
|
||||
mysqladmin -u root -ppassword password password # reset default root password
|
||||
mysql -u root -p${mysql_root_password} -e 'CREATE DATABASE IF NOT EXISTS box'
|
||||
@@ -83,6 +89,10 @@ EOF
|
||||
|
||||
set_progress "28" "Setup collectd"
|
||||
cp "${script_dir}/start/collectd.conf" "${DATA_DIR}/collectd/collectd.conf"
|
||||
# collectd 5.4.1 has some bug where we simply cannot get it to create df-vda1
|
||||
mkdir -p "${DATA_DIR}/graphite/whisper/collectd/localhost/"
|
||||
vda1_id=$(blkid -s UUID -o value /dev/vda1)
|
||||
ln -sfF "df-disk_by-uuid_${vda1_id}" "${DATA_DIR}/graphite/whisper/collectd/localhost/df-vda1"
|
||||
service collectd restart
|
||||
|
||||
set_progress "30" "Setup nginx"
|
||||
|
||||
@@ -193,12 +193,11 @@ LoadPlugin write_graphite
|
||||
</Plugin>
|
||||
|
||||
<Plugin df>
|
||||
Device "/dev/vda1"
|
||||
Device "/dev/loop0"
|
||||
Device "/dev/loop1"
|
||||
FSType "tmpfs"
|
||||
MountPoint "/dev"
|
||||
|
||||
ReportByDevice true
|
||||
IgnoreSelected false
|
||||
IgnoreSelected true
|
||||
|
||||
ValuesAbsolute true
|
||||
ValuesPercentage true
|
||||
|
||||
@@ -27,11 +27,13 @@ if [[ -n "${existing_containers}" ]]; then
|
||||
fi
|
||||
|
||||
# graphite
|
||||
docker run --restart=always -d --name="graphite" \
|
||||
graphite_container_id=$(docker run --restart=always -d --name="graphite" \
|
||||
-p 127.0.0.1:2003:2003 \
|
||||
-p 127.0.0.1:2004:2004 \
|
||||
-p 127.0.0.1:8000:8000 \
|
||||
-v "${DATA_DIR}/graphite:/app/data" cloudron/graphite:0.3.1
|
||||
-v "${DATA_DIR}/graphite:/app/data" \
|
||||
"${GRAPHITE_IMAGE}")
|
||||
echo "Graphite container id: ${graphite_container_id}"
|
||||
|
||||
# mail
|
||||
mail_container_id=$(docker run --restart=always -d --name="mail" \
|
||||
@@ -39,7 +41,7 @@ mail_container_id=$(docker run --restart=always -d --name="mail" \
|
||||
-h "${arg_fqdn}" \
|
||||
-e "DOMAIN_NAME=${arg_fqdn}" \
|
||||
-v "${DATA_DIR}/box/mail:/app/data" \
|
||||
cloudron/mail:0.3.0)
|
||||
"${MAIL_IMAGE}")
|
||||
echo "Mail container id: ${mail_container_id}"
|
||||
|
||||
# mysql
|
||||
@@ -52,8 +54,8 @@ EOF
|
||||
mysql_container_id=$(docker run --restart=always -d --name="mysql" \
|
||||
-h "${arg_fqdn}" \
|
||||
-v "${DATA_DIR}/mysql:/var/lib/mysql" \
|
||||
-v "${DATA_DIR}/addons/mysql_vars.sh:/etc/mysql/mysql_vars.sh:r" \
|
||||
cloudron/mysql:0.3.0)
|
||||
-v "${DATA_DIR}/addons/mysql_vars.sh:/etc/mysql/mysql_vars.sh:ro" \
|
||||
"${MYSQL_IMAGE}")
|
||||
echo "MySQL container id: ${mysql_container_id}"
|
||||
|
||||
# postgresql
|
||||
@@ -64,8 +66,8 @@ EOF
|
||||
postgresql_container_id=$(docker run --restart=always -d --name="postgresql" \
|
||||
-h "${arg_fqdn}" \
|
||||
-v "${DATA_DIR}/postgresql:/var/lib/postgresql" \
|
||||
-v "${DATA_DIR}/addons/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh:r" \
|
||||
cloudron/postgresql:0.3.0)
|
||||
-v "${DATA_DIR}/addons/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh:ro" \
|
||||
"${POSTGRESQL_IMAGE}")
|
||||
echo "PostgreSQL container id: ${postgresql_container_id}"
|
||||
|
||||
# mongodb
|
||||
@@ -76,8 +78,8 @@ EOF
|
||||
mongodb_container_id=$(docker run --restart=always -d --name="mongodb" \
|
||||
-h "${arg_fqdn}" \
|
||||
-v "${DATA_DIR}/mongodb:/var/lib/mongodb" \
|
||||
-v "${DATA_DIR}/addons/mongodb_vars.sh:/etc/mongodb_vars.sh:r" \
|
||||
cloudron/mongodb:0.3.0)
|
||||
-v "${DATA_DIR}/addons/mongodb_vars.sh:/etc/mongodb_vars.sh:ro" \
|
||||
"${MONGODB_IMAGE}")
|
||||
echo "Mongodb container id: ${mongodb_container_id}"
|
||||
|
||||
if [[ "${infra_version}" == "none" ]]; then
|
||||
|
||||
+2
-2
@@ -665,7 +665,7 @@ function setupRedis(app, callback) {
|
||||
name: 'redis-' + app.id,
|
||||
Hostname: config.appFqdn(app.location),
|
||||
Tty: true,
|
||||
Image: 'cloudron/redis:0.3.0',
|
||||
Image: 'cloudron/redis:0.3.1',
|
||||
Cmd: null,
|
||||
Volumes: {},
|
||||
VolumesFrom: []
|
||||
@@ -675,7 +675,7 @@ function setupRedis(app, callback) {
|
||||
|
||||
var startOptions = {
|
||||
Binds: [
|
||||
redisVarsFile + ':/etc/redis/redis_vars.sh:r',
|
||||
redisVarsFile + ':/etc/redis/redis_vars.sh:ro',
|
||||
redisDataDir + ':/var/lib/redis:rw'
|
||||
],
|
||||
// On Mac (boot2docker), we have to export the port to external world for port forwarding from Mac to work
|
||||
|
||||
+5
-5
@@ -675,16 +675,16 @@ function autoupdateApps(updateInfo, callback) { // updateInfo is { appId -> { ma
|
||||
|
||||
function backupApp(app, addonsToBackup, callback) {
|
||||
assert.strictEqual(typeof app, 'object');
|
||||
assert.strictEqual(typeof addonsToBackup, 'object');
|
||||
assert(!addonsToBackup || typeof addonsToBackup, 'object');
|
||||
assert.strictEqual(typeof callback, 'function');
|
||||
|
||||
function canBackupApp(app) {
|
||||
// only backup apps that are installed or pending configure. Rest of them are in some
|
||||
// state not good for consistent backup (i.e addons may not have been setup completely)
|
||||
return (app.installationState === appdb.ISTATE_INSTALLED && app.health === appdb.HEALTH_HEALTHY)
|
||||
|| app.installationState === appdb.ISTATE_PENDING_CONFIGURE
|
||||
|| app.installationState === appdb.ISTATE_PENDING_BACKUP
|
||||
|| app.installationState === appdb.ISTATE_PENDING_UPDATE; // called from apptask
|
||||
return (app.installationState === appdb.ISTATE_INSTALLED && app.health === appdb.HEALTH_HEALTHY) ||
|
||||
app.installationState === appdb.ISTATE_PENDING_CONFIGURE ||
|
||||
app.installationState === appdb.ISTATE_PENDING_BACKUP ||
|
||||
app.installationState === appdb.ISTATE_PENDING_UPDATE; // called from apptask
|
||||
}
|
||||
|
||||
if (!canBackupApp(app)) return callback(new AppsError(AppsError.BAD_STATE, 'App not healthy'));
|
||||
|
||||
+3
-5
@@ -189,7 +189,6 @@ function createContainer(app, callback) {
|
||||
}
|
||||
|
||||
env.push('CLOUDRON=1');
|
||||
env.push('ADMIN_ORIGIN' + '=' + config.adminOrigin()); // ## remove
|
||||
env.push('WEBADMIN_ORIGIN' + '=' + config.adminOrigin());
|
||||
env.push('API_ORIGIN' + '=' + config.adminOrigin());
|
||||
|
||||
@@ -202,8 +201,6 @@ function createContainer(app, callback) {
|
||||
Tty: true,
|
||||
Image: app.manifest.dockerImage,
|
||||
Cmd: null,
|
||||
Volumes: {},
|
||||
VolumesFrom: [],
|
||||
Env: env.concat(addonEnv),
|
||||
ExposedPorts: exposedPorts
|
||||
};
|
||||
@@ -342,7 +339,8 @@ function startContainer(app, callback) {
|
||||
RestartPolicy: {
|
||||
"Name": "always",
|
||||
"MaximumRetryCount": 0
|
||||
}
|
||||
},
|
||||
CpuShares: 512 // relative to 1024 for system processes
|
||||
};
|
||||
|
||||
var container = docker.getContainer(app.containerId);
|
||||
@@ -673,7 +671,7 @@ function restore(app, callback) {
|
||||
|
||||
// note that configure is called after an infra update as well
|
||||
function configure(app, callback) {
|
||||
var locationChanged = app.oldConfig.location !== app.location;
|
||||
var locationChanged = app.oldConfig ? app.oldConfig.location !== app.location : true;
|
||||
|
||||
async.series([
|
||||
updateApp.bind(null, app, { installationProgress: '10, Cleaning up old install' }),
|
||||
|
||||
+1
-3
@@ -19,8 +19,7 @@ exports = module.exports = {
|
||||
reboot: reboot,
|
||||
migrate: migrate,
|
||||
backup: backup,
|
||||
ensureBackup: ensureBackup
|
||||
};
|
||||
ensureBackup: ensureBackup};
|
||||
|
||||
var apps = require('./apps.js'),
|
||||
AppsError = require('./apps.js').AppsError,
|
||||
@@ -634,4 +633,3 @@ function backupBoxAndApps(callback) {
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
LoadPlugin "table"
|
||||
<Plugin table>
|
||||
<Table "/sys/fs/cgroup/memory/docker/<%= containerId %>/memory.stat">
|
||||
<Table "/sys/fs/cgroup/memory/system.slice/docker-<%= containerId %>.scope/memory.stat">
|
||||
Instance "<%= appId %>-memory"
|
||||
Separator " \\n"
|
||||
<Result>
|
||||
@@ -10,7 +10,7 @@ LoadPlugin "table"
|
||||
</Result>
|
||||
</Table>
|
||||
|
||||
<Table "/sys/fs/cgroup/memory/docker/<%= containerId %>/memory.max_usage_in_bytes">
|
||||
<Table "/sys/fs/cgroup/memory/system.slice/docker-<%= containerId %>.scope/memory.max_usage_in_bytes">
|
||||
Instance "<%= appId %>-memory"
|
||||
Separator "\\n"
|
||||
<Result>
|
||||
@@ -20,7 +20,7 @@ LoadPlugin "table"
|
||||
</Result>
|
||||
</Table>
|
||||
|
||||
<Table "/sys/fs/cgroup/cpuacct/docker/<%= containerId %>/cpuacct.stat">
|
||||
<Table "/sys/fs/cgroup/cpuacct/system.slice/docker-<%= containerId %>.scope/cpuacct.stat">
|
||||
Instance "<%= appId %>-cpu"
|
||||
Separator " \\n"
|
||||
<Result>
|
||||
|
||||
+32
-15
@@ -24,6 +24,9 @@ var gLogger = {
|
||||
fatal: console.error
|
||||
};
|
||||
|
||||
var GROUP_USERS_DN = 'cn=users,ou=groups,dc=cloudron';
|
||||
var GROUP_ADMINS_DN = 'cn=admin,ou=groups,dc=cloudron';
|
||||
|
||||
function start(callback) {
|
||||
assert(typeof callback === 'function');
|
||||
|
||||
@@ -39,6 +42,9 @@ function start(callback) {
|
||||
result.forEach(function (entry) {
|
||||
var dn = ldap.parseDN('cn=' + entry.id + ',ou=users,dc=cloudron');
|
||||
|
||||
var groups = [ GROUP_USERS_DN ];
|
||||
if (entry.admin) groups.push(GROUP_ADMINS_DN);
|
||||
|
||||
var tmp = {
|
||||
dn: dn.toString(),
|
||||
attributes: {
|
||||
@@ -49,7 +55,8 @@ function start(callback) {
|
||||
mail: entry.email,
|
||||
displayname: entry.username,
|
||||
username: entry.username,
|
||||
samaccountname: entry.username // to support ActiveDirectory clients
|
||||
samaccountname: entry.username, // to support ActiveDirectory clients
|
||||
memberof: groups
|
||||
}
|
||||
};
|
||||
|
||||
@@ -69,22 +76,32 @@ function start(callback) {
|
||||
user.list(function (error, result){
|
||||
if (error) return next(new ldap.OperationsError(error.toString()));
|
||||
|
||||
// we only have an admin group
|
||||
var dn = ldap.parseDN('cn=admin,ou=groups,dc=cloudron');
|
||||
var groups = [{
|
||||
name: 'users',
|
||||
admin: false
|
||||
}, {
|
||||
name: 'admins',
|
||||
admin: true
|
||||
}];
|
||||
|
||||
var tmp = {
|
||||
dn: dn.toString(),
|
||||
attributes: {
|
||||
objectclass: ['group'],
|
||||
cn: 'admin',
|
||||
memberuid: result.filter(function (entry) { return entry.admin; }).map(function(entry) { return entry.id; })
|
||||
groups.forEach(function (group) {
|
||||
var dn = ldap.parseDN('cn=' + group.name + ',ou=groups,dc=cloudron');
|
||||
var members = group.admin ? result.filter(function (entry) { return entry.admin; }) : result;
|
||||
|
||||
var tmp = {
|
||||
dn: dn.toString(),
|
||||
attributes: {
|
||||
objectclass: ['group'],
|
||||
cn: group.name,
|
||||
memberuid: members.map(function(entry) { return entry.id; })
|
||||
}
|
||||
};
|
||||
|
||||
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(tmp.attributes)) {
|
||||
res.send(tmp);
|
||||
debug('ldap group send:', tmp);
|
||||
}
|
||||
};
|
||||
|
||||
if ((req.dn.equals(dn) || req.dn.parentOf(dn)) && req.filter.matches(tmp.attributes)) {
|
||||
res.send(tmp);
|
||||
debug('ldap group send:', tmp);
|
||||
}
|
||||
});
|
||||
|
||||
res.end();
|
||||
});
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
<%if (format === 'text') { %>
|
||||
|
||||
New <%= type %> from <%= fqdn %>.
|
||||
|
||||
Sender: <%= user.email %>
|
||||
Sent at: <%= new Date().toUTCString() %>
|
||||
|
||||
Subject: <%= subject %>
|
||||
-----------------------------------------------------------
|
||||
<%= description %>
|
||||
|
||||
<% } else { %>
|
||||
|
||||
<% } %>
|
||||
|
||||
+24
-1
@@ -15,7 +15,12 @@ exports = module.exports = {
|
||||
|
||||
sendCrashNotification: sendCrashNotification,
|
||||
|
||||
appDied: appDied
|
||||
appDied: appDied,
|
||||
|
||||
FEEDBACK_TYPE_FEEDBACK: 'feedback',
|
||||
FEEDBACK_TYPE_TICKET: 'ticket',
|
||||
FEEDBACK_TYPE_APP: 'app',
|
||||
sendFeedback: sendFeedback
|
||||
};
|
||||
|
||||
var assert = require('assert'),
|
||||
@@ -277,3 +282,21 @@ function sendCrashNotification(program, context) {
|
||||
|
||||
enqueue(mailOptions);
|
||||
}
|
||||
|
||||
function sendFeedback(user, type, subject, description) {
|
||||
assert.strictEqual(typeof user, 'object');
|
||||
assert.strictEqual(typeof type, 'string');
|
||||
assert.strictEqual(typeof subject, 'string');
|
||||
assert.strictEqual(typeof description, 'string');
|
||||
|
||||
assert(type === exports.FEEDBACK_TYPE_TICKET || type === exports.FEEDBACK_TYPE_FEEDBACK || type === exports.FEEDBACK_TYPE_APP);
|
||||
|
||||
var mailOptions = {
|
||||
from: config.get('adminEmail'),
|
||||
to: 'support@cloudron.io',
|
||||
subject: util.format('[%s] %s - %s', type, config.fqdn(), subject),
|
||||
text: render('feedback.ejs', { fqdn: config.fqdn(), type: type, user: user, subject: subject, description: description, format: 'text'})
|
||||
};
|
||||
|
||||
enqueue(mailOptions);
|
||||
}
|
||||
|
||||
@@ -25,4 +25,4 @@
|
||||
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<body class="oauth">
|
||||
|
||||
+34
-22
@@ -1,32 +1,42 @@
|
||||
<% include header %>
|
||||
|
||||
<center>
|
||||
<h1>Login to <%= applicationName %></h1>
|
||||
</center>
|
||||
|
||||
<% if (error) { %>
|
||||
<center>
|
||||
<br/><br/>
|
||||
<h4 class="has-error"><%= error %></h4>
|
||||
</center>
|
||||
<% } %>
|
||||
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-md-6 col-md-offset-3">
|
||||
<form id="loginForm" action="" method="post">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="inputUsername">Username or Email</label>
|
||||
<input type="text" class="form-control" id="inputUsername" name="username" autofocus required>
|
||||
<div class="card">
|
||||
<div class="row">
|
||||
<div class="col-md-12" style="text-align: center;">
|
||||
<img width="128" height="128" src="<%= applicationLogo %>"/>
|
||||
<h1>Login to <%= applicationName %> on <%= cloudronName %></h1>
|
||||
<br/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="inputPassword">Password</label>
|
||||
<input type="password" class="form-control" name="password" id="inputPassword" required>
|
||||
<br/>
|
||||
<% if (error) { %>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h4 class="has-error"><%= error %></h4>
|
||||
</div>
|
||||
</div>
|
||||
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Sign in"/>
|
||||
</form>
|
||||
<a href="/api/v1/session/password/resetRequest.html">Reset your password</a>
|
||||
<% } %>
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<form id="loginForm" action="" method="post">
|
||||
<input type="hidden" name="_csrf" value="<%= csrf %>"/>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="inputUsername">Username or Email</label>
|
||||
<input type="text" class="form-control" id="inputUsername" name="username" autofocus required>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="control-label" for="inputPassword">Password</label>
|
||||
<input type="password" class="form-control" name="password" id="inputPassword" required>
|
||||
</div>
|
||||
<input class="btn btn-primary btn-outline pull-right" type="submit" value="Sign in"/>
|
||||
</form>
|
||||
<a href="/api/v1/session/password/resetRequest.html">Reset your password</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -34,6 +44,8 @@
|
||||
<script>
|
||||
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var search = window.location.search.slice(1).split('&').map(function (item) { return item.split('='); }).reduce(function (o, k) { o[k[0]] = k[1]; return o; }, {});
|
||||
|
||||
document.getElementById('loginForm').action = '/api/v1/session/login?returnTo=' + search.returnTo;
|
||||
|
||||
+15
-1
@@ -11,13 +11,15 @@ exports = module.exports = {
|
||||
getConfig: getConfig,
|
||||
update: update,
|
||||
migrate: migrate,
|
||||
setCertificate: setCertificate
|
||||
setCertificate: setCertificate,
|
||||
feedback: feedback
|
||||
};
|
||||
|
||||
var assert = require('assert'),
|
||||
cloudron = require('../cloudron.js'),
|
||||
config = require('../config.js'),
|
||||
progress = require('../progress.js'),
|
||||
mailer = require('../mailer.js'),
|
||||
CloudronError = cloudron.CloudronError,
|
||||
debug = require('debug')('box:routes/cloudron'),
|
||||
HttpError = require('connect-lastmile').HttpError,
|
||||
@@ -157,3 +159,15 @@ function setCertificate(req, res, next) {
|
||||
next(new HttpSuccess(202, {}));
|
||||
});
|
||||
}
|
||||
|
||||
function feedback(req, res, next) {
|
||||
assert.strictEqual(typeof req.user, 'object');
|
||||
|
||||
if (req.body.type !== mailer.FEEDBACK_TYPE_FEEDBACK && req.body.type !== mailer.FEEDBACK_TYPE_TICKET && req.body.type !== mailer.FEEDBACK_TYPE_APP) return next(new HttpError(400, 'type must be either "ticket", "feedback" or "app"'));
|
||||
if (typeof req.body.subject !== 'string' || !req.body.subject) return next(new HttpError(400, 'subject must be string'));
|
||||
if (typeof req.body.description !== 'string' || !req.body.description) return next(new HttpError(400, 'description must be string'));
|
||||
|
||||
mailer.sendFeedback(req.user, req.body.type, req.body.subject, req.body.description);
|
||||
|
||||
next(new HttpSuccess(201, {}));
|
||||
}
|
||||
|
||||
+31
-20
@@ -16,6 +16,7 @@ var assert = require('assert'),
|
||||
querystring = require('querystring'),
|
||||
util = require('util'),
|
||||
session = require('connect-ensure-login'),
|
||||
settings = require('../settings.js'),
|
||||
tokendb = require('../tokendb'),
|
||||
appdb = require('../appdb'),
|
||||
url = require('url'),
|
||||
@@ -188,37 +189,47 @@ function loginForm(req, res) {
|
||||
var u = url.parse(req.session.returnTo, true);
|
||||
if (!u.query.client_id) return sendErrorPageOrRedirect(req, res, 'Invalid login request. No client_id provided.');
|
||||
|
||||
function render(applicationName) {
|
||||
var cloudronName = '';
|
||||
|
||||
function render(applicationName, applicationLogo) {
|
||||
res.render('login', {
|
||||
adminOrigin: config.adminOrigin(),
|
||||
csrf: req.csrfToken(),
|
||||
cloudronName: cloudronName,
|
||||
applicationName: applicationName,
|
||||
applicationLogo: applicationLogo,
|
||||
error: req.query.error || null
|
||||
});
|
||||
}
|
||||
|
||||
clientdb.get(u.query.client_id, function (error, result) {
|
||||
if (error) return sendError(req, res, 'Unknown OAuth client');
|
||||
settings.getCloudronName(function (error, name) {
|
||||
if (error) return sendError(req, res, 'Internal Error');
|
||||
|
||||
// Handle our different types of oauth clients
|
||||
var appId = result.appId;
|
||||
if (appId === constants.ADMIN_CLIENT_ID) {
|
||||
return render(constants.ADMIN_NAME);
|
||||
} else if (appId === constants.TEST_CLIENT_ID) {
|
||||
return render(constants.TEST_NAME);
|
||||
} else if (appId.indexOf('external-') === 0) {
|
||||
return render('External Application');
|
||||
} else if (appId.indexOf('addon-') === 0) {
|
||||
appId = appId.slice('addon-'.length);
|
||||
} else if (appId.indexOf('proxy-') === 0) {
|
||||
appId = appId.slice('proxy-'.length);
|
||||
}
|
||||
cloudronName = name;
|
||||
|
||||
appdb.get(appId, function (error, result) {
|
||||
if (error) return sendErrorPageOrRedirect(req, res, 'Unknown Application for those OAuth credentials');
|
||||
clientdb.get(u.query.client_id, function (error, result) {
|
||||
if (error) return sendError(req, res, 'Unknown OAuth client');
|
||||
|
||||
var applicationName = result.location || config.fqdn();
|
||||
render(applicationName);
|
||||
// Handle our different types of oauth clients
|
||||
var appId = result.appId;
|
||||
if (appId === constants.ADMIN_CLIENT_ID) {
|
||||
return render(constants.ADMIN_NAME, '/api/v1/cloudron/avatar');
|
||||
} else if (appId === constants.TEST_CLIENT_ID) {
|
||||
return render(constants.TEST_NAME, '/api/v1/cloudron/avatar');
|
||||
} else if (appId.indexOf('external-') === 0) {
|
||||
return render('External Application', '/api/v1/cloudron/avatar');
|
||||
} else if (appId.indexOf('addon-') === 0) {
|
||||
appId = appId.slice('addon-'.length);
|
||||
} else if (appId.indexOf('proxy-') === 0) {
|
||||
appId = appId.slice('proxy-'.length);
|
||||
}
|
||||
|
||||
appdb.get(appId, function (error, result) {
|
||||
if (error) return sendErrorPageOrRedirect(req, res, 'Unknown Application for those OAuth credentials');
|
||||
|
||||
var applicationName = result.location || config.fqdn();
|
||||
render(applicationName, '/api/v1/cloudron/avatar');
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -573,7 +573,8 @@ describe('App installation', function () {
|
||||
docker.getContainer(appEntry.containerId).inspect(function (error, data) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(data.Config.ExposedPorts['7777/tcp']).to.eql({ });
|
||||
expect(data.Config.Env).to.contain('ADMIN_ORIGIN=' + config.adminOrigin());
|
||||
expect(data.Config.Env).to.contain('WEBADMIN_ORIGIN=' + config.adminOrigin());
|
||||
expect(data.Config.Env).to.contain('API_ORIGIN=' + config.adminOrigin());
|
||||
expect(data.Config.Env).to.contain('CLOUDRON=1');
|
||||
clientdb.getByAppId('addon-' + appResult.id, function (error, client) {
|
||||
expect(error).to.not.be.ok();
|
||||
|
||||
@@ -26,6 +26,7 @@ var token = null; // authentication token
|
||||
|
||||
var server;
|
||||
function setup(done) {
|
||||
nock.cleanAll();
|
||||
config.set('version', '0.5.0');
|
||||
server.start(done);
|
||||
}
|
||||
@@ -501,6 +502,158 @@ describe('Cloudron', function () {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('feedback', function () {
|
||||
before(function (done) {
|
||||
async.series([
|
||||
setup,
|
||||
|
||||
function (callback) {
|
||||
var scope1 = nock(config.apiServerOrigin()).get('/api/v1/boxes/' + config.fqdn() + '/setup/verify?setupToken=somesetuptoken').reply(200, {});
|
||||
var scope2 = nock(config.apiServerOrigin()).post('/api/v1/boxes/' + config.fqdn() + '/setup/done?setupToken=somesetuptoken').reply(201, {});
|
||||
|
||||
config._reset();
|
||||
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/activate')
|
||||
.query({ setupToken: 'somesetuptoken' })
|
||||
.send({ username: USERNAME, password: PASSWORD, email: EMAIL })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result).to.be.ok();
|
||||
expect(scope1.isDone()).to.be.ok();
|
||||
expect(scope2.isDone()).to.be.ok();
|
||||
|
||||
// stash token for further use
|
||||
token = result.body.token;
|
||||
|
||||
callback();
|
||||
});
|
||||
},
|
||||
], done);
|
||||
});
|
||||
|
||||
after(cleanup);
|
||||
|
||||
it('fails without token', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'ticket', subject: 'some subject', description: 'some description' })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(401);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails without type', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ subject: 'some subject', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(400);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with empty type', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: '', subject: 'some subject', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(400);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with unknown type', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'foobar', subject: 'some subject', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(400);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds with ticket type', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'ticket', subject: 'some subject', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(201);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds with app type', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'app', subject: 'some subject', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(201);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails without description', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'ticket', subject: 'some subject' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(400);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with empty subject', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'ticket', subject: '', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(400);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with empty description', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'ticket', subject: 'some subject', description: '' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(400);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds with feedback type', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'feedback', subject: 'some subject', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(201);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails without subject', function (done) {
|
||||
request.post(SERVER_URL + '/api/v1/cloudron/feedback')
|
||||
.send({ type: 'ticket', description: 'some description' })
|
||||
.query({ access_token: token })
|
||||
.end(function (error, result) {
|
||||
expect(error).to.not.be.ok();
|
||||
expect(result.statusCode).to.equal(400);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
set -eu -o pipefail
|
||||
|
||||
readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
|
||||
|
||||
source ${SOURCE_DIR}/setup/INFRA_VERSION
|
||||
|
||||
readonly mysqldatadir="/tmp/mysqldata-$(date +%s)"
|
||||
readonly postgresqldatadir="/tmp/postgresqldata-$(date +%s)"
|
||||
readonly mongodbdatadir="/tmp/mongodbdata-$(date +%s)"
|
||||
@@ -20,7 +24,7 @@ start_postgresql() {
|
||||
|
||||
docker rm -f postgresql 2>/dev/null 1>&2 || true
|
||||
|
||||
docker run -dtP --name=postgresql -v "${postgresqldatadir}:/var/lib/postgresql" -v /tmp/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh cloudron/postgresql:0.3.0 >/dev/null
|
||||
docker run -dtP --name=postgresql -v "${postgresqldatadir}:/var/lib/postgresql" -v /tmp/postgresql_vars.sh:/etc/postgresql/postgresql_vars.sh "${POSTGRESQL_IMAGE}" >/dev/null
|
||||
}
|
||||
|
||||
start_mysql() {
|
||||
@@ -36,7 +40,7 @@ start_mysql() {
|
||||
|
||||
docker rm -f mysql 2>/dev/null 1>&2 || true
|
||||
|
||||
docker run -dP --name=mysql -v "${mysqldatadir}:/var/lib/mysql" -v /tmp/mysql_vars.sh:/etc/mysql/mysql_vars.sh cloudron/mysql:0.3.0 >/dev/null
|
||||
docker run -dP --name=mysql -v "${mysqldatadir}:/var/lib/mysql" -v /tmp/mysql_vars.sh:/etc/mysql/mysql_vars.sh "${MYSQL_IMAGE}" >/dev/null
|
||||
}
|
||||
|
||||
start_mongodb() {
|
||||
@@ -52,7 +56,7 @@ start_mongodb() {
|
||||
|
||||
docker rm -f mongodb 2>/dev/null 1>&2 || true
|
||||
|
||||
docker run -dP --name=mongodb -v "${mongodbdatadir}:/var/lib/mongodb" -v /tmp/mongodb_vars.sh:/etc/mongodb_vars.sh cloudron/mongodb:0.3.0 >/dev/null
|
||||
docker run -dP --name=mongodb -v "${mongodbdatadir}:/var/lib/mongodb" -v /tmp/mongodb_vars.sh:/etc/mongodb_vars.sh "${MONGODB_IMAGE}" >/dev/null
|
||||
}
|
||||
|
||||
start_mysql
|
||||
|
||||
Executable
+39
@@ -0,0 +1,39 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -eu -o pipefail
|
||||
|
||||
if [[ $EUID -ne 0 ]]; then
|
||||
echo "This script should be run as root." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ $# == 1 && "$1" == "--check" ]]; then
|
||||
echo "OK"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ $# -lt 1 ]; then
|
||||
echo "Usage: collectlogs.sh <program>"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
readonly program_name=$1
|
||||
|
||||
echo "${program_name}.log"
|
||||
echo "-------------------"
|
||||
tail --lines=100 /var/log/supervisor/${program_name}.log
|
||||
echo
|
||||
echo
|
||||
echo "dmesg"
|
||||
echo "-----"
|
||||
dmesg | tail --lines=100
|
||||
echo
|
||||
echo
|
||||
echo "docker"
|
||||
echo "------"
|
||||
tail --lines=100 /var/log/upstart/docker.log
|
||||
echo
|
||||
echo
|
||||
|
||||
|
||||
|
||||
@@ -102,6 +102,9 @@ function initializeExpressSync() {
|
||||
router.post('/api/v1/cloudron/certificate', rootScope, multipart, routes.cloudron.setCertificate);
|
||||
router.get ('/api/v1/cloudron/graphs', rootScope, routes.graphs.getGraphs);
|
||||
|
||||
// feedback
|
||||
router.post('/api/v1/cloudron/feedback', usersScope, routes.cloudron.feedback);
|
||||
|
||||
router.get ('/api/v1/profile', profileScope, routes.user.profile);
|
||||
|
||||
router.get ('/api/v1/users', usersScope, routes.user.list);
|
||||
|
||||
+1
-1
@@ -54,7 +54,7 @@ function uninitialize(callback) {
|
||||
|
||||
function startNextTask() {
|
||||
if (gPendingTasks.length === 0) return;
|
||||
assert(Object.keys(gActiveTasks).length === 0); // since we allow only one task at a time
|
||||
assert.strictEqual(Object.keys(gActiveTasks).length, 0); // since we allow only one task at a time
|
||||
|
||||
startAppTask(gPendingTasks.shift());
|
||||
}
|
||||
|
||||
+21
-18
@@ -2,21 +2,24 @@
|
||||
|
||||
set -eu
|
||||
|
||||
readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
readonly SOURCE_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
|
||||
source ${SOURCE_DIR}/setup/INFRA_VERSION
|
||||
|
||||
# reset sudo timestamp to avoid wrong success
|
||||
sudo -k || sudo --reset-timestamp
|
||||
|
||||
# checks if all scripts are sudo access
|
||||
scripts=("${SOURCE_DIR}/scripts/rmappdir.sh" \
|
||||
"${SOURCE_DIR}/scripts/createappdir.sh" \
|
||||
"${SOURCE_DIR}/scripts/reloadnginx.sh" \
|
||||
"${SOURCE_DIR}/scripts/backupbox.sh" \
|
||||
"${SOURCE_DIR}/scripts/backupapp.sh" \
|
||||
"${SOURCE_DIR}/scripts/restoreapp.sh" \
|
||||
"${SOURCE_DIR}/scripts/reboot.sh" \
|
||||
"${SOURCE_DIR}/scripts/backupswap.sh" \
|
||||
"${SOURCE_DIR}/scripts/reloadcollectd.sh")
|
||||
scripts=("${SOURCE_DIR}/src/scripts/rmappdir.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/createappdir.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/reloadnginx.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/backupbox.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/backupapp.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/restoreapp.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/reboot.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/backupswap.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/collectlogs.sh" \
|
||||
"${SOURCE_DIR}/src/scripts/reloadcollectd.sh")
|
||||
|
||||
for script in "${scripts[@]}"; do
|
||||
if [[ $(sudo -n "${script}" --check 2>/dev/null) != "OK" ]]; then
|
||||
@@ -36,23 +39,23 @@ if ! docker inspect girish/test:0.2.0 >/dev/null 2>/dev/null; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker inspect cloudron/redis:0.3.0 >/dev/null 2>/dev/null; then
|
||||
echo "docker pull cloudron/redis:0.3.0 for tests to run"
|
||||
if ! docker inspect "${REDIS_IMAGE}" >/dev/null 2>/dev/null; then
|
||||
echo "docker pull ${REDIS_IMAGE} for tests to run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker inspect cloudron/mysql:0.3.0 >/dev/null 2>/dev/null; then
|
||||
echo "docker pull cloudron/mysql:0.3.0 for tests to run"
|
||||
if ! docker inspect "${MYSQL_IMAGE}" >/dev/null 2>/dev/null; then
|
||||
echo "docker pull ${MYSQL_IMAGE} for tests to run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker inspect cloudron/postgresql:0.3.0 >/dev/null 2>/dev/null; then
|
||||
echo "docker pull cloudron/postgresql:0.3.0 for tests to run"
|
||||
if ! docker inspect "${POSTGRESQL_IMAGE}" >/dev/null 2>/dev/null; then
|
||||
echo "docker pull ${POSTGRESQL_IMAGE} for tests to run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! docker inspect cloudron/mongodb:0.3.0 >/dev/null 2>/dev/null; then
|
||||
echo "docker pull cloudron/mongodb:0.3.0 for tests to run"
|
||||
if ! docker inspect "${MONGODB_IMAGE}" >/dev/null 2>/dev/null; then
|
||||
echo "docker pull ${MONGODB_IMAGE} for tests to run"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
/* jslint node:true */
|
||||
/* global it:false */
|
||||
/* global describe:false */
|
||||
/* global before:false */
|
||||
/* global after:false */
|
||||
|
||||
'use strict';
|
||||
|
||||
require('supererror', { splatchError: true});
|
||||
|
||||
var database = require('../database.js'),
|
||||
expect = require('expect.js'),
|
||||
EventEmitter = require('events').EventEmitter,
|
||||
async = require('async'),
|
||||
user = require('../user.js'),
|
||||
config = require('../config.js'),
|
||||
ldapServer = require('../ldap.js'),
|
||||
ldap = require('ldapjs');
|
||||
|
||||
var USER_0 = {
|
||||
username: 'foobar0',
|
||||
password: 'password0',
|
||||
email: 'foo0@bar.com'
|
||||
};
|
||||
|
||||
var USER_1 = {
|
||||
username: 'foobar1',
|
||||
password: 'password1',
|
||||
email: 'foo1@bar.com'
|
||||
};
|
||||
|
||||
function setup(done) {
|
||||
async.series([
|
||||
database.initialize.bind(null),
|
||||
database._clear.bind(null),
|
||||
ldapServer.start.bind(null),
|
||||
user.create.bind(null, USER_0.username, USER_0.password, USER_0.email, true, null),
|
||||
user.create.bind(null, USER_1.username, USER_1.password, USER_1.email, false, USER_0)
|
||||
], done);
|
||||
}
|
||||
|
||||
function cleanup(done) {
|
||||
database._clear(done);
|
||||
}
|
||||
|
||||
describe('Ldap', function () {
|
||||
before(setup);
|
||||
after(cleanup);
|
||||
|
||||
describe('bind', function () {
|
||||
it('fails for nonexisting user', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
client.bind('cn=doesnotexist,ou=users,dc=cloudron', 'password', function (error) {
|
||||
expect(error).to.be.a(ldap.NoSuchObjectError);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('fails with wrong password', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
client.bind('cn=' + USER_0.username + ',ou=users,dc=cloudron', 'wrongpassword', function (error) {
|
||||
expect(error).to.be.a(ldap.InvalidCredentialsError);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
client.bind('cn=' + USER_0.username + ',ou=users,dc=cloudron', USER_0.password, function (error) {
|
||||
expect(error).to.be(null);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search users', function () {
|
||||
it ('fails for non existing tree', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
var opts = {
|
||||
filter: '(&(l=Seattle)(email=*@foo.com))'
|
||||
};
|
||||
|
||||
client.search('o=example', opts, function (error, result) {
|
||||
expect(error).to.be(null);
|
||||
expect(result).to.be.an(EventEmitter);
|
||||
|
||||
result.on('error', function (error) {
|
||||
expect(error).to.be.a(ldap.NoSuchObjectError);
|
||||
done();
|
||||
});
|
||||
result.on('end', function (result) {
|
||||
done(new Error('Should not succeed. Status ' + result.status));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it ('succeeds with basic filter', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
var opts = {
|
||||
filter: 'objectcategory=person'
|
||||
};
|
||||
|
||||
client.search('ou=users,dc=cloudron', opts, function (error, result) {
|
||||
expect(error).to.be(null);
|
||||
expect(result).to.be.an(EventEmitter);
|
||||
|
||||
var entries = [];
|
||||
|
||||
result.on('searchEntry', function (entry) { entries.push(entry.object); });
|
||||
result.on('error', done);
|
||||
result.on('end', function (result) {
|
||||
expect(result.status).to.equal(0);
|
||||
expect(entries.length).to.equal(2);
|
||||
expect(entries[0].username).to.equal(USER_0.username);
|
||||
expect(entries[1].username).to.equal(USER_1.username);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it ('succeeds with username wildcard filter', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
var opts = {
|
||||
filter: '&(objectcategory=person)(username=foobar*)'
|
||||
};
|
||||
|
||||
client.search('ou=users,dc=cloudron', opts, function (error, result) {
|
||||
expect(error).to.be(null);
|
||||
expect(result).to.be.an(EventEmitter);
|
||||
|
||||
var entries = [];
|
||||
|
||||
result.on('searchEntry', function (entry) { entries.push(entry.object); });
|
||||
result.on('error', done);
|
||||
result.on('end', function (result) {
|
||||
expect(result.status).to.equal(0);
|
||||
expect(entries.length).to.equal(2);
|
||||
expect(entries[0].username).to.equal(USER_0.username);
|
||||
expect(entries[1].username).to.equal(USER_1.username);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it ('succeeds with username filter', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
var opts = {
|
||||
filter: '&(objectcategory=person)(username=' + USER_0.username + ')'
|
||||
};
|
||||
|
||||
client.search('ou=users,dc=cloudron', opts, function (error, result) {
|
||||
expect(error).to.be(null);
|
||||
expect(result).to.be.an(EventEmitter);
|
||||
|
||||
var entries = [];
|
||||
|
||||
result.on('searchEntry', function (entry) { entries.push(entry.object); });
|
||||
result.on('error', done);
|
||||
result.on('end', function (result) {
|
||||
expect(result.status).to.equal(0);
|
||||
expect(entries.length).to.equal(1);
|
||||
expect(entries[0].username).to.equal(USER_0.username);
|
||||
expect(entries[0].memberof.length).to.equal(2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('search groups', function () {
|
||||
it ('succeeds with basic filter', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
var opts = {
|
||||
filter: 'objectclass=group'
|
||||
};
|
||||
|
||||
client.search('ou=groups,dc=cloudron', opts, function (error, result) {
|
||||
expect(error).to.be(null);
|
||||
expect(result).to.be.an(EventEmitter);
|
||||
|
||||
var entries = [];
|
||||
|
||||
result.on('searchEntry', function (entry) { entries.push(entry.object); });
|
||||
result.on('error', done);
|
||||
result.on('end', function (result) {
|
||||
expect(result.status).to.equal(0);
|
||||
expect(entries.length).to.equal(2);
|
||||
expect(entries[0].cn).to.equal('users');
|
||||
expect(entries[0].memberuid.length).to.equal(2);
|
||||
expect(entries[0].memberuid[0]).to.equal(USER_0.username);
|
||||
expect(entries[0].memberuid[1]).to.equal(USER_1.username);
|
||||
expect(entries[1].cn).to.equal('admins');
|
||||
// if only one entry, the array becomes a string :-/
|
||||
expect(entries[1].memberuid).to.equal(USER_0.username);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it ('succeeds with cn wildcard filter', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
var opts = {
|
||||
filter: '&(objectclass=group)(cn=*)'
|
||||
};
|
||||
|
||||
client.search('ou=groups,dc=cloudron', opts, function (error, result) {
|
||||
expect(error).to.be(null);
|
||||
expect(result).to.be.an(EventEmitter);
|
||||
|
||||
var entries = [];
|
||||
|
||||
result.on('searchEntry', function (entry) { entries.push(entry.object); });
|
||||
result.on('error', done);
|
||||
result.on('end', function (result) {
|
||||
expect(result.status).to.equal(0);
|
||||
expect(entries.length).to.equal(2);
|
||||
expect(entries[0].cn).to.equal('users');
|
||||
expect(entries[0].memberuid.length).to.equal(2);
|
||||
expect(entries[0].memberuid[0]).to.equal(USER_0.username);
|
||||
expect(entries[0].memberuid[1]).to.equal(USER_1.username);
|
||||
expect(entries[1].cn).to.equal('admins');
|
||||
// if only one entry, the array becomes a string :-/
|
||||
expect(entries[1].memberuid).to.equal(USER_0.username);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('succeeds with memberuid filter', function (done) {
|
||||
var client = ldap.createClient({ url: 'ldap://127.0.0.1:' + config.get('ldapPort') });
|
||||
|
||||
var opts = {
|
||||
filter: '&(objectclass=group)(memberuid=' + USER_1.username + ')'
|
||||
};
|
||||
|
||||
client.search('ou=groups,dc=cloudron', opts, function (error, result) {
|
||||
expect(error).to.be(null);
|
||||
expect(result).to.be.an(EventEmitter);
|
||||
|
||||
var entries = [];
|
||||
|
||||
result.on('searchEntry', function (entry) { entries.push(entry.object); });
|
||||
result.on('error', done);
|
||||
result.on('end', function (result) {
|
||||
expect(result.status).to.equal(0);
|
||||
expect(entries.length).to.equal(1);
|
||||
expect(entries[0].cn).to.equal('users');
|
||||
expect(entries[0].memberuid.length).to.equal(2);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -143,9 +143,10 @@
|
||||
<ul class="dropdown-menu" role="menu">
|
||||
<li><a href="#/account"><i class="fa fa-user fa-fw"></i> Account</a></li>
|
||||
<li ng-show="user.admin && config.isDev"><a href="#/dns"><i class="fa fa-wrench fa-fw"></i> DNS Management</a></li>
|
||||
<li ng-show="user.admin"><a href="#/upgrade"><i class="fa fa-arrow-up fa-fw"></i> Upgrade</a></li>
|
||||
<li ng-show="user.admin"><a href="#/settings"><i class="fa fa-wrench fa-fw"></i> Settings</a></li>
|
||||
<li ng-show="user.admin"><a href="#/graphs"><i class="fa fa-bar-chart fa-fw"></i> Graphs</a></li>
|
||||
<li ng-show="user.admin"><a href="#/upgrade"><i class="fa fa-arrow-up fa-fw"></i> Upgrade</a></li>
|
||||
<li><a href="#/support"><i class="fa fa-comment fa-fw"></i> Support</a></li>
|
||||
<li ng-show="user.admin"><a href="#/settings"><i class="fa fa-wrench fa-fw"></i> Settings</a></li>
|
||||
<li class="divider"></li>
|
||||
<li><a href="" ng-click="logout($event)"><i class="fa fa-sign-out fa-fw"></i> Logout</a></li>
|
||||
</ul>
|
||||
|
||||
@@ -63,6 +63,17 @@ angular.module('Application').service('AppStore', ['$http', 'Client', function (
|
||||
});
|
||||
};
|
||||
|
||||
AppStore.prototype.getAppByIdAndVersion = function (appId, version, callback) {
|
||||
if (Client.getConfig().apiServerOrigin === null) return callback(new AppStoreError(420, 'Enhance Your Calm'));
|
||||
|
||||
$http.get(Client.getConfig().apiServerOrigin + '/api/v1/apps/' + appId + '/versions/' + version).success(function (data, status) {
|
||||
if (status !== 200) return callback(new AppStoreError(status, data));
|
||||
return callback(null, data);
|
||||
}).error(function (data, status) {
|
||||
return callback(new AppStoreError(status, data));
|
||||
});
|
||||
};
|
||||
|
||||
AppStore.prototype.getManifest = function (appId, callback) {
|
||||
if (Client.getConfig().apiServerOrigin === null) return callback(new AppStoreError(420, 'Enhance Your Calm'));
|
||||
|
||||
|
||||
@@ -469,6 +469,19 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
|
||||
}).error(defaultErrorHandler(callback));
|
||||
};
|
||||
|
||||
Client.prototype.feedback = function (type, subject, description, callback) {
|
||||
var data = {
|
||||
type: type,
|
||||
subject: subject,
|
||||
description: description
|
||||
};
|
||||
|
||||
$http.post(client.apiOrigin + '/api/v1/cloudron/feedback', data).success(function (data, status) {
|
||||
if (status !== 201) return callback(new ClientError(status, data));
|
||||
callback(null);
|
||||
}).error(defaultErrorHandler(callback));
|
||||
};
|
||||
|
||||
Client.prototype.createUser = function (username, email, callback) {
|
||||
var data = {
|
||||
username: username,
|
||||
|
||||
@@ -37,6 +37,9 @@ app.config(['$routeProvider', function ($routeProvider) {
|
||||
}).when('/settings', {
|
||||
controller: 'SettingsController',
|
||||
templateUrl: 'views/settings.html'
|
||||
}).when('/support', {
|
||||
controller: 'SupportController',
|
||||
templateUrl: 'views/support.html'
|
||||
}).when('/upgrade', {
|
||||
controller: 'UpgradeController',
|
||||
templateUrl: 'views/upgrade.html'
|
||||
|
||||
+127
-18
@@ -120,6 +120,14 @@ html {
|
||||
.grid-item {
|
||||
padding: 10px;
|
||||
min-width: 200px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.grid-item:hover .grid-item-bottom {
|
||||
@media(min-width:768px) {
|
||||
opacity: 1;
|
||||
right: 10px;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-item-content {
|
||||
@@ -132,10 +140,39 @@ html {
|
||||
padding: 10px 15px;
|
||||
}
|
||||
|
||||
.grid-item-bottom {
|
||||
.grid-item-top-title {
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.grid-item-bottom-mobile {
|
||||
padding: 10px 15px;
|
||||
border-top: 1px solid #ddd;
|
||||
background-color: white
|
||||
background-color: white;
|
||||
|
||||
@media(min-width:768px) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.grid-item-bottom {
|
||||
display: none;
|
||||
padding: 10px 15px;
|
||||
border-top: 1px solid #ddd;
|
||||
background-color: white;
|
||||
|
||||
@media(min-width:768px) {
|
||||
display: block;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: -10px;
|
||||
opacity: 0;
|
||||
background-color: transparent;
|
||||
|
||||
transition: all 250ms;
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
@@ -195,15 +232,53 @@ html {
|
||||
}
|
||||
|
||||
.appstore-category-link {
|
||||
display: block;
|
||||
padding: 10px;
|
||||
margin: 0;
|
||||
overflow: hidden;
|
||||
color: black;
|
||||
color: inherit;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
display: block;
|
||||
font-size: 18px;
|
||||
|
||||
&:hover,
|
||||
&:focus,
|
||||
&.category-active {
|
||||
text-decoration: none;
|
||||
background-color: white;
|
||||
color: black;
|
||||
}
|
||||
|
||||
&.category-active {
|
||||
background-color: $navbar-default-link-hover-color;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.appstore-category-missing {
|
||||
padding: 10px;
|
||||
background-color: white;
|
||||
|
||||
p {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
textarea {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 50px;
|
||||
margin-bottom: 10px;
|
||||
transition: all 250ms ease-out;
|
||||
|
||||
&:focus {
|
||||
height: 200px;
|
||||
}
|
||||
}
|
||||
|
||||
button {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.appstore-install-description {
|
||||
@@ -226,14 +301,6 @@ html {
|
||||
color: $text-muted;
|
||||
}
|
||||
|
||||
.appstore-category-link:hover,
|
||||
.appstore-category-link:focus,
|
||||
.appstore-category-link.category-active {
|
||||
text-decoration: none;
|
||||
background-color: $navbar-default-link-hover-color;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.appstore-item-rating {
|
||||
color: $navbar-default-link-hover-color;
|
||||
}
|
||||
@@ -304,6 +371,10 @@ html {
|
||||
color: #5CB85C;
|
||||
}
|
||||
|
||||
.text-bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.text-large {
|
||||
font-size: $font-size-h1;
|
||||
}
|
||||
@@ -329,12 +400,9 @@ html {
|
||||
.grid-item-top .progress {
|
||||
border-radius: 0;
|
||||
box-shadown: none;
|
||||
margin-left: -15px;
|
||||
margin-right: -15px;
|
||||
margin-bottom: -11px;
|
||||
margin-top: 9px;
|
||||
margin-top: 10px;
|
||||
width: inherit;
|
||||
height: 2px;
|
||||
height: 10px;
|
||||
}
|
||||
|
||||
.grid-item-top .progress-bar {
|
||||
@@ -636,6 +704,33 @@ footer a {
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------
|
||||
// Oauth classes
|
||||
// ----------------------------
|
||||
|
||||
.oauth {
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
padding: 0;
|
||||
background: #F7F7F7;
|
||||
|
||||
h1 {
|
||||
font-size: 33px;
|
||||
}
|
||||
|
||||
.card {
|
||||
max-width: none;
|
||||
padding: 20px;
|
||||
text-align: left;
|
||||
margin-top: 15px;
|
||||
|
||||
@media(min-width:768px) {
|
||||
margin-top: 20%;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// ----------------------------
|
||||
// Graphs classes
|
||||
// ----------------------------
|
||||
@@ -779,3 +874,17 @@ $graphs-success-alt: lighten(#27CE65, 20%);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------
|
||||
// Support
|
||||
// ----------------------------
|
||||
|
||||
.support {
|
||||
|
||||
max-width: 600px;
|
||||
|
||||
h3 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
}
|
||||
@@ -89,6 +89,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div class="text-left">
|
||||
<h1>Account</h1>
|
||||
|
||||
@@ -43,6 +43,7 @@
|
||||
<option value="roleUser">Visible only to Cloudron users</option>
|
||||
</select>
|
||||
</div>
|
||||
<a ng-show="!!appConfigure.app.manifest.configurePath" ng-href="https://{{ appConfigure.app.location }}{{ !appConfigure.app.location ? '' : (config.isCustomDomain ? '.' : '-') }}{{ config.fqdn }}/{{ appConfigure.app.manifest.configurePath }}" target="_blank">Application Specific Settings</a>
|
||||
<br/>
|
||||
<br/>
|
||||
<div class="form-group" ng-class="{ 'has-error': (appConfigureForm.password.$dirty && appConfigureForm.password.$invalid) || (!appConfigureForm.password.$dirty && appConfigure.error.password) }">
|
||||
@@ -53,7 +54,7 @@
|
||||
</form>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<div class="modal-footer ">
|
||||
<button type="button" class="btn btn-default" style="float: left;" ng-click="startApp(appConfigure.app)" ng-show="appConfigure.app.runState === 'stopped' && !appConfigure.runStateBusy && !(appConfigure.app | installationActive)"><i class="fa fa-play"></i> Start</button>
|
||||
<button type="button" class="btn btn-default" style="float: left;" ng-show="appConfigure.app.runState !== 'stopped' && appConfigure.app.runState !== 'running' || appConfigure.runStateBusy && !(appConfigure.app | installationActive)" disabled ><i class="fa fa-refresh fa-spin"></i></button>
|
||||
<button type="button" class="btn btn-default" style="float: left;" ng-click="stopApp(appConfigure.app)" ng-show="appConfigure.app.runState === 'running' && !appConfigure.runStateBusy && !(appConfigure.app | installationActive)"><i class="fa fa-pause"></i> Stop</button>
|
||||
@@ -182,6 +183,9 @@
|
||||
</script>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
|
||||
<div class="col-lg-12">
|
||||
<h1>Installed Applications</h1>
|
||||
@@ -200,11 +204,12 @@
|
||||
</div>
|
||||
<br/>
|
||||
<div class="row">
|
||||
<div class="col-xs-12 text-left">
|
||||
<div style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden">{{ app.location || app.fqdn }}</div>
|
||||
<div class="col-xs-12 text-center">
|
||||
<div class="grid-item-top-title">{{ app.location || app.fqdn }}</div>
|
||||
<div class="text-muted" style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden">
|
||||
{{ app | installationStateLabel }}
|
||||
</div>
|
||||
<br ng-hide="app | installationActive"/>
|
||||
<div ng-show="app | installationActive">
|
||||
<div class="progress progress-striped active">
|
||||
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ app.progress }}%"></div>
|
||||
@@ -213,31 +218,53 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
<div class="grid-item-bottom" ng-show="user.admin">
|
||||
<div class="row">
|
||||
<div class="col-xs-4 text-left">
|
||||
<a href="" ng-click="showRestore(app)" ng-show="(app | installError) === true">
|
||||
<i class="fa fa-undo scale"></i>
|
||||
</a>
|
||||
<div class="grid-item-bottom-mobile" ng-show="user.admin">
|
||||
<div class="row">
|
||||
<div class="col-xs-4 text-left">
|
||||
<a href="" ng-click="showRestore(app)" ng-show="(app | installError) === true">
|
||||
<i class="fa fa-undo scale"></i>
|
||||
</a>
|
||||
|
||||
<a href="" ng-click="showConfigure(app)" ng-show="(app | installSuccess) == true">
|
||||
<i class="fa fa-wrench scale"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<!-- we check the version here because the box updater does not know when an app gets updated -->
|
||||
<a href="" ng-click="showUpdate(app)" class="ng-hide animateMe" ng-show="config.update.apps[app.id].manifest.version && config.update.apps[app.id].manifest.version !== app.manifest.version && (app | installSuccess)">
|
||||
<i class="fa fa-arrow-up text-success scale"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-right">
|
||||
<a href="" ng-click="showUninstall(app)">
|
||||
<i class="fa fa-remove scale"></i>
|
||||
</a>
|
||||
<a href="" ng-click="showConfigure(app)" ng-show="(app | installSuccess) == true">
|
||||
<i class="fa fa-wrench scale"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-center">
|
||||
<!-- we check the version here because the box updater does not know when an app gets updated -->
|
||||
<a href="" ng-click="showUpdate(app)" class="ng-hide animateMe" ng-show="config.update.apps[app.id].manifest.version && config.update.apps[app.id].manifest.version !== app.manifest.version && (app | installSuccess)">
|
||||
<i class="fa fa-arrow-up text-success scale"></i>
|
||||
</a>
|
||||
</div>
|
||||
<div class="col-xs-4 text-right">
|
||||
<a href="" ng-click="showUninstall(app)">
|
||||
<i class="fa fa-remove scale"></i>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="grid-item-bottom" ng-show="user.admin">
|
||||
<br/>
|
||||
|
||||
<div>
|
||||
<a href="" ng-click="showUninstall(app)"><i class="fa fa-remove scale"></i></a>
|
||||
</div>
|
||||
|
||||
<div ng-show="(app | installError) === true">
|
||||
<a href="" ng-click="showRestore(app)"><i class="fa fa-undo scale"></i></a>
|
||||
</div>
|
||||
|
||||
<div ng-show="(app | installSuccess) == true">
|
||||
<a href="" ng-click="showConfigure(app)"><i class="fa fa-wrench scale"></i></a>
|
||||
</div>
|
||||
|
||||
<!-- we check the version here because the box updater does not know when an app gets updated -->
|
||||
<div ng-show="config.update.apps[app.id].manifest.version && config.update.apps[app.id].manifest.version !== app.manifest.version && (app | installSuccess)">
|
||||
<a href="" ng-click="showUpdate(app)"><i class="fa fa-arrow-up text-success scale"></i></a>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -214,7 +214,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$location
|
||||
AppStore.getManifest(app.appStoreId, function (error, manifest) {
|
||||
if (error) return console.error(error);
|
||||
|
||||
$scope.appUpdate.manifest = manifest;
|
||||
$scope.appUpdate.manifest = angular.copy(manifest);
|
||||
|
||||
// ensure we always operate on objects here
|
||||
app.portBindings = app.portBindings || {};
|
||||
@@ -334,7 +334,7 @@ angular.module('Application').controller('AppsController', ['$scope', '$location
|
||||
};
|
||||
|
||||
// setup all the dialog focus handling
|
||||
['appConfigureModal', 'appUninstallModal', 'appUpdateModal'].forEach(function (id) {
|
||||
['appConfigureModal', 'appUninstallModal', 'appUpdateModal', 'appRestoreModal'].forEach(function (id) {
|
||||
$('#' + id).on('shown.bs.modal', function () {
|
||||
$(this).find("[autofocus]:first").focus();
|
||||
});
|
||||
|
||||
@@ -64,6 +64,47 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal feedback -->
|
||||
<div class="modal fade" id="feedbackModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">App Feedback</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<fieldset>
|
||||
<form name="feedbackForm" ng-submit="submitFeedback()">
|
||||
<div ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</div>
|
||||
<textarea class="form-control" id="feedbackDescriptionTextarea" cols="3" ng-model="feedback.description" ng-minlength="1" required placeholder="Name, Category, Links ..." autofocus></textarea>
|
||||
<input class="ng-hide" type="submit" ng-disabled="feedbackForm.$invalid || feedback.busy"/>
|
||||
</form>
|
||||
</fieldset>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
|
||||
<button type="button" class="btn btn-success" ng-click="submitFeedback()" ng-disabled="feedbackForm.$invalid || feedback.busy"><i class="fa fa-fw fa-paper-plane"></i> Submit</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Modal app not found -->
|
||||
<div class="modal fade" id="appNotFoundModal" tabindex="-1" role="dialog">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title">App not found</h4>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
There is no such app <b>{{ appNotFound.appId }}</b><span ng-show="appNotFound.version"> with version <b>{{ appNotFound.version }}</b></span>.
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-primary" data-dismiss="modal">Ok</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="row-no-margin">
|
||||
<div class="col-md-2">
|
||||
@@ -98,6 +139,10 @@
|
||||
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'wiki' }" category="wiki">Wiki</a>
|
||||
<br/>
|
||||
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'testing' }" category="testing" ng-show="config.developerMode">Testing</a>
|
||||
<br/>
|
||||
<br/>
|
||||
<br/>
|
||||
<a href="" ng-click="showFeedbackModal()">Missing an app? Let us know.</a>
|
||||
</div>
|
||||
<div class="col-md-10" ng-show="ready && apps.length">
|
||||
<div class="row-no-margin">
|
||||
@@ -117,7 +162,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-10 animateMeOpacity loading-banner" ng-show="ready && !apps.length">
|
||||
<h3 class="text-muted">No applications in this category</h3>
|
||||
<h3 class="text-muted">No applications in this category.</h3>
|
||||
<a href="" ng-click="showFeedbackModal()"><h3>Let us know if you miss something.</h3></a>
|
||||
</div>
|
||||
<div class="col-md-10 animateMeOpacity loading-banner" ng-show="!ready">
|
||||
<h2><i class="fa fa-spinner fa-pulse"></i> Loading</h2>
|
||||
|
||||
@@ -20,6 +20,47 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
|
||||
mediaLinks: []
|
||||
};
|
||||
|
||||
$scope.appNotFound = {
|
||||
appId: '',
|
||||
version: ''
|
||||
};
|
||||
|
||||
$scope.feedback = {
|
||||
error: null,
|
||||
success: false,
|
||||
subject: 'App feedback',
|
||||
description: '',
|
||||
type: 'app'
|
||||
};
|
||||
|
||||
function resetFeedback() {
|
||||
$scope.feedback.description = '';
|
||||
|
||||
$scope.feedbackForm.$setUntouched();
|
||||
$scope.feedbackForm.$setPristine();
|
||||
}
|
||||
|
||||
$scope.submitFeedback = function () {
|
||||
$scope.feedback.busy = true;
|
||||
$scope.feedback.success = false;
|
||||
$scope.feedback.error = null;
|
||||
|
||||
Client.feedback($scope.feedback.type, $scope.feedback.subject, $scope.feedback.description, function (error) {
|
||||
if (error) {
|
||||
$scope.feedback.error = error;
|
||||
} else {
|
||||
$scope.feedback.success = true;
|
||||
$('#feedbackModal').modal('hide');
|
||||
resetFeedback();
|
||||
}
|
||||
|
||||
$scope.feedback.busy = false;
|
||||
});
|
||||
};
|
||||
|
||||
$scope.showFeedbackModal = function () {
|
||||
$('#feedbackModal').modal('show');
|
||||
};
|
||||
|
||||
function getAppList(callback) {
|
||||
AppStore.getApps(function (error, apps) {
|
||||
@@ -132,6 +173,13 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
|
||||
}
|
||||
};
|
||||
|
||||
$scope.showAppNotFound = function (appId, version) {
|
||||
$scope.appNotFound.appId = appId;
|
||||
$scope.appNotFound.version = version;
|
||||
|
||||
$('#appNotFoundModal').modal('show');
|
||||
};
|
||||
|
||||
$scope.doInstall = function () {
|
||||
$scope.appInstall.busy = true;
|
||||
$scope.appInstall.error.other = null;
|
||||
@@ -189,11 +237,26 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
|
||||
|
||||
// show install app dialog immediately if an app id was passed in the query
|
||||
if ($routeParams.appId) {
|
||||
var found = apps.filter(function (app) {
|
||||
return (app.id === $routeParams.appId) && ($routeParams.version ? $routeParams.version === app.manifest.version : true);
|
||||
});
|
||||
if (found.length) {
|
||||
$scope.showInstall(found[0]);
|
||||
if ($routeParams.version) {
|
||||
AppStore.getAppByIdAndVersion($routeParams.appId, $routeParams.version, function (error, result) {
|
||||
if (error) {
|
||||
$scope.showAppNotFound($routeParams.appId, $routeParams.version);
|
||||
console.error(error);
|
||||
return;
|
||||
}
|
||||
|
||||
$scope.showInstall(result);
|
||||
});
|
||||
} else {
|
||||
var found = apps.filter(function (app) {
|
||||
return (app.id === $routeParams.appId) && ($routeParams.version ? $routeParams.version === app.manifest.version : true);
|
||||
});
|
||||
|
||||
if (found.length) {
|
||||
$scope.showInstall(found[0]);
|
||||
} else {
|
||||
$scope.showAppNotFound($routeParams.appId, null);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -204,7 +267,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
|
||||
refresh();
|
||||
|
||||
// setup all the dialog focus handling
|
||||
['appInstallModal'].forEach(function (id) {
|
||||
['appInstallModal', 'feedbackModal'].forEach(function (id) {
|
||||
$('#' + id).on('shown.bs.modal', function () {
|
||||
$(this).find("[autofocus]:first").focus();
|
||||
});
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1>Graphs</h1>
|
||||
|
||||
@@ -107,6 +107,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;">
|
||||
<div class="text-left">
|
||||
<h1>Settings</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="max-width: 600px; margin: 0 auto;" ng-show="user.admin">
|
||||
<div class="text-left">
|
||||
<h3>Backups</h3>
|
||||
|
||||
@@ -3,28 +3,34 @@
|
||||
<h1>Welcome to your Cloudron!</h1>
|
||||
<hr/>
|
||||
<h3 class="">
|
||||
Choose a name and avatar
|
||||
Choose a name and avatar for your Cloudron
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4 text-center">
|
||||
<img id="previewAvatar" width="98" height="98" ng-src="{{wizard.avatar.data || wizard.avatar.url}}"/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 col-md-offset-4 text-center">
|
||||
<div class="form-group" ng-class="{ 'has-error': setup_form.name.$dirty && setup_form.name.$invalid }">
|
||||
<!-- <label class="control-label" for="inputName">Name</label> -->
|
||||
<input type="text" class="form-control" ng-model="wizard.name" id="inputName" name="name" placeholder="Name" ng-enter="next('/step3', setup_form.name.$invalid)" ng-maxlength="512" ng-minlength="1" autofocus required autocomplete="off">
|
||||
<input type="text" class="form-control" ng-model="wizard.name" id="inputName" name="name" placeholder="Name" ng-enter="next('/step2', setup_form.name.$invalid)" ng-maxlength="512" ng-minlength="1" autofocus required autocomplete="off">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-12 settings-avatar-selector">
|
||||
<img id="previewAvatar" width="80" height="80" ng-src="{{wizard.avatar.data || wizard.avatar.url}}"/>
|
||||
<input type="file" id="avatarFileInput" style="display: none" accept="image/png"/>
|
||||
|
||||
<br/>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<div class="content support">
|
||||
<br/>
|
||||
|
||||
<div>
|
||||
<div class="text-left">
|
||||
<h1>Support</h1>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-large">
|
||||
<div class="grid-item-top">
|
||||
<div class="row animateMeOpacity">
|
||||
<div class="col-lg-12">
|
||||
If you have any questions, please revise our <a href="{{ config.webServerOrigin }}/faq.html" target="_blank">Frequently Asked Questions</a>. We add more answers as we go, if you couldn't find your answers, just let us know using the forms below.<br/>
|
||||
<br/>
|
||||
For any developer related issues, please see our <a href="{{ config.webServerOrigin }}/documentation.html" target="_blank">Developer documentation</a>.
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<br/>
|
||||
|
||||
<div class="card card-large">
|
||||
<div class="grid-item-top">
|
||||
<div class="row animateMeOpacity">
|
||||
<div class="col-lg-12">
|
||||
<h3>Feedback</h3>
|
||||
We would love to hear any ideas or feature requests from your side.<br/>
|
||||
If you found any issue or bugs, let us know as well, we will resolve that for you as soon as possible.
|
||||
<br/>
|
||||
<br/>
|
||||
<form name="feedbackForm" ng-submit="submitFeedback()">
|
||||
<div class="form-group">
|
||||
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required>
|
||||
<option value="feedback">Enhancement / Idea</option>
|
||||
<option value="ticket">Bug Report</option>
|
||||
<option value="app">Missing App</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
|
||||
<input type="text" class="form-control" name="subject" placeholder="Enter your idea or issue" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required>
|
||||
</div>
|
||||
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.description.$dirty && feedbackForm.description.$invalid) }">
|
||||
<textarea class="form-control" name="description" rows="3" placeholder="Describe your idea or issue" ng-model="feedback.description" ng-minlength="1" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary" ng-disabled="feedbackForm.$invalid || feedback.busy"><i class="fa fa-spinner fa-pulse" ng-show="feedback.busy"></i> Submit</button>
|
||||
<span ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</span>
|
||||
<span ng-show="feedback.success" class="text-success text-bold">Thank You!</span>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Offset the footer -->
|
||||
<br/><br/>
|
||||
@@ -0,0 +1,40 @@
|
||||
'use strict';
|
||||
|
||||
angular.module('Application').controller('SupportController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
|
||||
$scope.config = Client.getConfig();
|
||||
|
||||
$scope.feedback = {
|
||||
error: null,
|
||||
success: false,
|
||||
busy: false,
|
||||
subject: '',
|
||||
type: '',
|
||||
description: ''
|
||||
};
|
||||
|
||||
function resetFeedback() {
|
||||
$scope.feedback.subject = '';
|
||||
$scope.feedback.description = '';
|
||||
$scope.feedback.type = '';
|
||||
|
||||
$scope.feedbackForm.$setUntouched();
|
||||
$scope.feedbackForm.$setPristine();
|
||||
}
|
||||
|
||||
$scope.submitFeedback = function () {
|
||||
$scope.feedback.busy = true;
|
||||
$scope.feedback.success = false;
|
||||
$scope.feedback.error = null;
|
||||
|
||||
Client.feedback($scope.feedback.type, $scope.feedback.subject, $scope.feedback.description, function (error) {
|
||||
if (error) {
|
||||
$scope.feedback.error = error;
|
||||
} else {
|
||||
$scope.feedback.success = true;
|
||||
resetFeedback();
|
||||
}
|
||||
|
||||
$scope.feedback.busy = false;
|
||||
});
|
||||
};
|
||||
}]);
|
||||
@@ -77,7 +77,7 @@
|
||||
<div class="cloudron-model-item-content shadow" ng-class="{ 'selected': size.slug === currentSize.slug }" style="height: {{ 120 + $index * 30 }}px">
|
||||
<!-- <img src="img/box.png" style="transform: scale({{ size.price/50.0 }});"/><br/> -->
|
||||
<h3>{{ size.name }}</h3>
|
||||
<h5>${{ size.price }}/mo</h5>
|
||||
<h5>${{ (size.price/100).toFixed() }}/mo</h5>
|
||||
<button class="btn btn-success" ng-disabled="busy" ng-hide="size.slug === currentSize.slug" ng-click="showUpgradeConfirm(size)">Upgrade</button>
|
||||
<button class="btn btn-success" ng-show="size.slug === currentSize.slug" data-toggle="tooltip" data-placement="top" title="Your Current Model"><i class="fa fa-check"></i></button>
|
||||
</div>
|
||||
|
||||
@@ -80,6 +80,9 @@
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
|
||||
<br/>
|
||||
|
||||
<div>
|
||||
<div class="text-left">
|
||||
<h1>Users <button class="btn btn-primary btn-outline pull-right" data-toggle="modal" data-target="#userAddModal"><i class="fa fa-user-plus"></i> New User</button></h1>
|
||||
|
||||
Reference in New Issue
Block a user