Compare commits

..

65 Commits

Author SHA1 Message Date
girish@cloudron.io a7b5b49d96 fix language 2016-02-26 10:14:37 -08:00
Johannes Zellner 93ef1919c2 Hide superuser checkbox for the user himself 2016-02-26 18:08:56 +01:00
girish@cloudron.io 254d6ac92e handle singular case as well 2016-02-26 08:43:59 -08:00
girish@cloudron.io 3a12265f42 Do not preallocate data volume
This is not tested but will get tested in the next upgrade
2016-02-26 08:27:13 -08:00
Johannes Zellner 71eeb47f0f Hide groups in user listing if the screen is too tiny 2016-02-26 13:10:29 +01:00
Johannes Zellner 5ea5023d97 Better encapsulate the form related functions in install view 2016-02-26 12:50:05 +01:00
Johannes Zellner 1148e21cd4 Add more changes 2016-02-26 12:29:52 +01:00
Johannes Zellner e9a2b2a7cf Disable group access control and show info if there are no groups 2016-02-26 11:59:18 +01:00
Johannes Zellner 7a34f40611 Allow to specify accessRestrictions on app install 2016-02-26 11:47:20 +01:00
Johannes Zellner c630de1003 Fetch groups in appstore.js 2016-02-26 11:24:01 +01:00
Johannes Zellner 74da8f5af8 Add 0.9.3 changes 2016-02-26 11:19:45 +01:00
girish@cloudron.io b758be5ae2 0.9.2 changes 2016-02-26 11:17:36 +01:00
Johannes Zellner c585be4eec Adjust group length error message 2016-02-26 11:07:31 +01:00
Johannes Zellner 3ebc569438 Avoid using superuser in the ui, but describe what it is 2016-02-26 11:03:39 +01:00
Johannes Zellner 5a2cf3cbfe Move superuser checkbox at the bottom of the form 2016-02-26 11:02:43 +01:00
Johannes Zellner 715c5f9f61 Do not set admin group multiple times 2016-02-26 10:56:33 +01:00
Johannes Zellner 6843fda601 Show group names and make sure we don't break layout right away 2016-02-26 10:53:41 +01:00
Johannes Zellner a78f3b1db3 Give more space to some views 2016-02-26 10:52:47 +01:00
girish@cloudron.io 1419108a86 umount is for unmounting 2016-02-25 20:13:16 -08:00
girish@cloudron.io 7a8b457ce9 truncate to shrink the file if required 2016-02-25 19:26:46 -08:00
girish@cloudron.io 10967ff8ce allow 1.2 times RAM
This is basically to allow 2 phabricators and another small app with
no warning on a 4gb droplet :-)
2016-02-25 18:34:28 -08:00
girish@cloudron.io 1fdfd3681c Revert "Display group ids"
This reverts commit d80ce25363061f95cb14e223efd4ab9828739eea.

Didn't mean to commit this
2016-02-25 18:11:25 -08:00
girish@cloudron.io 187d4f9ca2 round memory to nearest GB
os.totalmem returns some close-to-GB number. Because of this two
apps with 2GB don't install on 4GB.
2016-02-25 17:19:39 -08:00
girish@cloudron.io 6b67e64bf1 Display group ids 2016-02-25 16:33:58 -08:00
girish@cloudron.io 7ae6061d72 Edit -> Save 2016-02-25 15:24:50 -08:00
Johannes Zellner e96b9c3e3f Give the user the perception he gets what he pays for 2016-02-26 00:18:47 +01:00
Johannes Zellner c9ca05a703 Do not offer admin group for access restriction
The same can be achieved with a new group and it just
keeps the superuser/admin out of the way here. In any case
admins can always access all apps.
2016-02-26 00:13:09 +01:00
Johannes Zellner 23e5bed247 Fix the group numbering to ignore admin group 2016-02-26 00:02:38 +01:00
Johannes Zellner bae0d728b3 Only show group count to not break layout and allow quickedit 2016-02-26 00:01:25 +01:00
Johannes Zellner 5cd1c7d714 add hand selector 2016-02-26 00:01:01 +01:00
Johannes Zellner d430e902bf Separate superuser checkbox from the other groups in user edit 2016-02-25 23:20:55 +01:00
Johannes Zellner 4fb89de34f Remove 'this is you' 2016-02-25 22:29:06 +01:00
Johannes Zellner 7cd3bb31e1 Add new support type for failing erroring apps 2016-02-25 21:38:37 +01:00
Girish Ramakrishnan 2857158543 do not set memoryLimit 2016-02-25 11:05:19 -08:00
Johannes Zellner 82a347ea4b Add tooltips for superusers 2016-02-25 16:07:31 +01:00
Johannes Zellner b5c7f978a2 Do not show admin group in group listing 2016-02-25 15:54:30 +01:00
Johannes Zellner 625da29fce Show admins with an icon instead of a group tag 2016-02-25 15:53:36 +01:00
Johannes Zellner b82b183df6 Show alternative text for non admins, when no apps are available for this user 2016-02-25 15:38:46 +01:00
Johannes Zellner ce36fadf2b Fix bug for non admins to view the appstore 2016-02-25 15:34:44 +01:00
Johannes Zellner 2429599733 Change text from installed to your applications 2016-02-25 15:34:33 +01:00
Johannes Zellner 261a0a1728 Add account ui to change displayName 2016-02-25 15:09:52 +01:00
Johannes Zellner d8def61f67 Encapsulate the business logic in the account controller 2016-02-25 14:58:26 +01:00
Johannes Zellner 2732af24c1 Some code cleanups and bugfixes for the accounts view 2016-02-25 14:46:53 +01:00
Johannes Zellner 3d48da0e8d Remove unused function in client to change email 2016-02-25 14:34:35 +01:00
Johannes Zellner d3b8bd1314 Remove password field for user email change 2016-02-25 14:34:16 +01:00
Johannes Zellner f600ebcf19 Remove password entry from user edit form 2016-02-25 14:15:48 +01:00
Johannes Zellner 160467e199 Do not require password for user profile changes 2016-02-25 14:03:42 +01:00
Johannes Zellner 384c410e7c Do not require a password for user profile changes 2016-02-25 13:54:44 +01:00
Johannes Zellner 84c4187fa9 Test normal users accessing the user api 2016-02-25 13:53:18 +01:00
Johannes Zellner 4f7fd9177c Allow user details only for the same user or admins 2016-02-25 13:44:53 +01:00
Johannes Zellner b5b0ab7475 Require admin rights for user listing 2016-02-25 13:43:15 +01:00
Johannes Zellner a0d7406b3c Do not allow normal users to get group listings or details 2016-02-25 13:34:01 +01:00
Johannes Zellner 7165be0513 Warn the user about installing too many apps, but give an override button 2016-02-25 12:41:15 +01:00
Johannes Zellner 9c995277f7 Fixup the apps unit tests 2016-02-25 12:20:18 +01:00
Johannes Zellner aa693e529b Only list apps where a user has access to 2016-02-25 12:20:11 +01:00
Johannes Zellner 63013c7297 Just check for .admin flag in the user object 2016-02-25 11:42:25 +01:00
Johannes Zellner c8db6419d8 Admins are not special cased in apps.js app listing
This is done in the route
2016-02-25 11:41:14 +01:00
Johannes Zellner 93c1ddd982 Always amend the admin flag for further use 2016-02-25 11:40:48 +01:00
Johannes Zellner df102ec374 Add basic getAllByUser() app tests 2016-02-25 11:28:45 +01:00
Johannes Zellner 9688e4c124 Add apps.getAllByUser() 2016-02-25 11:28:29 +01:00
Johannes Zellner 00d277b1c3 Make the error dialog generic not only for app install errors 2016-02-24 18:36:40 +01:00
Johannes Zellner 0fb44bfbc1 Forward the error.message instead of making a new Error object
That leads to only Internal Error
2016-02-24 18:12:31 +01:00
Johannes Zellner c167bd8996 Set error in installationProgress also on uninstallation errors 2016-02-24 17:53:21 +01:00
Johannes Zellner a3737c3797 Report access denied errors in route53 backend 2016-02-23 17:29:28 +01:00
Johannes Zellner 8fcb0b46a5 add 0.9.1 changes 2016-02-21 14:51:51 +01:00
30 changed files with 829 additions and 467 deletions
+4
View File
@@ -420,3 +420,7 @@
- Allow more apps to be installed in bigger sized cloudrons
- Allow user to override memory limit warning and install anyway
[0.9.3]
- Admin flag is handled outside of groups
- User interface fixes for groups
- Allow to set access restrictions on app installation
+1 -1
View File
@@ -268,7 +268,7 @@ echo "==== Install box-setup systemd script ===="
cat > /etc/systemd/system/box-setup.service <<EOF
[Unit]
Description=Box Setup
Before=docker.service umount.target collectd.service
Before=docker.service collectd.service
After=do-resize.service
[Service]
+3 -2
View File
@@ -24,7 +24,6 @@ readonly app_count=$((${physical_memory} / 200)) # estimated app count
readonly disk_size_gb=$(fdisk -l ${disk_device} | grep "Disk ${disk_device}" | awk '{ print $3 }')
readonly disk_size=$((disk_size_gb * 1024))
readonly backup_swap_size=1024
# readonly system_size=5120 # 5 gigs for system libs, installer, box code and tmp
readonly system_size=10240 # 10 gigs for system libs, apps images, installer, box code and tmp
readonly ext4_reserved=$((disk_size * 5 / 100)) # this can be changes using tune2fs -m percent /dev/vda1
@@ -59,7 +58,9 @@ echo "Resizing data volume"
home_data_size=$((disk_size - system_size - swap_size - backup_swap_size - ext4_reserved))
echo "Resizing up btrfs user data to size ${home_data_size}M"
umount "${USER_DATA_DIR}"
fallocate -l "${home_data_size}m" "${USER_DATA_FILE}" # does not overwrite existing data
# Do not preallocate (non-sparse). Doing so overallocates for data too much in advance and causes problems when using many apps with smaller data
# fallocate -l "${home_data_size}m" "${USER_DATA_FILE}" # does not overwrite existing data
truncate -s "${home_data_size}m" "${USER_DATA_FILE}" # this will shrink it if the file had existed. this is useful when running this script on a live system
mount "${USER_DATA_FILE}"
btrfs filesystem resize max "${USER_DATA_DIR}"
+16
View File
@@ -12,6 +12,7 @@ exports = module.exports = {
getBySubdomain: getBySubdomain,
getByIpAddress: getByIpAddress,
getAll: getAll,
getAllByUser: getAllByUser,
purchase: purchase,
install: install,
configure: configure,
@@ -357,6 +358,21 @@ function getAll(callback) {
});
}
function getAllByUser(user, callback) {
assert.strictEqual(typeof user, 'object');
assert.strictEqual(typeof callback, 'function');
getAll(function (error, result) {
if (error) return callback(error);
async.filter(result, function (app, callback) {
hasAccessTo(app, user, function (error, hasAccess) {
callback(hasAccess);
});
}, callback.bind(null, null)); // never error
});
}
function purchase(appStoreId, callback) {
assert.strictEqual(typeof appStoreId, 'string');
assert.strictEqual(typeof callback, 'function');
+7 -1
View File
@@ -701,7 +701,13 @@ function uninstall(app, callback) {
updateApp.bind(null, app, { installationProgress: '95, Remove app from database' }),
appdb.del.bind(null, app.id)
], callback);
], function seriesDone(error) {
if (error) {
debugApp(app, 'error uninstalling app: %s', error);
return updateApp(app, { installationState: appdb.ISTATE_ERROR, installationProgress: error.message }, callback.bind(null, error));
}
callback(null);
});
}
function runApp(app, callback) {
+9 -1
View File
@@ -16,6 +16,7 @@ var assert = require('assert'),
debug = require('debug')('box:auth'),
LocalStrategy = require('passport-local').Strategy,
crypto = require('crypto'),
groups = require('./groups'),
passport = require('passport'),
tokendb = require('./tokendb'),
user = require('./user'),
@@ -123,7 +124,14 @@ function initialize(callback) {
// amend the tokenType of the token owner
user.tokenType = tokenType;
callback(null, user, info);
// amend the admin flag
groups.isMember(groups.ADMIN_GROUP_ID, user.id, function (error, isAdmin) {
if (error) return callback(error);
user.admin = isAdmin;
callback(null, user, info);
});
});
});
}));
+14 -12
View File
@@ -39,7 +39,8 @@ function getZoneByName(dnsConfig, zoneName, callback) {
var route53 = new AWS.Route53(getDnsCredentials(dnsConfig));
route53.listHostedZones({}, function (error, result) {
if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, new Error(error)));
if (error && error.code === 'AccessDenied') return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message));
if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message));
var zone = result.HostedZones.filter(function (zone) {
return zone.Name.slice(0, -1) === zoneName; // aws zone name contains a '.' at the end
@@ -84,11 +85,9 @@ function add(dnsConfig, zoneName, subdomain, type, values, callback) {
var route53 = new AWS.Route53(getDnsCredentials(dnsConfig));
route53.changeResourceRecordSets(params, function(error, result) {
if (error && error.code === 'PriorRequestNotComplete') {
return callback(new SubdomainError(SubdomainError.STILL_BUSY, error.message));
} else if (error) {
return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message));
}
if (error && error.code === 'AccessDenied') return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message));
if (error && error.code === 'PriorRequestNotComplete') return callback(new SubdomainError(SubdomainError.STILL_BUSY, error.message));
if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message));
callback(null, result.ChangeInfo.Id);
});
@@ -131,7 +130,8 @@ function get(dnsConfig, zoneName, subdomain, type, callback) {
var route53 = new AWS.Route53(getDnsCredentials(dnsConfig));
route53.listResourceRecordSets(params, function (error, result) {
if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, new Error(error)));
if (error && error.code === 'AccessDenied') return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message));
if (error) return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message));
if (result.ResourceRecordSets.length === 0) return callback(null, [ ]);
if (result.ResourceRecordSets[0].Name !== params.StartRecordName && result.ResourceRecordSets[0].Type !== params.StartRecordType) return callback(null, [ ]);
@@ -175,21 +175,22 @@ function del(dnsConfig, zoneName, subdomain, type, values, callback) {
var route53 = new AWS.Route53(getDnsCredentials(dnsConfig));
route53.changeResourceRecordSets(params, function(error, result) {
if (error && error.code === 'AccessDenied') return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message));
if (error && error.message && error.message.indexOf('it was not found') !== -1) {
debug('del: resource record set not found.', error);
return callback(new SubdomainError(SubdomainError.NOT_FOUND, new Error(error)));
return callback(new SubdomainError(SubdomainError.NOT_FOUND, error.message));
} else if (error && error.code === 'NoSuchHostedZone') {
debug('del: hosted zone not found.', error);
return callback(new SubdomainError(SubdomainError.NOT_FOUND, new Error(error)));
return callback(new SubdomainError(SubdomainError.NOT_FOUND, error.message));
} else if (error && error.code === 'PriorRequestNotComplete') {
debug('del: resource is still busy', error);
return callback(new SubdomainError(SubdomainError.STILL_BUSY, new Error(error)));
return callback(new SubdomainError(SubdomainError.STILL_BUSY, error.message));
} else if (error && error.code === 'InvalidChangeBatch') {
debug('del: invalid change batch. No such record to be deleted.');
return callback(new SubdomainError(SubdomainError.NOT_FOUND, new Error(error)));
return callback(new SubdomainError(SubdomainError.NOT_FOUND, error.message));
} else if (error) {
debug('del: error', error);
return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, new Error(error)));
return callback(new SubdomainError(SubdomainError.EXTERNAL_ERROR, error.message));
}
callback(null);
@@ -206,6 +207,7 @@ function getChangeStatus(dnsConfig, changeId, callback) {
var route53 = new AWS.Route53(getDnsCredentials(dnsConfig));
route53.getChange({ Id: changeId }, function (error, result) {
if (error && error.code === 'AccessDenied') return callback(new SubdomainError(SubdomainError.ACCESS_DENIED, error.message));
if (error) return callback(error);
callback(null, result.ChangeInfo.Status);
+1 -1
View File
@@ -59,7 +59,7 @@ function validateGroupname(name) {
assert.strictEqual(typeof name, 'string');
var RESERVED = [ 'admins', 'users' ]; // ldap code uses 'users' pseudo group
if (name.length <= 2) return new GroupError(GroupError.BAD_NAME, 'name must be atleast 3 chars');
if (name.length <= 2) return new GroupError(GroupError.BAD_NAME, 'name must be atleast 2 chars');
if (name.length >= 200) return new GroupError(GroupError.BAD_NAME, 'name too long');
if (!/^[A-Za-z0-9_-]*$/.test(name)) return new GroupError(GroupError.BAD_NAME, 'name can only have A-Za-z0-9_-');
+6 -2
View File
@@ -22,7 +22,8 @@ exports = module.exports = {
FEEDBACK_TYPE_FEEDBACK: 'feedback',
FEEDBACK_TYPE_TICKET: 'ticket',
FEEDBACK_TYPE_APP: 'app',
FEEDBACK_TYPE_APP_MISSING: 'app_missing',
FEEDBACK_TYPE_APP_ERROR: 'app_error',
sendFeedback: sendFeedback,
_getMailQueue: _getMailQueue,
@@ -396,7 +397,10 @@ function sendFeedback(user, type, subject, description) {
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);
assert(type === exports.FEEDBACK_TYPE_TICKET ||
type === exports.FEEDBACK_TYPE_FEEDBACK ||
type === exports.FEEDBACK_TYPE_APP_MISSING ||
type === exports.FEEDBACK_TYPE_APP_ERROR);
var mailOptions = {
from: config.get('adminEmail'),
+1 -1
View File
@@ -13,7 +13,7 @@ app.controller('Controller', [function () {}]);
</script>
<center>
<h1>Hello <%= user.username %> create a password</h1>
<h1>Hello <%= user.username %>, set a password</h1>
</center>
<div class="container" ng-app="Application" ng-controller="Controller">
+4 -1
View File
@@ -76,7 +76,10 @@ function getAppBySubdomain(req, res, next) {
}
function getApps(req, res, next) {
apps.getAll(function (error, allApps) {
assert.strictEqual(typeof req.user, 'object');
var func = req.user.admin ? apps.getAll : apps.getAllByUser.bind(null, req.user);
func(function (error, allApps) {
if (error) return next(new HttpError(500, error));
allApps = allApps.map(removeInternalAppFields);
+4 -1
View File
@@ -131,7 +131,10 @@ function update(req, res, next) {
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 (req.body.type !== mailer.FEEDBACK_TYPE_FEEDBACK &&
req.body.type !== mailer.FEEDBACK_TYPE_TICKET &&
req.body.type !== mailer.FEEDBACK_TYPE_APP_MISSING &&
req.body.type !== mailer.FEEDBACK_TYPE_APP_ERROR) return next(new HttpError(400, 'type must be either "ticket", "feedback" or "app_missing" or "app_error"'));
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'));
+3 -4
View File
@@ -378,7 +378,7 @@ describe('Apps', function () {
superagent.post(SERVER_URL + '/api/v1/apps/install')
.query({ access_token: token })
.send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: null, accessRestriction: null })
.send({ appStoreId: APP_STORE_ID, manifest: APP_MANIFEST, password: PASSWORD, location: APP_LOCATION, portBindings: null, accessRestriction: { users: [ 'someuser' ], groups: [] } })
.end(function (err, res) {
expect(res.statusCode).to.equal(202);
expect(res.body.id).to.be.a('string');
@@ -433,14 +433,13 @@ describe('Apps', function () {
});
});
it('non admin can get all apps', function (done) {
it('non admin cannot see the app due to accessRestriction', function (done) {
superagent.get(SERVER_URL + '/api/v1/apps')
.query({ access_token: token_1 })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.apps).to.be.an('array');
expect(res.body.apps[0].id).to.eql(APP_ID);
expect(res.body.apps[0].installationState).to.be.ok();
expect(res.body.apps.length).to.equal(0);
done();
});
});
+36 -2
View File
@@ -15,13 +15,15 @@ var appdb = require('../../appdb.js'),
superagent = require('superagent'),
server = require('../../server.js'),
settings = require('../../settings.js'),
tokendb = require('../../tokendb.js'),
nock = require('nock'),
userdb = require('../../userdb.js');
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME = 'admin', PASSWORD = 'Foobar?1337', EMAIL ='silly@me.com';
var token = null;
var USERNAME_1 = 'user', PASSWORD_1 = 'Foobar?1337', EMAIL_1 ='happy@me.com';
var token, token_1 = null;
var server;
function setup(done) {
@@ -48,8 +50,22 @@ function setup(done) {
callback();
});
},
function (callback) {
superagent.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ username: USERNAME_1, email: EMAIL_1, invite: false })
.end(function (error, result) {
expect(result).to.be.ok();
expect(result.statusCode).to.eql(201);
token_1 = tokendb.generateToken();
// HACK to get a token for second user (passwords are generated and the user should have gotten a password setup link...)
tokendb.add(token_1, tokendb.PREFIX_USER + USERNAME_1, 'test-client-id', Date.now() + 100000, '*', callback);
});
}
], done);
], done);
}
function cleanup(done) {
@@ -73,6 +89,15 @@ describe('Groups API', function () {
});
});
it('cannot get groups as normal user', function (done) {
superagent.get(SERVER_URL + '/api/v1/groups')
.query({ access_token: token_1 })
.end(function (err, res) {
expect(res.statusCode).to.equal(403);
done();
});
});
it('can get groups', function (done) {
superagent.get(SERVER_URL + '/api/v1/groups')
.query({ access_token: token })
@@ -127,6 +152,15 @@ describe('Groups API', function () {
});
});
it('cannot get existing group with normal user', function (done) {
superagent.get(SERVER_URL + '/api/v1/groups/admin')
.query({ access_token: token_1 })
.end(function (error, result) {
expect(result.statusCode).to.equal(403);
done();
});
});
it('can get existing group', function (done) {
superagent.get(SERVER_URL + '/api/v1/groups/admin')
.query({ access_token: token })
+71 -42
View File
@@ -19,9 +19,9 @@ var config = require('../../config.js'),
var SERVER_URL = 'http://localhost:' + config.get('port');
var USERNAME_0 = 'admin', PASSWORD = 'Foobar?1337', EMAIL = 'silly@me.com', EMAIL_0_NEW = 'stupid@me.com', DISPLAY_NAME_0_NEW = 'New Name';
var USERNAME_0 = 'admin', PASSWORD = 'Foobar?1337', EMAIL_0 = 'silly@me.com', EMAIL_0_NEW = 'stupid@me.com', DISPLAY_NAME_0_NEW = 'New Name';
var USERNAME_1 = 'userTheFirst', EMAIL_1 = 'tao@zen.mac';
var USERNAME_2 = 'userTheSecond', EMAIL_2 = 'user@foo.bar';
var USERNAME_2 = 'userTheSecond', EMAIL_2 = 'user@foo.bar', EMAIL_2_NEW = 'happy@me.com';
var USERNAME_3 = 'userTheThird', EMAIL_3 = 'user3@foo.bar';
var server;
@@ -105,7 +105,7 @@ describe('User API', function () {
superagent.post(SERVER_URL + '/api/v1/cloudron/activate')
.query({ setupToken: 'somesetuptoken' })
.send({ username: USERNAME_0, password: PASSWORD, email: EMAIL })
.send({ username: USERNAME_0, password: PASSWORD, email: EMAIL_0 })
.end(function (err, res) {
expect(res.statusCode).to.equal(201);
@@ -133,7 +133,7 @@ describe('User API', function () {
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL);
expect(res.body.email).to.equal(EMAIL_0);
expect(res.body.admin).to.be.ok();
// stash for further use
@@ -167,7 +167,7 @@ describe('User API', function () {
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL);
expect(res.body.email).to.equal(EMAIL_0);
expect(res.body.admin).to.be.ok();
done();
});
@@ -206,7 +206,7 @@ describe('User API', function () {
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL);
expect(res.body.email).to.equal(EMAIL_0);
expect(res.body.admin).to.be.ok();
expect(res.body.displayName).to.be.a('string');
expect(res.body.password).to.not.be.ok();
@@ -376,10 +376,19 @@ describe('User API', function () {
});
});
it('second user userInfo', function (done) {
it('second user userInfo fails for first user', function (done) {
superagent.get(SERVER_URL + '/api/v1/users/' + USERNAME_2)
.query({ access_token: token_1 })
.end(function (error, result) {
expect(result.statusCode).to.equal(403);
done();
});
});
it('second user userInfo succeeds for second user', function (done) {
superagent.get(SERVER_URL + '/api/v1/users/' + USERNAME_2)
.query({ access_token: token_2 })
.end(function (error, result) {
expect(result.statusCode).to.equal(200);
expect(result.body.username).to.equal(USERNAME_2);
expect(result.body.email).to.equal(EMAIL_2);
@@ -392,16 +401,25 @@ describe('User API', function () {
it('create user with same username should fail', function (done) {
superagent.post(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.send({ username: USERNAME_2, email: EMAIL, invite: false })
.send({ username: USERNAME_2, email: EMAIL_0, invite: false })
.end(function (err, res) {
expect(res.statusCode).to.equal(409);
done();
});
});
it('list users', function (done) {
it('list users fails for normal user', function (done) {
superagent.get(SERVER_URL + '/api/v1/users')
.query({ access_token: token_2 })
.end(function (error, res) {
expect(res.statusCode).to.equal(403);
done();
});
});
it('list users succeeds for admin', function (done) {
superagent.get(SERVER_URL + '/api/v1/users')
.query({ access_token: token })
.end(function (error, res) {
expect(error).to.be(null);
expect(res.statusCode).to.equal(200);
@@ -483,29 +501,9 @@ describe('User API', function () {
// Change email
it('change email fails due to missing token', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.send({ password: PASSWORD, email: EMAIL_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(401);
done();
});
});
it('change email fails due to missing password', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ email: EMAIL_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(400);
done();
});
});
it('change email fails due to wrong password', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD+PASSWORD, email: EMAIL_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(403);
expect(result.statusCode).to.equal(401);
done();
});
});
@@ -513,37 +511,68 @@ describe('User API', function () {
it('change email fails due to invalid email', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD, email: 'foo@bar' })
.send({ email: 'foo@bar' })
.end(function (error, result) {
expect(result.statusCode).to.equal(400);
done();
});
});
it('change email for other user fails', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token_2 })
.send({ email: 'foobar@bar.baz' })
.end(function (error, result) {
expect(result.statusCode).to.equal(403);
done();
});
});
it('change user succeeds without email nor displayName', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD })
.send({})
.end(function (error, result) {
expect(result.statusCode).to.equal(204);
done();
});
});
it('change email succeeds', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD, email: EMAIL_0_NEW })
it('change email for own user succeeds', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_2)
.query({ access_token: token_2 })
.send({ email: EMAIL_2_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(204);
superagent.get(SERVER_URL + '/api/v1/users/' + USERNAME_0)
superagent.get(SERVER_URL + '/api/v1/users/' + USERNAME_2)
.query({ access_token: token_2 })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_2);
expect(res.body.email).to.equal(EMAIL_2_NEW);
expect(res.body.admin).to.equal(false);
expect(res.body.displayName).to.equal('');
done();
});
});
});
it('change email as admin for other user succeeds', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_2)
.query({ access_token: token })
.send({ email: EMAIL_2 })
.end(function (error, result) {
expect(result.statusCode).to.equal(204);
superagent.get(SERVER_URL + '/api/v1/users/' + USERNAME_2)
.query({ access_token: token })
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL_0_NEW);
expect(res.body.admin).to.be.ok();
expect(res.body.username).to.equal(USERNAME_2);
expect(res.body.email).to.equal(EMAIL_2);
expect(res.body.admin).to.equal(false);
expect(res.body.displayName).to.equal('');
done();
@@ -554,7 +583,7 @@ describe('User API', function () {
it('change displayName succeeds', function (done) {
superagent.put(SERVER_URL + '/api/v1/users/' + USERNAME_0)
.query({ access_token: token })
.send({ password: PASSWORD, displayName: DISPLAY_NAME_0_NEW })
.send({ displayName: DISPLAY_NAME_0_NEW })
.end(function (error, result) {
expect(result.statusCode).to.equal(204);
@@ -563,7 +592,7 @@ describe('User API', function () {
.end(function (err, res) {
expect(res.statusCode).to.equal(200);
expect(res.body.username).to.equal(USERNAME_0);
expect(res.body.email).to.equal(EMAIL_0_NEW);
expect(res.body.email).to.equal(EMAIL_0);
expect(res.body.admin).to.be.ok();
expect(res.body.displayName).to.equal(DISPLAY_NAME_0_NEW);
+6 -8
View File
@@ -92,6 +92,7 @@ function update(req, res, next) {
if ('displayName' in req.body && typeof req.body.displayName !== 'string') return next(new HttpError(400, 'displayName must be string'));
if (req.user.tokenType !== tokendb.TYPE_USER) return next(new HttpError(403, 'Token type not allowed'));
if (req.user.id !== req.params.userId && !req.user.admin) return next(new HttpError(403, 'Not allowed'));
user.get(req.params.userId, function (error, result) {
if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'No such user'));
@@ -135,6 +136,9 @@ function listUser(req, res, next) {
function info(req, res, next) {
assert.strictEqual(typeof req.params.userId, 'string');
assert.strictEqual(typeof req.user, 'object');
if (req.user.id !== req.params.userId && !req.user.admin) return next(new HttpError(403, 'Not allowed'));
user.get(req.params.userId, function (error, result) {
if (error && error.reason === UserError.NOT_FOUND) return next(new HttpError(404, 'No such user'));
@@ -202,15 +206,9 @@ function verifyPassword(req, res, next) {
function requireAdmin(req, res, next) {
assert.strictEqual(typeof req.user, 'object');
groups.isMember(groups.ADMIN_GROUP_ID, req.user.id, function (error, isAdmin) {
if (error) return next(new HttpError(500, error));
if (!req.user.admin) return next(new HttpError(403, 'API call requires admin rights.'));
if (!isAdmin) return next(new HttpError(403, 'API call requires admin rights.'));
req.user.admin = true;
next();
});
next();
}
function sendInvite(req, res, next) {
+9 -6
View File
@@ -100,19 +100,22 @@ function initializeExpressSync() {
router.get ('/api/v1/profile', profileScope, routes.user.profile);
router.get ('/api/v1/users', usersScope, routes.user.list);
// user routes only for admins
router.get ('/api/v1/users', usersScope, routes.user.requireAdmin, routes.user.list);
router.post('/api/v1/users', usersScope, routes.user.requireAdmin, routes.user.create);
router.get ('/api/v1/users/:userId', usersScope, routes.user.info);
router.put ('/api/v1/users/:userId', usersScope, routes.user.verifyPassword, routes.user.update);
router.del ('/api/v1/users/:userId', usersScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.user.remove);
router.post('/api/v1/users/:userId/password', usersScope, routes.user.changePassword); // changePassword verifies password
router.put ('/api/v1/users/:userId/set_groups', usersScope, routes.user.requireAdmin, routes.user.setGroups);
router.post('/api/v1/users/:userId/invite', usersScope, routes.user.requireAdmin, routes.user.sendInvite);
// user routes for admins and users operating on their own account
router.get ('/api/v1/users/:userId', usersScope, routes.user.info);
router.put ('/api/v1/users/:userId', usersScope, routes.user.update);
router.post('/api/v1/users/:userId/password', usersScope, routes.user.changePassword); // changePassword verifies password
// Group management
router.get ('/api/v1/groups', usersScope, routes.groups.list);
router.get ('/api/v1/groups', usersScope, routes.user.requireAdmin, routes.groups.list);
router.post('/api/v1/groups', usersScope, routes.user.requireAdmin, routes.groups.create);
router.get ('/api/v1/groups/:groupId', usersScope, routes.groups.get);
router.get ('/api/v1/groups/:groupId', usersScope, routes.user.requireAdmin, routes.groups.get);
router.del ('/api/v1/groups/:groupId', usersScope, routes.user.requireAdmin, routes.user.verifyPassword, routes.groups.remove);
// form based login routes used by oauth2 frame
+1
View File
@@ -44,6 +44,7 @@ SubdomainError.EXTERNAL_ERROR = 'External error';
SubdomainError.STILL_BUSY = 'Still busy';
SubdomainError.MISSING_CREDENTIALS = 'Missing credentials';
SubdomainError.INTERNAL_ERROR = 'Missing credentials';
SubdomainError.ACCESS_DENIED = 'Access denied';
// choose which subdomain backend we use for test purpose we use route53
function api(provider) {
+109 -8
View File
@@ -13,15 +13,54 @@ var appdb = require('../appdb.js'),
config = require('../config.js'),
constants = require('../constants.js'),
database = require('../database.js'),
expect = require('expect.js');
expect = require('expect.js'),
groups = require('../groups.js'),
hat = require('hat'),
userdb = require('../userdb.js');
describe('Apps', function () {
var ADMIN_0 = {
id: 'admin123',
username: 'admin123',
password: 'secret',
email: 'admin@me.com',
salt: 'morton',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: hat(256),
displayName: ''
};
var USER_0 = {
id: 'uuid213',
username: 'uuid213',
password: 'secret',
email: 'safe@me.com',
salt: 'morton',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: hat(256),
displayName: ''
};
var USER_1 = {
id: 'uuid2134',
username: 'uuid2134',
password: 'secret',
email: 'safe1@me.com',
salt: 'morton',
createdAt: 'sometime back',
modifiedAt: 'now',
resetToken: hat(256),
displayName: ''
};
var GROUP_0 = 'somegroup';
var GROUP_1 = 'someothergroup';
var APP_0 = {
id: 'appid-0',
appStoreId: 'appStoreId-0',
installationState: appdb.ISTATE_PENDING_INSTALL,
installationProgress: null,
runState: null,
location: 'some-location-0',
manifest: {
version: '0.1', dockerImage: 'docker/app0', healthCheckPath: '/', httpPort: 80, title: 'app0',
@@ -32,19 +71,51 @@ describe('Apps', function () {
}
}
},
httpPort: null,
containerId: null,
portBindings: { PORT: 5678 },
healthy: null,
accessRestriction: null,
memoryLimit: 0
};
var APP_1 = {
id: 'appid-1',
appStoreId: 'appStoreId-1',
location: 'some-location-1',
manifest: {
version: '0.1', dockerImage: 'docker/app1', healthCheckPath: '/', httpPort: 80, title: 'app1',
tcpPorts: {}
},
portBindings: null,
accessRestriction: { users: [ 'someuser' ], groups: [ GROUP_0 ] },
memoryLimit: 0
};
var APP_2 = {
id: 'appid-2',
appStoreId: 'appStoreId-2',
location: 'some-location-2',
manifest: {
version: '0.1', dockerImage: 'docker/app2', healthCheckPath: '/', httpPort: 80, title: 'app2',
tcpPorts: {}
},
portBindings: null,
accessRestriction: { users: [ 'someuser', USER_0.id ], groups: [ GROUP_1 ] },
memoryLimit: 0
};
before(function (done) {
async.series([
database.initialize,
database._clear,
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0.accessRestriction, APP_0.memoryLimit)
userdb.add.bind(null, ADMIN_0.id, ADMIN_0),
userdb.add.bind(null, USER_0.id, USER_0),
userdb.add.bind(null, USER_1.id, USER_1),
groups.create.bind(null, GROUP_0),
groups.create.bind(null, GROUP_1),
groups.addMember.bind(null, groups.ADMIN_GROUP_ID, ADMIN_0.id),
groups.addMember.bind(null, GROUP_0, USER_1.id),
appdb.add.bind(null, APP_0.id, APP_0.appStoreId, APP_0.manifest, APP_0.location, APP_0.portBindings, APP_0.accessRestriction, APP_0.memoryLimit),
appdb.add.bind(null, APP_1.id, APP_1.appStoreId, APP_1.manifest, APP_1.location, APP_1.portBindings, APP_1.accessRestriction, APP_1.memoryLimit),
appdb.add.bind(null, APP_2.id, APP_2.appStoreId, APP_2.manifest, APP_2.location, APP_2.portBindings, APP_2.accessRestriction, APP_2.memoryLimit)
], done);
});
@@ -238,6 +309,36 @@ describe('Apps', function () {
done();
});
});
});
describe('getAllByUser', function () {
it('succeeds for USER_0', function (done) {
apps.getAllByUser(USER_0, function (error, result) {
expect(error).to.equal(null);
expect(result.length).to.equal(2);
expect(result[0].id).to.equal(APP_0.id);
expect(result[1].id).to.equal(APP_2.id);
done();
});
});
it('succeeds for USER_1', function (done) {
apps.getAllByUser(USER_1, function (error, result) {
expect(error).to.equal(null);
expect(result.length).to.equal(2);
expect(result[0].id).to.equal(APP_0.id);
expect(result[1].id).to.equal(APP_1.id);
done();
});
});
it('succeeds with admin not being special', function (done) {
apps.getAllByUser(ADMIN_0, function (error, result) {
expect(error).to.equal(null);
expect(result.length).to.equal(1);
expect(result[0].id).to.equal(APP_0.id);
done();
});
});
});
});
+3 -15
View File
@@ -162,6 +162,7 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
this._userInfo.id = userInfo.id;
this._userInfo.username = userInfo.username;
this._userInfo.email = userInfo.email;
this._userInfo.displayName = userInfo.displayName;
this._userInfo.admin = !!userInfo.admin;
this._userInfo.gravatar = 'https://www.gravatar.com/avatar/' + md5.createHash(userInfo.email.toLowerCase()) + '.jpg?s=24&d=mm';
this._userInfo.gravatarHuge = 'https://www.gravatar.com/avatar/' + md5.createHash(userInfo.email.toLowerCase()) + '.jpg?s=128&d=mm';
@@ -607,9 +608,8 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
}).error(defaultErrorHandler(callback));
};
Client.prototype.updateUser = function (user, password, callback) {
Client.prototype.updateUser = function (user, callback) {
var data = {
password: password,
email: user.email,
displayName: user.displayName
};
@@ -643,18 +643,6 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
}).error(defaultErrorHandler(callback));
};
Client.prototype.changeEmail = function (email, password, callback) {
var data = {
password: password,
email: email
};
$http.put(client.apiOrigin + '/api/v1/users/' + this._userInfo.username, data).success(function(data, status) {
if (status !== 204) return callback(new ClientError(status, data));
callback(null, data);
}).error(defaultErrorHandler(callback));
};
Client.prototype.refreshUserInfo = function (callback) {
var that = this;
@@ -780,7 +768,7 @@ angular.module('Application').service('Client', ['$http', 'md5', 'Notification',
var totalMemory = roundedMemory * 1.2; // box-setup.sh creates equal amount of swap. 1.2 factor is arbitrary
var available = (totalMemory || 0) - used;
return (available - needed) > 0;
return (available - needed) >= 0;
};
client = new Client();
+9
View File
@@ -144,6 +144,15 @@ app.filter('inProgressApps', function () {
};
});
app.filter('ignoreAdminGroup', function () {
return function (groups) {
return groups.filter(function (group) {
if (group.id) return group.id !== 'admin';
return group !== 'admin';
});
};
});
app.filter('applicationLink', function() {
return function(app) {
if (app.installationState === ISTATES.INSTALLED && app.health === HSTATES.HEALTHY) {
+31 -1
View File
@@ -83,6 +83,14 @@ $table-border-color: transparent !default;
border-color: $brand-danger !important;
}
.elide-table-cell {
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
max-width: 300px;
width: 300px;
}
// ----------------------------
// Main classes
// ----------------------------
@@ -109,6 +117,11 @@ html {
margin: 0 auto;
}
.content-large {
max-width: 970px;
margin: 0 auto;
}
.navbar-brand-icon {
padding: 5px 15px;
}
@@ -360,7 +373,7 @@ html {
}
.card-large {
max-width: 800px;
max-width: 970px;
}
.text-success {
@@ -584,6 +597,10 @@ footer a {
padding: 10px;
}
.hand {
cursor: pointer;
}
// ----------------------------
// Upgrade
@@ -905,3 +922,16 @@ $graphs-success-alt: lighten(#27CE65, 20%);
opacity: 1;
}
}
// ----------------------------
// Users view
// ----------------------------
.group-badge {
margin-right: 10px;
}
.no-wrap {
text-overflow: ellipsis;
white-space: nowrap;
}
+70 -49
View File
@@ -6,39 +6,38 @@
<h4 class="modal-title">Change Your Password</h4>
</div>
<div class="modal-body">
<form name="passwordchange_form" role="form" novalidate ng-submit="doChangePassword(passwordchange_form)" autocomplete="off">
<fieldset>
<div class="form-group" ng-class="{ 'has-error': (!passwordchange_form.password.$dirty && passwordchange.error.password) || (passwordchange_form.password.$dirty && passwordchange_form.password.$invalid) }">
<label class="control-label" for="inputPasswordChangePassword">Current Password</label>
<div class="control-label" ng-show="(!passwordchange_form.password.$dirty && passwordchange.error.password) || (passwordchange_form.password.$dirty && passwordchange_form.password.$invalid)">
<small ng-show="!passwordchange_form.password.$dirty && passwordchange.error.password">Wrong password</small>
<small ng-show="passwordchange_form.password.$dirty && passwordchange_form.password.$error.required">A password is required</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.password" id="inputPasswordChangePassword" name="password" required autofocus>
<form name="passwordChangeForm" role="form" novalidate ng-submit="passwordchange.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid) }">
<label class="control-label" for="inputPasswordChangePassword">Current Password</label>
<div class="control-label" ng-show="(!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid)">
<small ng-show="!passwordChangeForm.password.$dirty && passwordchange.error.password">Wrong password</small>
<small ng-show="passwordChangeForm.password.$dirty && passwordChangeForm.password.$error.required">A password is required</small>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordchange_form.newPassword.$dirty && passwordchange.error.newPassword) || (passwordchange_form.newPassword.$dirty && passwordchange_form.newPassword.$invalid) }">
<label class="control-label" for="inputPasswordChangeNewPassword">New Password</label>
<div class="control-label" ng-show="(!passwordchange_form.newPassword.$dirty && passwordchange.error.newPassword) || (passwordchange_form.newPassword.$dirty && passwordchange_form.newPassword.$invalid)">
<small ng-show="!passwordchange_form.newPassword.$dirty && passwordchange.error.newPassword">{{ passwordchange.error.newPassword }}<br/><br/></small>
<small ng-show=" passwordchange_form.newPassword.$dirty && passwordchange_form.newPassword.$invalid">Password must be 8-30 character with at least one uppercase, one numeric and one special character</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.newPassword" id="inputPasswordChangeNewPassword" name="newPassword" ng-pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{8,30}$/" required autofocus>
<input type="password" class="form-control" ng-model="passwordchange.password" id="inputPasswordChangePassword" name="password" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid) }">
<label class="control-label" for="inputPasswordChangeNewPassword">New Password</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid)">
<small ng-show="!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword">{{ passwordchange.error.newPassword }}<br/><br/></small>
<small ng-show=" passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid">Password must be 8-30 character with at least one uppercase, one numeric and one special character</small>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordchange_form.newPassword.$dirty && passwordchange.error.newPassword) || (passwordchange_form.newPasswordRepeat.$dirty && passwordchange_form.newPasswordRepeat.$error.required) || (passwordchange_form.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat) }">
<label class="control-label" for="inputPasswordChangeNewPasswordRepeat">Repeat New Password</label>
<div class="control-label" ng-show="(!passwordchange_form.newPassword.$dirty && passwordchange.error.newPassword) || (passwordchange_form.newPasswordRepeat.$dirty && passwordchange_form.newPasswordRepeat.$error.required) || (passwordchange_form.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat)">
<small ng-show="passwordchange_form.newPasswordRepeat.$dirty && passwordchange_form.newPasswordRepeat.$error.required">A password is required</small>
<small ng-show="passwordchange_form.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat && passwordchange.newPasswordRepeat">Passwords don't match</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.newPasswordRepeat" id="inputPasswordChangeNewPasswordRepeat" name="newPasswordRepeat" required autofocus>
<input type="password" class="form-control" ng-model="passwordchange.newPassword" id="inputPasswordChangeNewPassword" name="newPassword" ng-pattern="/^(?=.*\d)(?=.*[a-z])(?=.*[A-Z])(?=.*[^a-zA-Z0-9])(?!.*\s).{8,30}$/" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat) }">
<label class="control-label" for="inputPasswordChangeNewPasswordRepeat">Repeat New Password</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat)">
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required">A password is required</small>
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat && passwordchange.newPasswordRepeat">Passwords don't match</small>
</div>
<input class="ng-hide" type="submit" ng-disabled="passwordchange_form.$invalid"/>
</fieldset>
<input type="password" class="form-control" ng-model="passwordchange.newPasswordRepeat" id="inputPasswordChangeNewPasswordRepeat" name="newPasswordRepeat" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="passwordChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="doChangePassword(passwordchange_form)" ng-disabled="passwordchange_form.$invalid || passwordchange.busy"><i class="fa fa-spinner fa-pulse" ng-show="passwordchange.busy"></i> Change</button>
<button type="button" class="btn btn-danger" ng-click="passwordchange.submit()" ng-disabled="passwordChangeForm.$invalid || passwordchange.busy"><i class="fa fa-spinner fa-pulse" ng-show="passwordchange.busy"></i> Change</button>
</div>
</div>
</div>
@@ -52,31 +51,49 @@
<h4 class="modal-title">Change Your Email</h4>
</div>
<div class="modal-body">
<form name="emailchange_form" role="form" novalidate ng-submit="doChangeEmail(emailchange_form)" autocomplete="off">
<fieldset>
<div class="form-group" ng-class="{ 'has-error': (emailchange_form.email.$dirty && emailchange_form.email.$invalid) }">
<label class="control-label" for="inputEmailChangeEmail">New Email Address</label>
<div class="control-label" ng-show="(!emailchange_form.email.$dirty && emailchange.error.email) || (emailchange_form.email.$dirty && emailchange_form.email.$invalid)">
<small ng-show="emailchange_form.email.$error.required">A valid email address is required</small>
<small ng-show="(emailchange_form.email.$dirty && emailchange_form.email.$invalid) && !emailchange_form.email.$error.required">The Email address is not valid</small>
</div>
<input type="email" class="form-control" ng-model="emailchange.email" id="inputEmailChangeEmail" name="email" required autofocus>
<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) }">
<label class="control-label" for="inputEmailChangeEmail">New Email Address</label>
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailchange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
<small ng-show="emailChangeForm.email.$error.required">A valid email address is required</small>
<small ng-show="(emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) && !emailChangeForm.email.$error.required">The Email address is not valid</small>
</div>
<div class="form-group" ng-class="{ 'has-error': (emailchange_form.password.$dirty && emailchange_form.password.$invalid) || (!emailchange_form.password.$dirty && emailchange.error.password) }">
<label class="control-label" for="inputEmailChangePassword">Password</label>
<div class="control-label" ng-show="(emailchange_form.password.$dirty && emailchange_form.password.$invalid) || (!emailchange_form.password.$dirty && emailchange.error.password)">
<small ng-show=" emailchange_form.password.$dirty && emailchange_form.password.$invalid">Password required</small>
<small ng-show="!emailchange_form.password.$dirty && emailchange.error.password">Wrong password</small>
</div>
<input type="password" class="form-control" ng-model="emailchange.password" id="inputEmailChangePassword" name="password" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="emailchange_form.$invalid"/>
</fieldset>
<input type="email" class="form-control" ng-model="emailchange.email" id="inputEmailChangeEmail" name="email" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="emailChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="doChangeEmail(emailchange_form)" ng-disabled="emailchange_form.$invalid || emailchange.busy"><i class="fa fa-spinner fa-pulse" ng-show="emailchange.busy"></i> Change</button>
<button type="button" class="btn btn-success" ng-click="emailchange.submit()" ng-disabled="emailChangeForm.$invalid || emailchange.busy"><i class="fa fa-spinner fa-pulse" ng-show="emailchange.busy"></i> Change</button>
</div>
</div>
</div>
</div>
<!-- Modal change displayName -->
<div class="modal fade" id="displayNameChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change Your Display Name</h4>
</div>
<div class="modal-body">
<form name="displayNameChangeForm" role="form" novalidate ng-submit="displayNameChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid) }">
<label class="control-label">Display Name</label>
<div class="control-label" ng-show="(!displayNameChangeForm.displayName.$dirty && displayNameChange.error.displayName) || (displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid)">
<small ng-show="displayNameChangeForm.displayName.$error.required">A valid display name is required</small>
<small ng-show="(displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid) && !displayNameChangeForm.displayName.$error.required">This display name is not valid</small>
</div>
<input type="text" class="form-control" ng-model="displayNameChange.displayName" name="displayName" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="displayNameChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="displayNameChange.submit()" ng-disabled="displayNameChangeForm.$invalid || displayNameChange.busy"><i class="fa fa-spinner fa-pulse" ng-show="displayNameChange.busy"></i> Change</button>
</div>
</div>
</div>
@@ -100,16 +117,20 @@
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">Username</td>
<td class="text-right" style="vertical-align: top;">{{ user.username }} &nbsp;&nbsp;</td>
<td class="text-right" style="vertical-align: top;">{{ user.username }} &nbsp;&nbsp;&nbsp;</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Display Name</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ user.displayName }} <a href="" ng-click="displayNameChange.show()"><i class="fa fa-pencil text-small"></i></a></td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Email</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ user.email }} <a href="" ng-click="showChangeEmail(emailchange_form)"><i class="fa fa-pencil text-small"></i></a></td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ user.email }} <a href="" ng-click="emailchange.show()"><i class="fa fa-pencil text-small"></i></a></td>
</tr>
<tr>
<td class="text-right" colspan="2" style="vertical-align: top;">
<br/>
<button class="btn btn-outline btn-xs btn-danger" ng-click="showChangePassword(passwordchange_form)">Change Password</button>
<button class="btn btn-outline btn-xs btn-danger" ng-click="passwordchange.show()">Change Password</button>
</td>
</tr>
</table>
+118 -83
View File
@@ -12,113 +12,148 @@ angular.module('Application').controller('AccountController', ['$scope', '$locat
error: {},
password: '',
newPassword: '',
newPasswordRepeat: ''
newPasswordRepeat: '',
reset: function () {
$scope.passwordchange.error.password = null;
$scope.passwordchange.error.newPassword = null;
$scope.passwordchange.error.newPasswordRepeat = null;
$scope.passwordchange.password = '';
$scope.passwordchange.newPassword = '';
$scope.passwordchange.newPasswordRepeat = '';
$scope.passwordChangeForm.$setUntouched();
$scope.passwordChangeForm.$setPristine();
},
show: function () {
$scope.passwordchange.reset();
$('#passwordChangeModal').modal('show');
},
submit: function () {
$scope.passwordchange.error.password = null;
$scope.passwordchange.error.newPassword = null;
$scope.passwordchange.error.newPasswordRepeat = null;
$scope.passwordchange.busy = true;
Client.changePassword($scope.passwordchange.password, $scope.passwordchange.newPassword, function (error) {
$scope.passwordchange.busy = false;
if (error) {
if (error.statusCode === 403) {
$scope.passwordchange.error.password = true;
$scope.passwordchange.password = '';
$('#inputPasswordChangePassword').focus();
$scope.passwordChangeForm.password.$setPristine();
} else if (error.statusCode === 400) {
$scope.passwordchange.error.newPassword = error.message;
$scope.passwordchange.newPassword = '';
$scope.passwordchange.newPasswordRepeat = '';
$scope.passwordChangeForm.newPassword.$setPristine();
$scope.passwordChangeForm.newPasswordRepeat.$setPristine();
$('#inputPasswordChangeNewPassword').focus();
} else {
console.error('Unable to change password.', error);
}
return;
}
$scope.passwordchange.reset();
$('#passwordChangeModal').modal('hide');
});
}
};
$scope.emailchange = {
busy: false,
error: {},
email: '',
password: ''
};
function passwordChangeReset (form) {
$scope.passwordchange.error.password = null;
$scope.passwordchange.error.newPassword = null;
$scope.passwordchange.error.newPasswordRepeat = null;
$scope.passwordchange.password = '';
$scope.passwordchange.newPassword = '';
$scope.passwordchange.newPasswordRepeat = '';
reset: function () {
$scope.emailchange.busy = false;
$scope.emailchange.error.email = null;
$scope.emailchange.email = '';
if (form) {
form.$setPristine();
form.$setUntouched();
}
}
$scope.emailChangeForm.$setUntouched();
$scope.emailChangeForm.$setPristine();
},
function emailChangeReset (form) {
$scope.emailchange.error.email = null;
$scope.emailchange.error.password = null;
$scope.emailchange.email = '';
$scope.emailchange.password = '';
show: function () {
$scope.emailchange.reset();
$('#emailChangeModal').modal('show');
},
if (form) {
form.$setPristine();
form.$setUntouched();
}
}
submit: function () {
$scope.emailchange.error.email = null;
$scope.emailchange.busy = true;
$scope.doChangePassword = function (form) {
$scope.passwordchange.error.password = null;
$scope.passwordchange.error.newPassword = null;
$scope.passwordchange.error.newPasswordRepeat = null;
$scope.passwordchange.busy = true;
var user = {
id: $scope.user.id,
email: $scope.emailchange.email
};
Client.changePassword($scope.passwordchange.password, $scope.passwordchange.newPassword, function (error) {
if (error) {
if (error.statusCode === 403) {
$scope.passwordchange.error.password = true;
$scope.passwordchange.password = '';
$('#inputPasswordChangePassword').focus();
$scope.passwordchange_form.password.$setPristine();
} else if (error.statusCode === 400) {
$scope.passwordchange.error.newPassword = error.message;
$scope.passwordchange.newPassword = '';
$scope.passwordchange.newPasswordRepeat = '';
$scope.passwordchange_form.newPassword.$setPristine();
$scope.passwordchange_form.newPasswordRepeat.$setPristine();
$('#inputPasswordChangeNewPassword').focus();
} else {
console.error('Unable to change password.', error);
}
} else {
passwordChangeReset(form);
Client.updateUser(user, function (error) {
$scope.emailchange.busy = false;
$('#passwordChangeModal').modal('hide');
}
$scope.passwordchange.busy = false;
});
};
$scope.doChangeEmail = function (form) {
$scope.emailchange.error.email = null;
$scope.emailchange.error.password = null;
$scope.emailchange.busy = true;
Client.changeEmail($scope.emailchange.email, $scope.emailchange.password, function (error) {
if (error) {
if (error.statusCode === 403) {
$scope.emailchange.error.password = true;
$scope.emailchange.password = '';
$scope.emailchange_form.password.$setPristine();
$('#inputEmailChangePassword').focus();
} else {
if (error) {
console.error('Unable to change email.', error);
return;
}
} else {
emailChangeReset(form);
// update user info in the background
Client.refreshUserInfo();
$scope.emailchange.reset();
$('#emailChangeModal').modal('hide');
}
$scope.emailchange.busy = false;
});
});
}
};
$scope.showChangePassword = function (form) {
passwordChangeReset(form);
$scope.displayNameChange = {
busy: false,
error: {},
displayName: '',
$('#passwordChangeModal').modal('show');
};
reset: function () {
$scope.displayNameChange.busy = false;
$scope.displayNameChange.error.displayName = null;
$scope.displayNameChange.displayName = '';
$scope.showChangeEmail = function (form) {
emailChangeReset(form);
$scope.displayNameChangeForm.$setUntouched();
$scope.displayNameChangeForm.$setPristine();
},
$('#emailChangeModal').modal('show');
show: function () {
$scope.displayNameChange.reset();
$scope.displayNameChange.displayName = $scope.user.displayName;
$('#displayNameChangeModal').modal('show');
},
submit: function () {
$scope.displayNameChange.error.displayName = null;
$scope.displayNameChange.busy = true;
var user = {
id: $scope.user.id,
displayName: $scope.displayNameChange.displayName
};
Client.updateUser(user, function (error) {
$scope.displayNameChange.busy = false;
if (error) {
console.error('Unable to change displayName.', error);
return;
}
// update user info in the background
Client.refreshUserInfo();
$scope.displayNameChange.reset();
$('#displayNameChangeModal').modal('hide');
});
}
};
$scope.removeAccessTokens = function (client) {
@@ -149,7 +184,7 @@ angular.module('Application').controller('AccountController', ['$scope', '$locat
});
// setup all the dialog focus handling
['passwordChangeModal', 'emailChangeModal'].forEach(function (id) {
['passwordChangeModal', 'emailChangeModal', 'displayNameChangeModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
+13 -8
View File
@@ -1,4 +1,4 @@
<div class="row animateMeOpacity ng-hide" ng-show="installedApps.length === 0">
<div class="row animateMeOpacity ng-hide" ng-show="installedApps.length === 0 && user.admin">
<div class="col-lg-6 col-lg-offset-3" style="text-align: center;">
<br/><br/><br/><br/>
<h1><i class="fa fa-cloud-download fa-fw"></i> Your Cloudron does not have any apps installed yet!</h1>
@@ -7,6 +7,15 @@
</div>
</div>
<div class="row animateMeOpacity ng-hide" ng-show="installedApps.length === 0 && !user.admin">
<div class="col-lg-6 col-lg-offset-3" style="text-align: center;">
<br/><br/><br/><br/>
<h1>You don't have access to any apps on this Cloudron yet!</h1>
<br/></br>
<h3>Once you do, they will show up here.</h3>
</div>
</div>
<!-- Modal configure app -->
<div class="modal fade" id="appConfigureModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
@@ -60,11 +69,7 @@
<div class="has-error" ng-show="appConfigure.accessRestrictionOption !== '' && !appConfigure.isAccessRestrictionValid()">Select at least one group</div>
<div>
<div>
<span>
<button class="btn btn-default" type="button" ng-disabled="appConfigure.accessRestrictionOption === ''" ng-click="appConfigureToggleGroup({ id: 'admin', name: 'admin' })" ng-class="{ 'btn-admin': (appConfigure.accessRestriction.groups && appConfigure.accessRestriction.groups.indexOf('admin') !== -1) }">Admin</button>
</span>
<span ng-repeat="group in groups" ng-show="group.id !== 'admin'">
<span ng-repeat="group in groups | ignoreAdminGroup">
<button class="btn btn-default" type="button" ng-disabled="appConfigure.accessRestrictionOption === ''" ng-click="appConfigureToggleGroup(group);" ng-class="{ 'btn-primary': (appConfigure.accessRestriction.groups && appConfigure.accessRestriction.groups.indexOf(group.id) !== -1) }">{{ group.name }}</button>
</span>
</div>
@@ -165,7 +170,7 @@
<h4 class="modal-title">{{ appError.app.location }}</h4>
</div>
<div class="modal-body">
<p>There was an error installing this app</p>
<p><b>There was an error:</b></p>
<p>{{appError.app.installationProgress}}</p>
</div>
<div class="modal-footer">
@@ -275,7 +280,7 @@
<div class="row animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="col-lg-12">
<h1>Installed Applications</h1>
<h1>Your Applications</h1>
</div>
</div>
+34 -12
View File
@@ -13,7 +13,7 @@
</div>
<div class="modal-body">
<div class="collapse" id="collapseInstallForm" data-toggle="false">
<form role="form" name="appInstallForm" ng-submit="doInstall()" autocomplete="off">
<form role="form" name="appInstallForm" ng-submit="appInstall.submit()" autocomplete="off">
<div class="has-error text-center" ng-show="appInstall.error.other" ng-bind-html="appInstall.error.other"></div>
<div class="form-group" ng-class="{ 'has-error': (appInstallForm.location.$dirty && appInstallForm.location.$invalid) || (!appInstallForm.location.$dirty && appInstall.error.location) }">
<label class="control-label" for="appInstallLocationInput">Location {{ appInstall.error.location }} </label>
@@ -36,10 +36,32 @@
</div>
<div class="form-group" ng-show="appInstall.app.manifest.singleUser">
<label class="control-label" for="accessRestriction">User</label>
<p>This is a single user application.</p>
<select class="form-control" id="accessRestriction" ng-model="appInstall.accessRestriction" ng-options="user as user.username for user in users track by user.id" ng-required="appInstall.app.manifest.singleUser">
</select>
<label class="control-label">User</label>
<select class="form-control" ng-model="appInstall.accessRestrictionSingleUser" ng-options="user as user.username for user in users track by user.id" ng-required="appInstall.app.manifest.singleUser"></select>
</div>
<div class="form-group" ng-hide="appInstall.app.manifest.singleUser">
<label class="control-label">Access control</label>
<div class="radio">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="">
Every Cloudron user
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="restricted" ng-disabled="groups.length <= 1">
Restrict to groups
</label>
</div>
<div ng-show="groups.length <= 1">No groups available. Create groups to restrict access to them first.</div>
<div class="has-error" ng-show="appInstall.accessRestrictionOption !== '' && !appInstall.isAccessRestrictionValid()">Select at least one group</div>
<div>
<div>
<span ng-repeat="group in groups | ignoreAdminGroup">
<button class="btn btn-default" type="button" ng-disabled="appInstall.accessRestrictionOption === ''" ng-click="appInstall.toggleGroup(group);" ng-class="{ 'btn-primary': (appInstall.accessRestriction.groups && appInstall.accessRestriction.groups.indexOf(group.id) !== -1) }">{{ group.name }}</button>
</span>
</div>
</div>
</div>
<br/>
@@ -86,9 +108,9 @@
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-danger" ng-show="!appInstall.installFormVisible && user.admin && appInstall.resourceConstraintVisible" ng-click="showInstallForm(true)">Install anyway</button>
<button type="button" class="btn btn-success" ng-show="!appInstall.installFormVisible && user.admin && !appInstall.resourceConstraintVisible" ng-click="showInstallForm()">Install</button>
<button type="button" class="btn btn-success" ng-show="appInstall.installFormVisible && user.admin && !appInstall.resourceConstraintVisible" ng-click="doInstall()" ng-disabled="appInstallForm.$invalid || appInstall.busy"><i class="fa fa-spinner fa-pulse" ng-show="appInstall.busy"></i> Install</button>
<button type="button" class="btn btn-danger" ng-show="!appInstall.installFormVisible && user.admin && appInstall.resourceConstraintVisible" ng-click="appInstall.showForm(true)">Install anyway</button>
<button type="button" class="btn btn-success" ng-show="!appInstall.installFormVisible && user.admin && !appInstall.resourceConstraintVisible" ng-click="appInstall.showForm()">Install</button>
<button type="button" class="btn btn-success" ng-show="appInstall.installFormVisible && user.admin && !appInstall.resourceConstraintVisible" ng-click="appInstall.submit()" ng-disabled="appInstallForm.$invalid || appInstall.busy"><i class="fa fa-spinner fa-pulse" ng-show="appInstall.busy"></i> Install</button>
</div>
</div>
</div>
@@ -103,7 +125,7 @@
</div>
<div class="modal-body">
<fieldset>
<form name="feedbackForm" ng-submit="submitFeedback()">
<form name="feedbackForm" ng-submit="feedback.submit()">
<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"/>
@@ -112,7 +134,7 @@
</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>
<button type="button" class="btn btn-success" ng-click="feedback.submit()" ng-disabled="feedbackForm.$invalid || feedback.busy"><i class="fa fa-fw fa-paper-plane"></i> Submit</button>
</div>
</div>
</div>
@@ -172,7 +194,7 @@
<br/>
<br/>
<br/>
<a href="" ng-click="showFeedbackModal()">Missing an app? Let us know.</a>
<a href="" ng-click="feedback.show()">Missing an app? Let us know.</a>
</div>
<div class="col-md-10" ng-show="ready && apps.length">
<div class="row-no-margin">
@@ -194,7 +216,7 @@
</div>
<div class="col-md-10 animateMeOpacity loading-banner" ng-show="ready && !apps.length">
<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>
<a href="" ng-click="feedback.show()"><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>
+208 -168
View File
@@ -6,6 +6,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.users = [];
$scope.groups = [];
$scope.category = '';
$scope.cachedCategory = ''; // used to cache the selected category while searching
$scope.searchString = '';
@@ -17,12 +18,154 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
app: {},
location: '',
portBindings: {},
accessRestriction: null,
mediaLinks: [],
certificateFile: null,
certificateFileName: '',
keyFile: null,
keyFileName: ''
keyFileName: '',
accessRestrictionOption: '',
accessRestriction: { users: [], groups: [] },
accessRestrictionSingleUser: null,
isAccessRestrictionValid: function () {
var tmp = $scope.appInstall.accessRestriction;
return !!(tmp.users.length || tmp.groups.length);
},
toggleGroup: function (group) {
var groups = $scope.appInstall.accessRestriction.groups;
var pos = groups.indexOf(group.id);
if (pos === -1) groups.push(group.id);
else groups.splice(pos, 1);
},
reset: function () {
$scope.appInstall.app = {};
$scope.appInstall.error = {};
$scope.appInstall.location = '';
$scope.appInstall.portBindings = {};
$scope.appInstall.installFormVisible = false;
$scope.appInstall.resourceConstraintVisible = false;
$scope.appInstall.mediaLinks = [];
$scope.appInstall.certificateFile = null;
$scope.appInstall.certificateFileName = '';
$scope.appInstall.keyFile = null;
$scope.appInstall.keyFileName = '';
$scope.appInstall.accessRestrictionOption = '';
$scope.appInstall.accessRestriction = { users: [], groups: [] };
$scope.appInstall.accessRestrictionSingleUser = null;
$('#collapseInstallForm').collapse('hide');
$('#collapseResourceConstraint').collapse('hide');
$('#collapseMediaLinksCarousel').collapse('show');
$scope.appInstallForm.$setPristine();
$scope.appInstallForm.$setUntouched();
},
showForm: function (force) {
if (Client.enoughResourcesAvailable($scope.appInstall.app) || force) {
$scope.appInstall.installFormVisible = true;
$scope.appInstall.resourceConstraintVisible = false;
$('#collapseMediaLinksCarousel').collapse('hide');
$('#collapseResourceConstraint').collapse('hide');
$('#collapseInstallForm').collapse('show');
$('#appInstallLocationInput').focus();
} else {
$scope.appInstall.installFormVisible = false;
$scope.appInstall.resourceConstraintVisible = true;
$('#collapseMediaLinksCarousel').collapse('hide');
$('#collapseResourceConstraint').collapse('show');
}
},
show: function (app) {
$scope.appInstall.reset();
// make a copy to work with in case the app object gets updated while polling
angular.copy(app, $scope.appInstall.app);
$scope.appInstall.mediaLinks = $scope.appInstall.app.manifest.mediaLinks || [];
$scope.appInstall.location = app.location;
$scope.appInstall.portBindingsInfo = $scope.appInstall.app.manifest.tcpPorts || {}; // Portbinding map only for information
$scope.appInstall.portBindings = {}; // This is the actual model holding the env:port pair
$scope.appInstall.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag
$scope.appInstall.accessRestrictionOption = app.accessRestriction ? 'restricted' : '';
$scope.appInstall.accessRestriction = app.accessRestriction || { users: [], groups: [] };
$scope.appInstall.accessRestrictionSingleUser = null;
// set default ports
for (var env in $scope.appInstall.app.manifest.tcpPorts) {
$scope.appInstall.portBindings[env] = $scope.appInstall.app.manifest.tcpPorts[env].defaultValue || 0;
$scope.appInstall.portBindingsEnabled[env] = true;
}
$('#appInstallModal').modal('show');
},
submit: function () {
$scope.appInstall.busy = true;
$scope.appInstall.error.other = null;
$scope.appInstall.error.location = null;
$scope.appInstall.error.port = null;
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appInstall.portBindings) {
if ($scope.appInstall.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.appInstall.portBindings[env];
}
}
// translate to accessRestriction object
var accessRestriction = $scope.appInstall.app.manifest.singleUser ? {
users: [ $scope.appInstall.accessRestrictionSingleUser.id ]
} : (!$scope.appInstall.accessRestrictionOption ? null : $scope.appInstall.accessRestriction);
var data = {
location: $scope.appInstall.location || '',
portBindings: finalPortBindings,
accessRestriction: accessRestriction,
cert: $scope.appInstall.certificateFile,
key: $scope.appInstall.keyFile,
};
Client.installApp($scope.appInstall.app.id, $scope.appInstall.app.manifest, $scope.appInstall.app.title, data, function (error) {
if (error) {
if (error.statusCode === 409 && (error.message.indexOf('is reserved') !== -1 || error.message.indexOf('is already in use') !== -1)) {
$scope.appInstall.error.port = error.message;
} else if (error.statusCode === 409) {
$scope.appInstall.error.location = 'This name is already taken.';
$scope.appInstallForm.location.$setPristine();
$('#appInstallLocationInput').focus();
} else if (error.statusCode === 402) {
$scope.appInstall.error.other = 'Unable to purchase this app<br/>Please make sure your payment is setup <a href="' + $scope.config.webServerOrigin + '/console.html#/userprofile" target="_blank">here</a>';
} else if (error.statusCode === 400 && error.message.indexOf('cert') !== -1 ) {
$scope.appInstall.error.cert = error.message;
$scope.appInstall.certificateFileName = '';
$scope.appInstall.certificateFile = null;
$scope.appInstall.keyFileName = '';
$scope.appInstall.keyFile = null;
} else {
$scope.appInstall.error.other = error.message;
}
$scope.appInstall.busy = false;
return;
}
$scope.appInstall.busy = false;
// wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already
$('#appInstallModal').on('hidden.bs.modal', function () {
$scope.appInstall.reset();
$location.path('/apps');
});
$('#appInstallModal').modal('hide');
});
}
};
$scope.appNotFound = {
@@ -32,39 +175,40 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$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();
}
type: 'app_missing',
reset: function () {
$scope.feedback.busy = false;
});
};
$scope.feedback.error = null;
$scope.feedback.description = '';
$scope.showFeedbackModal = function () {
$('#feedbackModal').modal('show');
$scope.feedbackForm.$setUntouched();
$scope.feedbackForm.$setPristine();
},
show: function () {
$scope.feedback.reset();
$('#feedbackModal').modal('show');
},
submit: function () {
$scope.feedback.busy = true;
$scope.feedback.error = null;
Client.feedback($scope.feedback.type, $scope.feedback.subject, $scope.feedback.description, function (error) {
$scope.feedback.busy = false;
if (error) {
$scope.feedback.error = error;
console.error(error);
return;
}
$('#feedbackModal').modal('hide');
});
}
};
function getAppList(callback) {
@@ -135,44 +279,6 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
});
};
$scope.reset = function () {
$scope.appInstall.app = {};
$scope.appInstall.error = {};
$scope.appInstall.location = '';
$scope.appInstall.portBindings = {};
$scope.appInstall.accessRestriction = null;
$scope.appInstall.installFormVisible = false;
$scope.appInstall.resourceConstraintVisible = false;
$scope.appInstall.mediaLinks = [];
$scope.appInstall.certificateFile = null;
$scope.appInstall.certificateFileName = '';
$scope.appInstall.keyFile = null;
$scope.appInstall.keyFileName = '';
$('#collapseInstallForm').collapse('hide');
$('#collapseResourceConstraint').collapse('hide');
$('#collapseMediaLinksCarousel').collapse('show');
$scope.appInstallForm.$setPristine();
$scope.appInstallForm.$setUntouched();
};
$scope.showInstallForm = function (force) {
if (Client.enoughResourcesAvailable($scope.appInstall.app) || force) {
$scope.appInstall.installFormVisible = true;
$scope.appInstall.resourceConstraintVisible = false;
$('#collapseMediaLinksCarousel').collapse('hide');
$('#collapseResourceConstraint').collapse('hide');
$('#collapseInstallForm').collapse('show');
$('#appInstallLocationInput').focus();
} else {
$scope.appInstall.installFormVisible = false;
$scope.appInstall.resourceConstraintVisible = true;
$('#collapseMediaLinksCarousel').collapse('hide');
$('#collapseResourceConstraint').collapse('show');
}
};
document.getElementById('appInstallCertificateFileInput').onchange = function (event) {
$scope.$apply(function () {
$scope.appInstall.certificateFile = null;
@@ -201,27 +307,6 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
});
};
$scope.showInstall = function (app) {
$scope.reset();
// make a copy to work with in case the app object gets updated while polling
angular.copy(app, $scope.appInstall.app);
$('#appInstallModal').modal('show');
$scope.appInstall.mediaLinks = $scope.appInstall.app.manifest.mediaLinks || [];
$scope.appInstall.location = app.location;
$scope.appInstall.portBindingsInfo = $scope.appInstall.app.manifest.tcpPorts || {}; // Portbinding map only for information
$scope.appInstall.portBindings = {}; // This is the actual model holding the env:port pair
$scope.appInstall.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag
$scope.appInstall.accessRestriction = app.accessRestriction ? app.accessRestriction.users[0] : $scope.user;
// set default ports
for (var env in $scope.appInstall.app.manifest.tcpPorts) {
$scope.appInstall.portBindings[env] = $scope.appInstall.app.manifest.tcpPorts[env].defaultValue || 0;
$scope.appInstall.portBindingsEnabled[env] = true;
}
};
$scope.showAppNotFound = function (appId, version) {
$scope.appNotFound.appId = appId;
$scope.appNotFound.version = version;
@@ -229,69 +314,6 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
$('#appNotFoundModal').modal('show');
};
$scope.doInstall = function () {
$scope.appInstall.busy = true;
$scope.appInstall.error.other = null;
$scope.appInstall.error.location = null;
$scope.appInstall.error.port = null;
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appInstall.portBindings) {
if ($scope.appInstall.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.appInstall.portBindings[env];
}
}
// translate to accessRestriction object
var accessRestriction = $scope.appInstall.app.manifest.singleUser ? {
users: [ $scope.appInstall.accessRestriction.id ]
} : null;
var data = {
location: $scope.appInstall.location || '',
portBindings: finalPortBindings,
accessRestriction: accessRestriction,
cert: $scope.appInstall.certificateFile,
key: $scope.appInstall.keyFile,
};
Client.installApp($scope.appInstall.app.id, $scope.appInstall.app.manifest, $scope.appInstall.app.title, data, function (error) {
if (error) {
if (error.statusCode === 409 && (error.message.indexOf('is reserved') !== -1 || error.message.indexOf('is already in use') !== -1)) {
$scope.appInstall.error.port = error.message;
} else if (error.statusCode === 409) {
$scope.appInstall.error.location = 'This name is already taken.';
$scope.appInstallForm.location.$setPristine();
$('#appInstallLocationInput').focus();
} else if (error.statusCode === 402) {
$scope.appInstall.error.other = 'Unable to purchase this app<br/>Please make sure your payment is setup <a href="' + $scope.config.webServerOrigin + '/console.html#/userprofile" target="_blank">here</a>';
} else if (error.statusCode === 400 && error.message.indexOf('cert') !== -1 ) {
$scope.appInstall.error.cert = error.message;
$scope.appInstall.certificateFileName = '';
$scope.appInstall.certificateFile = null;
$scope.appInstall.keyFileName = '';
$scope.appInstall.keyFile = null;
} else {
$scope.appInstall.error.other = error.message;
}
$scope.appInstall.busy = false;
return;
}
$scope.appInstall.busy = false;
// wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already
$('#appInstallModal').on('hidden.bs.modal', function () {
$scope.reset();
$location.path('/apps');
});
$('#appInstallModal').modal('hide');
});
};
$scope.gotoApp = function (app) {
$location.path('/appstore/' + app.manifest.id, false).search({ version : app.manifest.version });
};
@@ -309,7 +331,7 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
return;
}
$scope.showInstall(result);
$scope.appInstall.show(result);
});
} else {
var found = $scope.apps.filter(function (app) {
@@ -317,40 +339,58 @@ angular.module('Application').controller('AppStoreController', ['$scope', '$loca
});
if (found.length) {
$scope.showInstall(found[0]);
$scope.appInstall.show(found[0]);
} else {
$scope.showAppNotFound(appId, null);
}
}
} else {
$scope.reset();
$scope.appInstall.reset();
}
}
function fetchUsers() {
Client.getUsers(function (error, users) {
if (error) {
console.error(error);
return $timeout(fetchUsers, 5000);
}
$scope.users = users;
});
}
function fetchGroups() {
Client.getGroups(function (error, groups) {
if (error) {
console.error(error);
return $timeout(fetchUsers, 5000);
}
$scope.groups = groups;
});
}
(function refresh() {
$scope.ready = false;
Client.getUsers(function (error, users) {
getAppList(function (error, apps) {
if (error) {
console.error(error);
return $timeout(refresh, 1000);
}
$scope.users = users;
$scope.apps = apps;
getAppList(function (error, apps) {
if (error) {
console.error(error);
return $timeout(refresh, 1000);
}
// show install app dialog immediately if an app id was passed in the query
hashChangeListener();
$scope.apps = apps;
if ($scope.user.admin) {
fetchUsers();
fetchGroups();
}
// show install app dialog immediately if an app id was passed in the query
hashChangeListener();
$scope.ready = true;
});
$scope.ready = true;
});
})();
+2 -1
View File
@@ -35,7 +35,8 @@
<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>
<option value="app_missing">Missing App</option>
<option value="app_error">App Error/Failing</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
+22 -24
View File
@@ -120,23 +120,18 @@
<div class="form-group">
<label class="control-label">Groups</label>
<div>
<span>
<button class="btn btn-admin" type="button" ng-show="useredit.userInfo.id === userInfo.id" ng-click="showBubble($event)" data-toggle="tooltip" data-trigger="manual" title="Removing yourself from admin group is not allowed">Admin</button>
<button class="btn btn-default" type="button" ng-hide="useredit.userInfo.id === userInfo.id" ng-click="userEditToggleGroup({ id: 'admin', name: 'admin' })" ng-class="{ 'btn-admin': (useredit.groupIds.indexOf('admin') !== -1) }">Admin</button>
</span>
<span ng-repeat="group in groups" ng-show="group.id !== 'admin'">
<span ng-repeat="group in groups | ignoreAdminGroup" ng-show="group.id !== 'admin'">
<button class="btn btn-default" type="button" ng-click="userEditToggleGroup(group);" ng-class="{ 'btn-primary': (useredit.groupIds.indexOf(group.id) !== -1) }">{{ group.name }}</button>
</span>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (useredit_form.password.$dirty && useredit_form.password.$invalid) || (!useredit_form.password.$dirty && useredit.error.password)}">
<label class="control-label">Give your password to verify that you are performing that action</label>
<div class="control-label" ng-show="(!useredit_form.password.$dirty && useredit.error.password) || (useredit_form.password.$dirty && useredit_form.password.$invalid)">
<small ng-show="useredit_form.password.$error.required && !useredit.error.password">A password is required</small>
<small ng-show="!useredit_form.password.$dirty && useredit.error.password">{{ useredit.error.password }}</small>
<br/>
<div class="form-group" ng-hide="isMe(useredit.userInfo)">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="useredit.superuser"> Allow this user to manage apps, groups and other users
</label>
</div>
<input type="password" class="form-control" ng-model="useredit.password" name="password" id="inputUserEditPassword" placeholder="Password" required>
</div>
<input class="hide" type="submit" ng-disabled="useredit_form.$invalid || useredit.busy"/>
</form>
@@ -188,7 +183,7 @@
</div>
<div class="modal-body">
<div ng-show="groupRemove.memberCount" class="text-danger">
<b>This group still has {{ groupRemove.memberCount }} members. Are you sure this group is not used?</b>
<b>This group still has {{ groupRemove.memberCount }} member(s). Are you sure this group is not used?</b>
<br/>
<br/>
</div>
@@ -214,7 +209,7 @@
</div>
</div>
<div class="content">
<div class="content-large">
<br/>
@@ -236,22 +231,25 @@
<table class="table table-hover">
<thead>
<tr>
<th style="width: 1px;"></th>
<th style="">User</th>
<th style="width: 1px" class="text-right">Groups</th>
<th style="width: 150px" class="text-right">Actions</th>
<th style="" class="text-left hidden-xs hidden-sm">Groups</th>
<th style="width: 100px" class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="user in users">
<td class="text-overflow: ellipsis; white-space: nowrap;">
<td>
<i class="fa fa-briefcase" ng-show="user.admin" data-toggle="tooltip" title="This user can manage apps, groups and other users" ng-init="initTooltip()"></i>
</td>
<td class="hand elide-table-cell" ng-click="showUserEdit(user)">
{{ user.username }}
<span class="text-muted">{{ user.email }}</span>
<span ng-show="isMe(user)" class="label label-success">This is you!</span>
</td>
<td class="text-right">
<span ng-repeat="groupId in user.groupIds" class="label label-default" ng-class="{ 'label-danger': groupId === 'admin' }">{{ groupId === 'admin' ? 'Admin' : groupId }}</span>
<td class="text-left hand elide-table-cell hidden-xs hidden-sm" ng-click="showUserEdit(user)">
<span class="group-badge" ng-repeat="groupId in user.groupIds | ignoreAdminGroup">{{ groupId }}</span>
</td>
<td class="text-right" style="vertical-align: bottom">
<td class="text-right no-wrap" style="vertical-align: bottom">
<button ng-show="!isMe(user)" class="btn btn-xs btn-default" ng-click="sendInvite(user)" title="Send Invite"><i class="fa fa-paper-plane-o"></i></button>
<button class="btn btn-xs btn-default" ng-click="showUserEdit(user)" title="Edit User Profile"><i class="fa fa-pencil"></i></button>
<button ng-show="!isMe(user)" class="btn btn-xs btn-danger" ng-click="showUserRemove(user)" title="Remove User"><i class="fa fa-trash-o"></i></button>
@@ -289,12 +287,12 @@
</tr>
</thead>
<tbody>
<tr ng-repeat="group in groups">
<tr ng-repeat="group in groups | ignoreAdminGroup">
<td class="text-overflow: ellipsis; white-space: nowrap;">
{{ group.name !== 'admin' ? group.name : 'Admin (manage apps and users on this Cloudron)' }}
{{ group.name }}
</td>
<td class="text-right" style="vertical-align: bottom">
<button class="btn btn-xs btn-danger" ng-hide="group.name === 'admin'" ng-click="groupRemove.show(group)" title="Remove Group"><i class="fa fa-trash-o"></i></button>
<button class="btn btn-xs btn-danger" ng-click="groupRemove.show(group)" title="Remove Group"><i class="fa fa-trash-o"></i></button>
</td>
</tr>
</tbody>
+14 -13
View File
@@ -32,7 +32,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
userInfo: {},
email: '',
displayName: '',
password: ''
superuser: false
};
$scope.showBubble = function ($event) {
@@ -231,13 +231,13 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
};
$scope.showUserEdit = function (userInfo) {
$scope.useredit.error.password = null;
$scope.useredit.error.displayName = null;
$scope.useredit.error.email = null;
$scope.useredit.displayName = userInfo.displayName;
$scope.useredit.email = userInfo.email;
$scope.useredit.userInfo = userInfo;
$scope.useredit.groupIds = angular.copy(userInfo.groupIds);
$scope.useredit.superuser = userInfo.groupIds.indexOf('admin') !== -1;
$scope.useredit_form.$setPristine();
$scope.useredit_form.$setUntouched();
@@ -257,7 +257,6 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.doUserEdit = function () {
$scope.useredit.error.displayName = null;
$scope.useredit.error.email = null;
$scope.useredit.error.password = null;
$scope.useredit.busy = true;
var data = {
@@ -266,20 +265,18 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
displayName: $scope.useredit.displayName
};
Client.updateUser(data, $scope.useredit.password, function (error) {
if (error && error.statusCode === 403) {
$scope.useredit.busy = false;
$scope.useredit.error.password = 'Wrong password';
$scope.useredit.password = '';
$scope.useredit_form.password.$setPristine();
$('#inputUserEditPassword').focus();
return;
}
Client.updateUser(data, function (error) {
if (error) {
$scope.useredit.busy = false;
return console.error('Unable to update user:', error);
}
if ($scope.useredit.superuser) {
if ($scope.useredit.groupIds.indexOf('admin') === -1) $scope.useredit.groupIds.push('admin');
} else {
$scope.useredit.groupIds = $scope.useredit.groupIds.filter(function (groupId) { return groupId !== 'admin'; });
}
Client.setGroups(data.id, $scope.useredit.groupIds, function (error) {
$scope.useredit.busy = false;
@@ -288,7 +285,7 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
$scope.useredit.userInfo = {};
$scope.useredit.email = '';
$scope.useredit.displayName = '';
$scope.useredit.password = '';
$scope.useredit.superuser = false;
$scope.useredit.groupIds = [];
$scope.useredit_form.$setPristine();
@@ -369,6 +366,10 @@ angular.module('Application').controller('UsersController', ['$scope', '$locatio
refresh();
$scope.initTooltip = function () {
$('[data-toggle="tooltip"]').tooltip();
};
// setup all the dialog focus handling
['userAddModal', 'userRemoveModal', 'userEditModal', 'groupAddModal', 'groupRemoveModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {