move files to dashboard/

This commit is contained in:
Girish Ramakrishnan
2018-03-15 14:23:51 -07:00
parent 1d0f87f408
commit d59cb63188
134 changed files with 0 additions and 0 deletions
+195
View File
@@ -0,0 +1,195 @@
<!-- Modal change password -->
<div class="modal fade" id="passwordChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change your password</h4>
</div>
<div class="modal-body">
<form name="passwordChangeForm" role="form" novalidate ng-submit="passwordchange.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid) }">
<label class="control-label" for="inputPasswordChangePassword">Current password</label>
<div class="control-label" ng-show="(!passwordChangeForm.password.$dirty && passwordchange.error.password) || (passwordChangeForm.password.$dirty && passwordChangeForm.password.$invalid)">
<small ng-show="!passwordChangeForm.password.$dirty && passwordchange.error.password">Wrong password</small>
<small ng-show="passwordChangeForm.password.$dirty && passwordChangeForm.password.$error.required">A password is required</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.password" id="inputPasswordChangePassword" name="password" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid) }">
<label class="control-label" for="inputPasswordChangeNewPassword">New password</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid)">
<small ng-show="!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword">{{ passwordchange.error.newPassword }}<br/><br/></small>
<small ng-show=" passwordChangeForm.newPassword.$dirty && passwordChangeForm.newPassword.$invalid">Password must be 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>
</div>
<div class="form-group" ng-class="{ 'has-error': (!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat) }">
<label class="control-label" for="inputPasswordChangeNewPasswordRepeat">Repeat new password</label>
<div class="control-label" ng-show="(!passwordChangeForm.newPassword.$dirty && passwordchange.error.newPassword) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required) || (passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat)">
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordChangeForm.newPasswordRepeat.$error.required">A password is required</small>
<small ng-show="passwordChangeForm.newPasswordRepeat.$dirty && passwordchange.newPassword !== passwordchange.newPasswordRepeat && passwordchange.newPasswordRepeat">Passwords don't match</small>
</div>
<input type="password" class="form-control" ng-model="passwordchange.newPasswordRepeat" id="inputPasswordChangeNewPasswordRepeat" name="newPasswordRepeat" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="passwordChangeForm.$invalid"/>
</form>
</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="passwordchange.submit()" ng-disabled="passwordChangeForm.$invalid || passwordchange.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="passwordchange.busy"></i> Change</button>
</div>
</div>
</div>
</div>
<!-- Modal change email -->
<div class="modal fade" id="emailChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change primary email address</h4>
</div>
<div class="modal-body">
<form name="emailChangeForm" role="form" novalidate ng-submit="emailchange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) || (!emailChangeForm.email.$dirty && emailchange.error.email)}">
<div class="control-label" ng-show="(!emailChangeForm.email.$dirty && emailchange.error.email) || (emailChangeForm.email.$dirty && emailChangeForm.email.$invalid)">
<small ng-show="emailChangeForm.email.$error.required">A valid email address is required</small>
<small ng-show="(emailChangeForm.email.$dirty && emailChangeForm.email.$invalid) && !emailChangeForm.email.$error.required">The Email address is not valid</small>
<small ng-show="!emailChangeForm.email.$dirty && emailchange.error.email">{{ emailchange.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="emailchange.email" id="inputEmailChangeEmail" name="email" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="emailChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="emailchange.submit()" ng-disabled="emailChangeForm.$invalid || emailchange.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="emailchange.busy"></i> Change</button>
</div>
</div>
</div>
</div>
<!-- Modal change fallback email -->
<div class="modal fade" id="fallbackEmailChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change password recovery email address</h4>
</div>
<div class="modal-body">
<form name="fallbackEmailChangeForm" role="form" novalidate ng-submit="fallbackEmailChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid) || (!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email)}">
<div class="control-label" ng-show="(!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email) || (fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid)">
<small ng-show="fallbackEmailChangeForm.email.$error.required">A valid email address is required</small>
<small ng-show="(fallbackEmailChangeForm.email.$dirty && fallbackEmailChangeForm.email.$invalid) && !fallbackEmailChangeForm.email.$error.required">The Email address is not valid</small>
<small ng-show="!fallbackEmailChangeForm.email.$dirty && fallbackEmailChange.error.email">{{ fallbackEmailChange.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="fallbackEmailChange.email" id="inputfallbackEmailChangeEmail" name="email" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="fallbackEmailChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="fallbackEmailChange.submit()" ng-disabled="fallbackEmailChangeForm.$invalid || fallbackEmailChange.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="fallbackEmailChange.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) || (!displayNameChangeForm.displayName.$dirty && displayNameChange.error.displayName)}">
<label class="control-label">Display name</label>
<div class="control-label" ng-show="(!displayNameChangeForm.displayName.$dirty && displayNameChange.error.displayName) || (displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid)">
<small ng-show="displayNameChangeForm.displayName.$error.required">A valid display name is required</small>
<small ng-show="(displayNameChangeForm.displayName.$dirty && displayNameChangeForm.displayName.$invalid) && !displayNameChangeForm.displayName.$error.required">This display name is not valid</small>
<small ng-show="!displayNameChangeForm.email.$dirty && displayNameChange.error.displayName">{{ displayNameChange.error.displayName }}</small>
</div>
<input type="text" class="form-control" ng-model="displayNameChange.displayName" id="inputDisplayNameChangeDisplayName" name="displayName" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="displayNameChangeForm.$invalid"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="displayNameChange.submit()" ng-disabled="displayNameChangeForm.$invalid || displayNameChange.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="displayNameChange.busy"></i> Change</button>
</div>
</div>
</div>
</div>
<div class="content">
<div class="text-left">
<h1>Account</h1>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-4" style="min-width: 150px;">
<img width="128" height="128" ng-src="{{ user.gravatarHuge }}"/>
</div>
<div class="col-xs-8">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">Username</td>
<td class="text-right" style="vertical-align: top;">{{ user.username }} &nbsp;&nbsp;&nbsp;</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Display name</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ user.displayName }} <a href="" ng-click="displayNameChange.show()"><i class="fa fa-pencil text-small"></i></a></td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Primary email</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ user.email }} <a href="" ng-click="emailchange.show()"><i class="fa fa-pencil text-small"></i></a></td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Password recovery email</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ user.fallbackEmail }} <a href="" ng-click="fallbackEmailChange.show()"><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="passwordchange.show()">Change Password</button>
</td>
</tr>
</table>
</div>
</div>
</div>
</div>
<br/>
<div class="text-left">
<h3>Sessions</h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<p>You are logged into {{ activeClients.length + 1 }} app(s), including this session.</p>
<span ng-show="activeTokenCount > 1">
<hr/>
<h4>Active Apps:</h4>
<p ng-repeat="client in activeClients"><b>{{ client.name }} - {{client.activeTokens.length}} time(s)</b></p>
<hr/>
</span>
<button class="btn btn-outline btn-xs btn-danger pull-right" ng-click="revokeTokens()">Logout From All</button>
</div>
</div>
</div>
</div>
</div>
+280
View File
@@ -0,0 +1,280 @@
'use strict';
angular.module('Application').controller('AccountController', ['$scope', 'Client', function ($scope, Client) {
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.activeTokens = 0;
$scope.activeClients = [];
$scope.webadminClient = {};
$scope.passwordchange = {
busy: false,
error: {},
password: '',
newPassword: '',
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: '',
reset: function () {
$scope.emailchange.busy = false;
$scope.emailchange.error.email = null;
$scope.emailchange.email = '';
$scope.emailChangeForm.$setUntouched();
$scope.emailChangeForm.$setPristine();
},
show: function () {
$scope.emailchange.reset();
$('#emailChangeModal').modal('show');
},
submit: function () {
$scope.emailchange.error.email = null;
$scope.emailchange.busy = true;
var data = {
email: $scope.emailchange.email
};
Client.updateProfile(data, function (error) {
$scope.emailchange.busy = false;
if (error) {
if (error.statusCode === 409) {
$scope.emailchange.error.email = 'Email already taken';
$scope.emailChangeForm.email.$setPristine();
$('#inputEmailChangeEmail').focus();
} else {
console.error('Unable to change email.', error);
}
return;
}
// update user info in the background
Client.refreshUserInfo();
$scope.emailchange.reset();
$('#emailChangeModal').modal('hide');
});
}
};
$scope.fallbackEmailChange = {
busy: false,
error: {},
email: '',
reset: function () {
$scope.fallbackEmailChange.busy = false;
$scope.fallbackEmailChange.error.email = null;
$scope.fallbackEmailChange.email = '';
$scope.fallbackEmailChangeForm.$setUntouched();
$scope.fallbackEmailChangeForm.$setPristine();
},
show: function () {
$scope.fallbackEmailChange.reset();
$('#fallbackEmailChangeModal').modal('show');
},
submit: function () {
$scope.fallbackEmailChange.error.email = null;
$scope.fallbackEmailChange.busy = true;
var data = {
fallbackEmail: $scope.fallbackEmailChange.email
};
Client.updateProfile(data, function (error) {
$scope.fallbackEmailChange.busy = false;
if (error) return console.error('Unable to change fallback email.', error);
// update user info in the background
Client.refreshUserInfo();
$scope.fallbackEmailChange.reset();
$('#fallbackEmailChangeModal').modal('hide');
});
}
};
$scope.displayNameChange = {
busy: false,
error: {},
displayName: '',
reset: function () {
$scope.displayNameChange.busy = false;
$scope.displayNameChange.error.displayName = null;
$scope.displayNameChange.displayName = '';
$scope.displayNameChangeForm.$setUntouched();
$scope.displayNameChangeForm.$setPristine();
},
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 = {
displayName: $scope.displayNameChange.displayName
};
Client.updateProfile(user, function (error) {
$scope.displayNameChange.busy = false;
if (error) {
if (error.statusCode === 400) {
$scope.displayNameChange.error.displayName = 'Invalid display name';
$scope.displayNameChangeForm.email.$setPristine();
$('#inputDisplayNameChangeDisplayName').focus();
} else {
console.error('Unable to change email.', error);
}
return;
}
// update user info in the background
Client.refreshUserInfo();
$scope.displayNameChange.reset();
$('#displayNameChangeModal').modal('hide');
});
}
};
// poor man's async
function asyncForEach(items, handler, callback) {
var cur = 0;
if (items.length === 0) return callback();
(function iterator() {
handler(items[cur], function () {
if (cur >= items.length-1) return callback();
++cur;
iterator();
});
})();
}
function revokeTokensByClient(client, callback) {
Client.delTokensByClientId(client.id, function (error) {
if (error) console.error(error);
callback();
});
}
$scope.revokeTokens = function () {
asyncForEach($scope.activeClients, revokeTokensByClient, function () {
// now kill this session if exists
if (!$scope.webadminClient || !$scope.webadminClient.id) return;
revokeTokensByClient($scope.webadminClient, function () {
// we should be logged out by now
});
});
};
function refreshClientTokens(client, callback) {
Client.getTokensByClientId(client.id, function (error, result) {
if (error) console.error(error);
client.activeTokens = result || [];
callback();
});
}
Client.onReady(function () {
Client.getOAuthClients(function (error, activeClients) {
if (error) return console.error(error);
asyncForEach(activeClients, refreshClientTokens, function () {
activeClients = activeClients.filter(function (c) { return c.activeTokens.length > 0; });
$scope.activeClients = activeClients.filter(function (c) { return c.id !== 'cid-sdk' && c.id !== 'cid-webadmin'; });
$scope.webadminClient = activeClients.filter(function (c) { return c.id === 'cid-webadmin'; })[0];
$scope.activeTokenCount = $scope.activeClients.reduce(function (prev, cur) { return prev + cur.activeTokens.length; }, 0);
$scope.activeTokenCount += $scope.webadminClient ? $scope.webadminClient.activeTokens.length : 0;
});
});
});
// setup all the dialog focus handling
['passwordChangeModal', 'emailChangeModal', 'fallbackEmailChangeModal', 'displayNameChangeModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
$('.modal-backdrop').remove();
}]);
+50
View File
@@ -0,0 +1,50 @@
<div>
<div class="col-md-10 col-md-offset-1">
<h1>Activity Log</h1>
</div>
</div>
<div>
<div class="col-md-10 col-md-offset-1">
<div class="filter">
<input type="text" class="form-control" style="min-width: 350px;" ng-model="search" ng-model-options="{ debounce: 1000 }" ng-change="updateFilter()" placeholder="Search"/>
<multiselect ng-model="selectedActions" ms-header="All Events" options="a.name for a in actions" data-multiple="true" ng-change="updateFilter(true)"></multiselect>
<select class="form-control" ng-model="pageItems" ng-options="a.name for a in pageItemCount" ng-change="updateFilter(true)"></select>
<!-- <select class="form-control" ng-model="action" ng-options="a.name for a in actions" ng-change="updateFilter()">
<option value="">-- All actions --</option>
</select> -->
</div>
<div class="pagination pull-right">
<button class="btn btn-default btn-outline" ng-click="showPrevPage()" ng-disabled="busy || currentPage <= 1"><i class="fa fa-angle-double-left"></i> prev</button>
<button class="btn btn-default btn-outline" ng-click="showNextPage()" ng-disabled="busy || pageItems > eventLogs.length">next <i class="fa fa-angle-double-right"></i></button>
</div>
</div>
</div>
<div>
<div class="col-md-10 col-md-offset-1">
<div class="card card-block" style="max-width: 100%">
<center ng-show="busy"><h2><i class="fa fa-circle-o-notch fa-spin"></i></h2></center>
<table ng-hide="busy" class="table table-condensed table-hover">
<thead>
<tr>
<th class="col-md-2">Time</th>
<th class="col-md-3">Source</th>
<th class="col-md-7">Details</th>
</tr>
</thead>
<tbody ng-repeat="eventLog in eventLogs">
<tr ng-click="showEventLogDetails(eventLog)" class="hand">
<td><span uib-tooltip="{{ eventLog.creationTime | prettyLongDate }}" class="arrow">{{ eventLog.creationTime | prettyDate }}</span></td>
<td>{{ eventLog | eventLogSource }}</td>
<td ng-bind-html="eventLog | eventLogDetails"></td>
</tr>
<tr ng-show="activeEventLog === eventLog">
<td colspan="4"><pre class="eventlog-details">{{ eventLog.data | json }}</pre></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
+86
View File
@@ -0,0 +1,86 @@
'use strict';
angular.module('Application').controller('ActivityController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
$scope.config = Client.getConfig();
$scope.busy = false;
$scope.eventLogs = [];
$scope.activeEventLog = null;
// TODO sync this with the eventlog filter
$scope.actions = [
{ name: '-- All app events --', value: 'app.' },
{ name: '-- All user events --', value: 'user.' },
{ name: 'app.configure', value: 'app.configure' },
{ name: 'app.install', value: 'app.install' },
{ name: 'app.restore', value: 'app.restore' },
{ name: 'app.uninstall', value: 'app.uninstall' },
{ name: 'app.update', value: 'app.update' },
{ name: 'app.login', value: 'app.login' },
{ name: 'backup.cleanup', value: 'backup.cleanup' },
{ name: 'backup.finish', value: 'backup.finish' },
{ name: 'backup.start', value: 'backup.start' },
{ name: 'certificate.renew', value: 'certificate.renew' },
{ name: 'settings.climode', value: 'settings.climode' },
{ name: 'cloudron.activate', value: 'cloudron.activate' },
{ name: 'cloudron.start', value: 'cloudron.start' },
{ name: 'cloudron.update', value: 'cloudron.update' },
{ name: 'user.add', value: 'user.add' },
{ name: 'user.login', value: 'user.login' },
{ name: 'user.remove', value: 'user.remove' },
{ name: 'user.update', value: 'user.update' }
];
$scope.pageItemCount = [
{ name: 'Show 20 per page', value: 20 },
{ name: 'Show 50 per page', value: 50 },
{ name: 'Show 100 per page', value: 100 }
];
$scope.currentPage = 1;
$scope.pageItems = $scope.pageItemCount[0];
$scope.action = '';
$scope.selectedActions = [];
$scope.search = '';
function fetchEventLogs() {
$scope.busy = true;
var actions = $scope.selectedActions.map(function (a) { return a.value; }).join(', ');
Client.getEventLogs(actions, $scope.search || null, $scope.currentPage, $scope.pageItems.value, function (error, eventLogs) {
$scope.busy = false;
if (error) return console.error(error);
$scope.eventLogs = eventLogs;
});
}
$scope.showNextPage = function () {
$scope.currentPage++;
fetchEventLogs();
};
$scope.showPrevPage = function () {
if ($scope.currentPage > 1) $scope.currentPage--;
else $scope.currentPage = 1;
fetchEventLogs();
};
$scope.updateFilter = function (fresh) {
if (fresh) $scope.currentPage = 1;
fetchEventLogs();
};
$scope.showEventLogDetails = function (eventLog) {
if ($scope.activeEventLog === eventLog) $scope.activeEventLog = null;
else $scope.activeEventLog = eventLog;
};
Client.onReady(function () {
fetchEventLogs();
});
$('.modal-backdrop').remove();
}]);
+447
View File
@@ -0,0 +1,447 @@
<!-- Modal configure/repair app -->
<div class="modal fade" id="appConfigureModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" ng-show="(appConfigure.app | installError)">Repair {{ appConfigure.app.fqdn }}</h4>
<h4 class="modal-title" ng-hide="(appConfigure.app | installError)">Configure {{ appConfigure.app.fqdn }}</h4>
</div>
<div class="modal-body">
<fieldset>
<form role="form" name="appConfigureForm" ng-submit="appConfigure.submit()" autocomplete="off">
<div class="has-error text-center" ng-show="appConfigure.error.other">{{ appConfigure.error.other }}</div>
<div class="form-group" ng-class="{ 'has-error': (appConfigureForm.location.$dirty && appConfigureForm.location.$invalid) || (!appConfigureForm.location.$dirty && appConfigure.error.location) }">
<label class="control-label" for="appConfigureLocationInput">Location {{ appConfigure.error.location }} </label>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="appConfigure.location" id="appConfigureLocationInput" name="location" placeholder="{{ 'Leave empty to use bare domain' }}" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
{{ (!appConfigure.location ? '' : (appConfigure.domain.provider !== 'caas' ? '.' : '-')) + appConfigure.domain.domain }}
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="domain in domains">
<a href="" ng-click="appConfigure.domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
<p class="text-center" ng-show="appConfigure.location && dnsConfig.provider === 'manual' && !dnsConfig.wildcard">
<b>Add an A record manually for {{ appConfigure.location }} to this Cloudron's public IP</b>
<br>
</p>
<div class="has-error text-center" ng-show="appConfigure.error.port">{{ appConfigure.error.port }}</div>
<div ng-repeat="(env, info) in appConfigure.portBindingsInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!appConfigureForm.itemName{{$index}}.$dirty && appConfigure.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="appConfigurePortInput{{env}}"><input type="checkbox" ng-model="appConfigure.portBindingsEnabled[env]"> {{ info.description }} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})</label>
<input type="number" class="form-control" ng-model="appConfigure.portBindings[env]" ng-disabled="!appConfigure.portBindingsEnabled[env]" id="appConfigurePortInput{{env}}" later-name="itemName{{$index}}" min="{{HOST_PORT_MIN}}" max="{{HOST_PORT_MAX}}" required>
</div>
</ng-form>
</div>
<div class="form-group" ng-show="appConfigure.customAuth && !appConfigure.app.manifest.addons.email">
<label class="control-label">User management</label>
<p>
This app has it's own user management.
</p>
</div>
<div class="form-group" ng-show="!appConfigure.customAuth && appConfigure.app.sso === false">
<label class="control-label">User management</label>
<p>
This app is configured to use it's own user management.
</p>
</div>
<div class="form-group" ng-show="appConfigure.app.manifest.addons.email">
<label class="control-label">User management</label>
<p>
All users with a mailbox on this Cloudron have access.
</p>
</div>
<div class="form-group" ng-hide="appConfigure.customAuth || appConfigure.app.manifest.addons.email || appConfigure.app.sso === false">
<label class="control-label">User management</label>
<div class="radio">
<label>
<input type="radio" ng-model="appConfigure.accessRestrictionOption" value="any">
Allow all users from this Cloudron
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="appConfigure.accessRestrictionOption" value="groups">
Only allow the following users and groups <span class="label label-danger" ng-show="appConfigure.accessRestrictionOption === 'groups' && !appConfigure.isAccessRestrictionValid()">Select at least one user or group</span>
</label>
</div>
<div>
<div style="margin-left: 20px;">
<div class="col-md-5">
Users:
<multiselect class="input-sm stretch" ng-model="appConfigure.accessRestriction.users" ng-disabled="appConfigure.accessRestrictionOption !== 'groups'" options="user.display for user in users" data-multiple="true"></multiselect>
</div>
<div class="col-md-5">
Groups:
<multiselect class="input-sm stretch" ng-model="appConfigure.accessRestriction.groups" ng-disabled="appConfigure.accessRestrictionOption !== 'groups'" options="group.name for group in (groups | ignoreAdminGroup)" data-multiple="true"></multiselect>
</div>
</div>
</div>
<br/>
<br/>
</div>
<p ng-show="appConfigure.app.manifest.addons.email" class="text-info">
This app is pre-configured for use with <a href="https://cloudron.io/documentation/email/" target="_blank">Cloudron Email</a>.
</p>
<a href="" ng-click="appConfigure.advancedVisible = true" ng-hide="appConfigure.advancedVisible">Advanced settings...</a>
<div uib-collapse="!appConfigure.advancedVisible">
<div class="form-group">
<label class="control-label" for="memoryLimit">Memory Limit <sup><a ng-href="{{ config.webServerOrigin }}/documentation/apps/#increasing-the-memory-limit-of-an-app" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup> : <b>{{ appConfigure.memoryLimit ? appConfigure.memoryLimit / 1024 / 1024 + 'MB' : 'Default (256 MB)' }}</b></label>
<br/>
<div style="padding: 0 10px;">
<slider id="memoryLimit" ng-model="appConfigure.memoryLimit" step="134217728" tooltip="hide" ticks="appConfigure.memoryTicks" ticks-snap-bounds="67108864"></slider>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': !appConfigureForm.xFrameOptions.$dirty && appConfigure.error.xFrameOptions }">
<label class="control-label">Allow embedding from the following site</label>
<div class="control-label" ng-show="appConfigure.error.xFrameOptions"><small>Must be empty of a valid URL</small></div>
<input type="text" class="form-control" id="appConfigureXFrameOptionsInput" name="xFrameOptions" placeholder="https://example.com" ng-model="appConfigure.xFrameOptions" uib-tooltip="Leave blank to not allow embedding">
</div>
<div class="form-group">
<label class="control-label">Specify robots.txt file content</label>
<textarea ng-model="appConfigure.robotsTxt" placeholder="Leave empty to allow all bots to index this app." class="form-control" rows="3"></textarea>
</div>
<div class="form-group">
<input type="checkbox" id="appConfigureEnableBackup" ng-model="appConfigure.enableBackup">
<label class="control-label" for="appConfigureEnableBackup">Enable automatic daily backups</label>
</div>
<div class="hide">
<label class="control-label" for="appConfigureCertificateInput" ng-show="appConfigure.domain.provider !== 'caas'">Certificate (optional)</label>
<div class="has-error text-center" ng-show="appConfigure.error.cert && appConfigure.domain.provider !== 'caas'">{{ appConfigure.error.cert }}</div>
<div class="form-group" ng-class="{ 'has-error': !appConfigureForm.certificate.$dirty && appConfigure.error.cert }" ng-show="appConfigure.domain.provider !== 'caas'">
<div class="input-group">
<input type="file" id="appConfigureCertificateFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Certificate" ng-model="appConfigure.certificateFileName" id="appConfigureCertificateInput" name="certificate" onclick="getElementById('appConfigureCertificateFileInput').click();" style="cursor: pointer;" ng-required="appConfigure.keyFileName">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('appConfigureCertificateFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': !appConfigureForm.key.$dirty && appConfigure.error.cert }" ng-show="appConfigure.domain.provider !== 'caas'">
<div class="input-group">
<input type="file" id="appConfigureKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Key" ng-model="appConfigure.keyFileName" id="appConfigureKeyInput" name="key" onclick="getElementById('appConfigureKeyFileInput').click();" style="cursor: pointer;" ng-required="appConfigure.certificateFileName">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('appConfigureKeyFileInput').click();"></i>
</span>
</div>
</div>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="appConfigureForm.$invalid || appConfigure.busy || (appConfigure.accessRestrictionOption === 'groups' && !appConfigure.isAccessRestrictionValid())"/>
</form>
</fieldset>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="appConfigure.submit()" ng-disabled="appConfigureForm.$invalid || appConfigure.busy || (appConfigure.accessRestrictionOption === 'groups' && !appConfigure.isAccessRestrictionValid())"><i class="fa fa-circle-o-notch fa-spin" ng-show="appConfigure.busy"></i> Save</button>
</div>
</div>
</div>
</div>
<!-- Modal restore app -->
<div class="modal fade" id="appRestoreModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Restore {{ appRestore.app.fqdn }}</h4>
</div>
<div class="modal-body" ng-show="appRestore.busyFetching">
<h4 class="text-center"><i class="fa fa-circle-o-notch fa-spin"></i> Fetching backups</h4>
</div>
<div class="modal-body" ng-show="appRestore.backups.length === 0 && !appRestore.busyFetching">
<h4 class="text-danger">This app has no backups to restore from.</h4>
</div>
<div class="modal-body" ng-show="appRestore.backups.length !== 0">
<p>Restoring the app will lose all content generated since the backup.</p>
<label class="control-label">Select backup</label>
<div class="dropdown">
<button type="button" class="btn btn-block btn-default" data-toggle="dropdown">{{ appRestore.selectedBackup.creationTime | prettyDate }} - v{{appRestore.selectedBackup.version}} <span class="caret"></span></button>
<ul class="dropdown-menu" role="menu">
<li ng-repeat="backup in appRestore.backups | orderBy:'-creationTime'">
<a href="" ng-click="appRestore.selectBackup(backup)">{{backup.creationTime}} {{ backup.creationTime | prettyDate }} - v{{backup.version}}</a>
</li>
</ul>
</div>
<br/>
<fieldset>
<form role="form" name="appRestoreForm" ng-submit="appRestore.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (appRestoreForm.password.$dirty && appRestoreForm.password.$invalid) || (!appRestoreForm.password.$dirty && appRestore.error.password) }">
<label class="control-label" for="appRestorePasswordInput">Provide your password to confirm this action</label>
<div class="control-label" ng-show="(appRestoreForm.password.$dirty && appRestoreForm.password.$invalid) || (!appRestoreForm.password.$dirty && appRestore.error.password)">
<small ng-show=" appRestoreForm.password.$dirty && appRestoreForm.password.$invalid">Password required</small>
<small ng-show="!appRestoreForm.password.$dirty && appRestore.error.password">Wrong password</small>
</div>
<input type="password" class="form-control" ng-model="appRestore.password" id="appRestorePasswordInput" name="password" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="appRestoreForm.$invalid || busy"/>
</form>
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="appRestore.submit()" ng-show="appRestore.backups.length !== 0" ng-disabled="appRestoreForm.$invalid || appRestore.busy || !appRestore.selectedBackup"><i class="fa fa-circle-o-notch fa-spin" ng-show="appRestore.busy"></i> Restore</button>
</div>
</div>
</div>
</div>
<!-- Modal information of app -->
<div class="modal fade" id="appInfoModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appInfo.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-info-icon"/>
<h5 class="app-info-title">
{{ appInfo.app.manifest.title }}
<span class="app-info-meta text-small">Package <a ng-href="/#/appstore/{{appInfo.app.manifest.id}}?version={{appInfo.app.manifest.version}}">v{{ appInfo.app.manifest.version }}</a> </span>
</h5>
<br/>
<span class="app-info-meta" ng-show="appInfo.app.manifest.documentationUrl"><a target="_blank" ng-href="{{appInfo.app.manifest.documentationUrl}}">Documentation</a> </span>
<br/>
<span class="app-info-meta">Last updated {{ appInfo.app.updateTime | prettyDate }}</span>
</div>
<div class="modal-body">
<div class="app-postinstall-message" ng-hide="appInfo.app.manifest && appInfo.app.manifest.postInstallMessage">
This package has no special usage information.
</div>
<div class="app-postinstall-message" ng-show="appInfo.app.manifest && appInfo.app.manifest.postInstallMessage">
<div ng-bind-html="appInfo.message | postInstallMessage:appInfo.app | markdown2html"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal" autofocus>Got it</button>
</div>
</div>
</div>
</div>
<!-- Modal error app -->
<div class="modal fade" id="appErrorModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Error for {{ appError.app.fqdn }}</h4>
</div>
<div class="modal-body">
<p>{{ appError.app.message | prettyAppMessage }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default pull-left" ng-click="appConfigure.show(appError.app)" autofocus>Repair</button>
<button type="button" class="btn btn-default" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<!-- Modal uninstall app -->
<div class="modal fade" id="appUninstallModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Really uninstall {{ appUninstall.app.fqdn }} ?</h4>
</div>
<div class="modal-body">
<p>Deleting the app will also remove all content generated within this app!</p>
<fieldset>
<form role="form" name="appUninstallForm" ng-submit="doUninstall()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (appUninstallForm.password.$dirty && appUninstallForm.password.$invalid) || (!appUninstallForm.password.$dirty && appUninstall.error.password) }">
<label class="control-label" for="appUninstallPasswordInput">Provide your password to confirm this action</label>
<div class="control-label" ng-show="(appUninstallForm.password.$dirty && appUninstallForm.password.$invalid) || (!appUninstallForm.password.$dirty && appUninstall.error.password)">
<small ng-show=" appUninstallForm.password.$dirty && appUninstallForm.password.$invalid">Password required</small>
<small ng-show="!appUninstallForm.password.$dirty && appUninstall.error.password">Wrong password</small>
</div>
<input type="password" class="form-control" ng-model="appUninstall.password" id="appUninstallPasswordInput" name="password" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="appUninstallForm.$invalid || busy"/>
</form>
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="doUninstall()" ng-disabled="appUninstallForm.$invalid || appUninstall.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="appUninstall.busy"></i> Uninstall</button>
</div>
</div>
</div>
</div>
<!-- Modal update app -->
<div class="modal fade" id="appUpdateModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Update {{ appUpdate.app.fqdn }}</h4>
</div>
<div class="modal-body">
<p>Recent Changes for new version <b>{{ appUpdate.manifest.version}}</b>:</p>
<div ng-bind-html="appUpdate.manifest.changelog | markdown2html"></div>
</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="doUpdate()" ng-disabled="appUpdate.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="appUpdate.busy"></i> Update</button>
</div>
</div>
</div>
</div>
<script>
function imageErrorHandler(elem) {
'use strict';
var appstoreIconUrl = elem.getAttribute('appstore-icon');
var fallbackIconUrl = elem.getAttribute('fallback-icon');
if (elem.src === appstoreIconUrl) {
elem.src = fallbackIconUrl;
elem.onerror = null; // avoid retry after default icon cannot be loaded
} else {
elem.src = appstoreIconUrl;
}
}
</script>
<div class="content content-large">
<!-- Workaround for select-all issue, see commit message -->
<div style="font-size: 1px;">&nbsp;</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length === 0 && user.admin">
<div class="col-md-12" style="text-align: center;">
<br/><br/><br/><br/>
<h1><i class="fa fa-cloud-download fa-fw"></i> No apps installed yet!</h1>
<br/></br>
<h3>How about installing some? Check out the <a href="#/appstore">App Store</a></h3>
</div>
</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length === 0 && !user.admin">
<div class="col-md-12" 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>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="col-md-12">
<h1>Your Apps</h1>
</div>
</div>
<div class="animateMeOpacity ng-hide" ng-show="installedApps.length > 0">
<div class="col-sm-1 grid-item" ng-repeat="app in installedApps | orderBy:'location'">
<div style="background-color: white;" class="highlight grid-item-content" uib-tooltip="{{ app.fqdn }}">
<a ng-href="{{ app | applicationLink }}" ng-click="(app | installError) === true && showError(app)" target="_blank" ng-class="{ 'hand': !(app | installationActive) }">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12 text-center" style="padding-left: 5px; padding-right: 5px;">
<br/>
<img ng-src="{{app.iconUrl || 'img/appicon_fallback.png'}}" fallback-icon="img/appicon_fallback.png" appstore-icon="{{ app.iconUrlStore }}" onerror="imageErrorHandler(this)" class="app-icon"/>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-12 text-center">
<div class="grid-item-top-title" data-fittext>{{ app.location || app.fqdn }}</div>
<div class="text-muted status" style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden">
{{ app | installationStateLabel }}
</div>
<div class="status" ng-style="{ 'visibility': (app | installationActive) ? 'visible' : 'hidden' }">
<div class="progress progress-striped active">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ app.progress }}%"></div>
</div>
</div>
</div>
</div>
</div>
<div class="grid-item-bottom-mobile" ng-show="user.admin">
<div class="row">
<div class="col-xs-4 text-left">
<a href="" ng-click="appRestore.show(app)" ng-show="backupConfig.provider !== 'noop'">
<i class="fa fa-undo scale"></i>
</a>
<a href="" ng-click="appConfigure.show(app)" ng-show="app.installationState === 'installed' || app.installationState === 'pending_configure' || (app | installError)">
<i ng-hide="(app | installError)" class="fa fa-pencil scale"></i>
<i ng-show="(app | installError)" class="fa fa-wrench scale"></i>
</a>
</div>
<div class="col-xs-4 text-center"></div>
<div class="col-xs-4 text-right">
<a href="" ng-click="showUninstall(app)">
<i class="fa fa-remove scale"></i>
</a>
</div>
</div>
</div>
<div class="grid-item-bottom" ng-show="user.admin">
<div>
<a href="" ng-click="showUninstall(app)" uib-tooltip="Uninstall" tooltip-placement="right" tooltip-class="app-tooltip"><i class="fa fa-remove scale"></i></a>
</div>
<div>
<a href="" ng-click="appRestore.show(app)" ng-show="backupConfig.provider !== 'noop'" uib-tooltip="Restore" tooltip-placement="right" tooltip-class="app-tooltip"><i class="fa fa-undo scale"></i></a>
</div>
<div ng-show="(app.installationState === 'installed' || app.installationState === 'pending_configure') && !(app | installError)">
<a href="" ng-click="appConfigure.show(app)" uib-tooltip="Configure" tooltip-placement="right" tooltip-class="app-tooltip"><i class="fa fa-pencil scale"></i></a>
</div>
<div ng-show="app | installError">
<a href="" ng-click="appConfigure.show(app)" uib-tooltip="Repair" tooltip-placement="right" tooltip-class="app-tooltip"><i class="fa fa-wrench scale"></i></a>
</div>
<div>
<a href="" ng-click="showTerminal(app)" uib-tooltip="Terminal" tooltip-placement="right" tooltip-class="app-tooltip"><i class="fa fa-terminal scale"></i></a>
</div>
<div>
<a href="" ng-click="showLogs(app)" uib-tooltip="Logs" tooltip-placement="right" tooltip-class="app-tooltip"><i class="fa fa-file-text scale"></i></a>
</div>
<div>
<a href="" ng-click="showInformation(app)" uib-tooltip="Information" tooltip-placement="right" tooltip-class="app-tooltip"><i class="fa fa-info-circle scale"></i></a>
</div>
</div>
<!-- we check the version here because the box updater does not know when an app gets updated -->
<div class="app-update-badge" ng-show="config.update.apps[app.id].manifest.version && config.update.apps[app.id].manifest.version !== app.manifest.version && (app | installSuccess)">
<a href="" ng-click="showUpdate(app, config.update.apps[app.id].manifest)" title="Update Available">
<span class="fa-stack fa-lg scale-small">
<i class="fa fa-circle fa-stack-2x text-success"></i>
<i class="fa fa-refresh fa-stack-1x fa-inverse"></i>
</span>
</a>
</div>
</a>
</div>
</div>
</div>
</div>
+510
View File
@@ -0,0 +1,510 @@
'use strict';
angular.module('Application').controller('AppsController', ['$scope', '$location', '$timeout', '$interval', 'Client', 'ngTld', 'AppStore', function ($scope, $location, $timeout, $interval, Client, ngTld, AppStore) {
$scope.HOST_PORT_MIN = 1024;
$scope.HOST_PORT_MAX = 65535;
$scope.installedApps = Client.getInstalledApps();
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.domains = [];
$scope.groups = [];
$scope.users = [];
$scope.backupConfig = {};
$scope.appConfigure = {
busy: false,
error: {},
app: {},
domain: '',
location: '',
advancedVisible: false,
portBindings: {},
portBindingsEnabled: {},
portBindingsInfo: {},
robotsTxt: '',
certificateFile: null,
certificateFileName: '',
keyFile: null,
keyFileName: '',
memoryLimit: 0,
memoryTicks: [],
accessRestrictionOption: 'any',
accessRestriction: { users: [], groups: [] },
xFrameOptions: '',
customAuth: false,
isAccessRestrictionValid: function () {
var tmp = $scope.appConfigure.accessRestriction;
return !!(tmp.users.length || tmp.groups.length);
},
show: function (app) {
$scope.reset();
// fill relevant info from the app
$scope.appConfigure.app = app;
$scope.appConfigure.location = app.location;
$scope.appConfigure.domain = $scope.domains.filter(function (d) { return d.domain === app.domain; })[0];
$scope.appConfigure.portBindingsInfo = app.manifest.tcpPorts || {}; // Portbinding map only for information
$scope. Option = app.accessRestriction ? 'groups' : 'any';
$scope.appConfigure.memoryLimit = app.memoryLimit || app.manifest.memoryLimit || (256 * 1024 * 1024);
$scope.appConfigure.xFrameOptions = app.xFrameOptions.indexOf('ALLOW-FROM') === 0 ? app.xFrameOptions.split(' ')[1] : '';
$scope.appConfigure.customAuth = !(app.manifest.addons['ldap'] || app.manifest.addons['oauth']);
$scope.appConfigure.robotsTxt = app.robotsTxt;
$scope.appConfigure.enableBackup = app.enableBackup;
// create ticks starting from manifest memory limit. the memory limit here is currently split into ram+swap (and thus *2 below)
// TODO: the *2 will overallocate since 4GB is max swap that cloudron itself allocates
$scope.appConfigure.memoryTicks = [ ];
var npow2 = Math.pow(2, Math.ceil(Math.log($scope.config.memory)/Math.log(2)));
for (var i = 256; i <= (npow2*2/1024/1024); i *= 2) {
if (i >= (app.manifest.memoryLimit/1024/1024 || 0)) $scope.appConfigure.memoryTicks.push(i * 1024 * 1024);
}
if (app.manifest.memoryLimit && $scope.appConfigure.memoryTicks[0] !== app.manifest.memoryLimit) {
$scope.appConfigure.memoryTicks.unshift(app.manifest.memoryLimit);
}
$scope.appConfigure.accessRestrictionOption = app.accessRestriction ? 'groups' : 'any';
$scope.appConfigure.accessRestriction = { users: [], groups: [] };
if (app.accessRestriction) {
var userSet = { };
app.accessRestriction.users.forEach(function (uid) { userSet[uid] = true; });
$scope.users.forEach(function (u) { if (userSet[u.id] === true) $scope.appConfigure.accessRestriction.users.push(u); });
var groupSet = { };
app.accessRestriction.groups.forEach(function (gid) { groupSet[gid] = true; });
$scope.groups.forEach(function (g) { if (groupSet[g.id] === true) $scope.appConfigure.accessRestriction.groups.push(g); });
}
// fill the portBinding structures. There might be holes in the app.portBindings, which signalizes a disabled port
for (var env in $scope.appConfigure.portBindingsInfo) {
if (app.portBindings && app.portBindings[env]) {
$scope.appConfigure.portBindings[env] = app.portBindings[env];
$scope.appConfigure.portBindingsEnabled[env] = true;
} else {
$scope.appConfigure.portBindings[env] = $scope.appConfigure.portBindingsInfo[env].defaultValue || 0;
$scope.appConfigure.portBindingsEnabled[env] = false;
}
}
$('#appConfigureModal').modal('show');
},
submit: function () {
$scope.appConfigure.busy = true;
$scope.appConfigure.error.other = null;
$scope.appConfigure.error.location = null;
$scope.appConfigure.error.xFrameOptions = null;
// only use enabled ports from portBindings
var finalPortBindings = {};
for (var env in $scope.appConfigure.portBindings) {
if ($scope.appConfigure.portBindingsEnabled[env]) {
finalPortBindings[env] = $scope.appConfigure.portBindings[env];
}
}
var finalAccessRestriction = null;
if ($scope.appConfigure.accessRestrictionOption === 'groups') {
finalAccessRestriction = { users: [], groups: [] };
finalAccessRestriction.users = $scope.appConfigure.accessRestriction.users.map(function (u) { return u.id; });
finalAccessRestriction.groups = $scope.appConfigure.accessRestriction.groups.map(function (g) { return g.id; });
}
var data = {
location: $scope.appConfigure.location,
domain: $scope.appConfigure.domain.domain,
portBindings: finalPortBindings,
accessRestriction: finalAccessRestriction,
cert: $scope.appConfigure.certificateFile,
key: $scope.appConfigure.keyFile,
xFrameOptions: $scope.appConfigure.xFrameOptions ? ('ALLOW-FROM ' + $scope.appConfigure.xFrameOptions) : 'SAMEORIGIN',
memoryLimit: $scope.appConfigure.memoryLimit === $scope.appConfigure.memoryTicks[0] ? 0 : $scope.appConfigure.memoryLimit,
robotsTxt: $scope.appConfigure.robotsTxt,
enableBackup: $scope.appConfigure.enableBackup
};
Client.configureApp($scope.appConfigure.app.id, data, function (error) {
if (error) {
if (error.statusCode === 409 && (error.message.indexOf('is reserved') !== -1 || error.message.indexOf('is already in use') !== -1)) {
$scope.appConfigure.error.port = error.message;
} else if (error.statusCode === 409) {
$scope.appConfigure.error.location = 'This name is already taken.';
$scope.appConfigureForm.location.$setPristine();
$('#appConfigureLocationInput').focus();
} else if (error.statusCode === 400 && error.message.indexOf('cert') !== -1 ) {
$scope.appConfigure.error.cert = error.message;
$scope.appConfigure.certificateFileName = '';
$scope.appConfigure.certificateFile = null;
$scope.appConfigure.keyFileName = '';
$scope.appConfigure.keyFile = null;
} else if (error.statusCode === 400 && error.message.indexOf('xFrameOptions') !== -1 ) {
$scope.appConfigure.error.xFrameOptions = error.message;
$scope.appConfigureForm.xFrameOptions.$setPristine();
$('#appConfigureXFrameOptionsInput').focus();
} else {
$scope.appConfigure.error.other = error.message;
}
$scope.appConfigure.busy = false;
return;
}
$scope.appConfigure.busy = false;
Client.refreshInstalledApps(); // reflect the new app state immediately
$('#appConfigureModal').modal('hide');
$scope.reset();
});
}
};
$scope.appUninstall = {
busy: false,
error: {},
app: {},
password: ''
};
$scope.appRestore = {
busy: false,
busyFetching: false,
error: {},
app: {},
password: '',
backups: [ ],
selectedBackup: null,
selectBackup: function (backup) {
$scope.appRestore.selectedBackup = backup;
},
show: function (app) {
$scope.reset();
$scope.appRestore.app = app;
$scope.appRestore.busyFetching = true;
$('#appRestoreModal').modal('show');
Client.getAppBackups(app.id, function (error, backups) {
if (error) {
Client.error(error);
} else {
$scope.appRestore.backups = backups;
if (backups.length) $scope.appRestore.selectedBackup = backups[0]; // pre-select first backup
$scope.appRestore.busyFetching = false;
}
});
return false; // prevent propagation and default
},
submit: function () {
$scope.appRestore.busy = true;
$scope.appRestore.error.password = null;
Client.restoreApp($scope.appRestore.app.id, $scope.appRestore.selectedBackup.id, $scope.appRestore.password, function (error) {
if (error && error.statusCode === 403) {
$scope.appRestore.password = '';
$scope.appRestore.error.password = true;
$scope.appRestoreForm.password.$setPristine();
$('#appRestorePasswordInput').focus();
} else if (error) {
Client.error(error);
} else {
$('#appRestoreModal').modal('hide');
}
$scope.appRestore.busy = false;
Client.refreshInstalledApps(); // reflect the new app state immediately
});
}
};
$scope.appInfo = {
app: {},
message: ''
};
$scope.appError = {
app: {}
};
$scope.appUpdate = {
busy: false,
error: {},
app: {},
manifest: {},
portBindings: {}
};
$scope.reset = function () {
// close all dialogs
$('#appErrorModal').modal('hide');
$('#appConfigureModal').modal('hide');
$('#appRestoreModal').modal('hide');
$('#appUpdateModal').modal('hide');
$('#appInfoModal').modal('hide');
$('#appUninstallModal').modal('hide');
// reset configure dialog
$scope.appConfigure.error = {};
$scope.appConfigure.app = {};
$scope.appConfigure.domain = null;
$scope.appConfigure.location = '';
$scope.appConfigure.advancedVisible = false;
$scope.appConfigure.portBindings = {}; // This is the actual model holding the env:port pair
$scope.appConfigure.portBindingsEnabled = {}; // This is the actual model holding the enabled/disabled flag
$scope.appConfigure.certificateFile = null;
$scope.appConfigure.certificateFileName = '';
$scope.appConfigure.keyFile = null;
$scope.appConfigure.keyFileName = '';
$scope.appConfigure.memoryLimit = 0;
$scope.appConfigure.memoryTicks = [];
$scope.appConfigure.accessRestrictionOption = 'any';
$scope.appConfigure.accessRestriction = { users: [], groups: [] };
$scope.appConfigure.xFrameOptions = '';
$scope.appConfigure.customAuth = false;
$scope.appConfigure.robotsTxt = '';
$scope.appConfigure.enableBackup = true;
$scope.appConfigureForm.$setPristine();
$scope.appConfigureForm.$setUntouched();
// reset uninstall dialog
$scope.appUninstall.app = {};
$scope.appUninstall.error = {};
$scope.appUninstall.password = '';
$scope.appUninstallForm.$setPristine();
$scope.appUninstallForm.$setUntouched();
// reset update dialog
$scope.appUpdate.error = {};
$scope.appUpdate.app = {};
$scope.appUpdate.manifest = {};
// reset restore dialog
$scope.appRestore.error = {};
$scope.appRestore.app = {};
$scope.appRestore.password = '';
$scope.appRestore.selectedBackup = null;
$scope.appRestore.backups = [];
$scope.appRestoreForm.$setPristine();
$scope.appRestoreForm.$setUntouched();
};
document.getElementById('appConfigureCertificateFileInput').onchange = function (event) {
$scope.$apply(function () {
$scope.appConfigure.certificateFile = null;
$scope.appConfigure.certificateFileName = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
$scope.appConfigure.certificateFile = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
document.getElementById('appConfigureKeyFileInput').onchange = function (event) {
$scope.$apply(function () {
$scope.appConfigure.keyFile = null;
$scope.appConfigure.keyFileName = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
$scope.appConfigure.keyFile = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
$scope.showInformation = function (app) {
$scope.reset();
$scope.appInfo.app = app;
$scope.appInfo.message = app.manifest.postInstallMessage;
$('#appInfoModal').modal('show');
return false; // prevent propagation and default
};
$scope.showLogs = function (app) {
window.open('/logs.html?id=' + app.id, 'Logs', 'width=1024,height=800').focus();
};
$scope.showTerminal = function (app) {
window.open('/terminal.html?id=' + app.id, 'Terminal', 'width=1024,height=800').focus();
};
$scope.showError = function (app) {
$scope.reset();
$scope.appError.app = app;
$('#appErrorModal').modal('show');
return false; // prevent propagation and default
};
$scope.showUninstall = function (app) {
$scope.reset();
$scope.appUninstall.app = app;
$('#appUninstallModal').modal('show');
};
$scope.doUninstall = function () {
$scope.appUninstall.busy = true;
$scope.appUninstall.error.password = null;
Client.uninstallApp($scope.appUninstall.app.id, $scope.appUninstall.password, function (error) {
if (error && error.statusCode === 403) {
$scope.appUninstall.password = '';
$scope.appUninstall.error.password = true;
$scope.appUninstallForm.password.$setPristine();
$('#appUninstallPasswordInput').focus();
} else if (error && error.statusCode === 402) { // unpurchase failed
Client.error('Relogin to Cloudron App Store');
} else if (error) {
Client.error(error);
} else {
$('#appUninstallModal').modal('hide');
$scope.reset();
}
$scope.appUninstall.busy = false;
Client.refreshInstalledApps(); // reflect the new app state immediately
});
};
$scope.showUpdate = function (app, updateManifest) {
if (!updateManifest.dockerImage) {
$('#setupSubscriptionModal').modal('show');
return;
}
$scope.reset();
$scope.appUpdate.app = app;
$scope.appUpdate.manifest = angular.copy(updateManifest);
$('#appUpdateModal').modal('show');
};
$scope.doUpdate = function () {
$scope.appUpdate.busy = true;
Client.updateApp($scope.appUpdate.app.id, $scope.appUpdate.manifest, function (error) {
if (error) {
Client.error(error);
} else {
$scope.appUpdate.app = {};
$('#appUpdateModal').modal('hide');
}
$scope.appUpdate.busy = false;
Client.refreshInstalledApps(); // reflect the new app state immediately
});
};
$scope.renderAccessRestrictionUser = function (userId) {
var user = $scope.users.filter(function (u) { return u.id === userId; })[0];
// user not found
if (!user) return userId;
return user.username ? user.username : user.email;
};
$scope.cancel = function () {
window.history.back();
};
function fetchUsers() {
Client.getUsers(function (error, users) {
if (error) {
console.error(error);
return $timeout(fetchUsers, 5000);
}
// ensure we have something to work with in the access restriction dropdowns
users.forEach(function (user) { user.display = user.username || user.email; });
$scope.users = users;
});
}
function fetchGroups() {
Client.getGroups(function (error, groups) {
if (error) {
console.error(error);
return $timeout(fetchUsers, 5000);
}
$scope.groups = groups;
});
}
function getDomains() {
Client.getDomains(function (error, result) {
if (error) {
console.error(error);
return $timeout(getDomains, 5000);
}
$scope.domains = result;
});
}
function getBackupConfig() {
Client.getBackupConfig(function (error, backupConfig) {
if (error) return console.error(error);
$scope.backupConfig = backupConfig;
});
}
Client.onReady(function () {
Client.refreshInstalledApps(function (error) {
if (error) return console.error(error);
if ($scope.user.admin) {
fetchUsers();
fetchGroups();
getDomains();
getBackupConfig();
}
var refreshAppsTimer = $interval(Client.refreshInstalledApps.bind(Client), 5000);
$scope.$on('$destroy', function () {
$interval.cancel(refreshAppsTimer);
});
});
});
// setup all the dialog focus handling
['appConfigureModal', 'appUninstallModal', 'appUpdateModal', 'appRestoreModal', 'appInfoModal', 'appErrorModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
$('.modal-backdrop').remove();
}]);
+303
View File
@@ -0,0 +1,303 @@
<!-- Modal install app -->
<div class="modal fade appstore-install" id="appInstallModal" tabindex="-1" role="dialog" aria-labelledby="updateModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<img ng-src="{{appInstall.app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-icon"/>
<h3 class="appstore-install-title" title="Version {{ appInstall.app.manifest.version }}">{{ appInstall.app.manifest.title }} <span class="badge badge-danger" ng-show="appInstall.app.publishState === 'testing'">Testing</span></h3>
<br/>
<span class="appstore-install-meta"><a href="{{ appInstall.app.manifest.website }}" target="_blank">{{ appInstall.app.manifest.author }}</a></span>
<br/>
<span class="appstore-install-meta">Last updated {{ appInstall.app.creationDate | prettyDate }}</span>
<br/>
<span class="appstore-install-meta hand">Requires atleast {{ appInstall.app.manifest.memoryLimit | prettyMemory }}MB memory</span>
</div>
<div class="modal-body">
<div class="collapse" id="collapseInstallForm" data-toggle="false">
<form role="form" name="appInstallForm" ng-submit="appInstall.submit()" autocomplete="off">
<div class="has-error text-center" ng-show="appInstall.error.other" ng-bind-html="appInstall.error.other"></div>
<div class="form-group" ng-class="{ 'has-error': (appInstallForm.location.$dirty && appInstallForm.location.$invalid) || (!appInstallForm.location.$dirty && appInstall.error.location) }">
<label class="control-label" for="appInstallLocationInput">Location {{ appInstall.error.location }} </label>
<div class="input-group form-inline">
<input type="text" class="form-control" ng-model="appInstall.location" id="appInstallLocationInput" name="location" placeholder="Leave empty to use bare domain" autofocus>
<div class="input-group-btn">
<button type="button" class="btn btn-default dropdown-toggle" data-toggle="dropdown">
{{ (appInstall.location ? (appInstall.domain.provider !== 'caas' ? '.' : '-') : '') + appInstall.domain.domain }}
<span class="caret"></span>
</button>
<ul class="dropdown-menu dropdown-menu-right" role="menu">
<li ng-repeat="domain in domains">
<a href="" ng-click="appInstall.domain = domain">{{ domain.domain }}</a>
</li>
</ul>
</div>
</div>
</div>
<p class="text-center" ng-show="appInstall.location && dnsConfig.provider === 'manual' && !dnsConfig.wildcard">
<b>Add an A record manually for {{ appInstall.location }} to this Cloudron's public IP</b>
<br>
</p>
<div class="has-error text-center" ng-show="appInstall.error.port">{{ appInstall.error.port }}</div>
<div ng-repeat="(env, info) in appInstall.portBindingsInfo">
<ng-form name="portInfo_form">
<div class="form-group" ng-class="{ 'has-error': (!appInstallForm.itemName{{$index}}.$dirty && appInstall.error.port) || (portInfo_form.itemName{{$index}}.$dirty && portInfo_form.itemName{{$index}}.$invalid) }">
<label class="control-label" for="inputPortInfo{{env}}"><input type="checkbox" ng-model="appInstall.portBindingsEnabled[env]"> {{ info.description }} ({{ HOST_PORT_MIN }} - {{ HOST_PORT_MAX }})</label>
<input type="number" class="form-control" ng-model="appInstall.portBindings[env]" ng-disabled="!appInstall.portBindingsEnabled[env]" id="inputPortInfo{{env}}" later-name="itemName{{$index}}" min="{{hostPortMin}}" max="{{hostPortMax}}" required>
</div>
</ng-form>
</div>
<div class="form-group" ng-show="appInstall.customAuth && !appInstall.app.manifest.addons.email">
<label class="control-label">User management</label>
<p>
This app has it's own user management.
</p>
</div>
<div class="form-group" ng-show="appInstall.app.manifest.addons.email">
<label class="control-label">User management</label>
<p>
All users with a mailbox on this Cloudron have access.
</p>
</div>
<div class="form-group" ng-hide="appInstall.customAuth || appInstall.app.manifest.addons.email">
<label class="control-label">User management</label>
<div class="radio" ng-show="appInstall.optionalSso">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="nosso">
Leave user management to the app
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="any">
Allow all users from this Cloudron
</label>
</div>
<div class="radio">
<label>
<input type="radio" ng-model="appInstall.accessRestrictionOption" value="groups">
Only allow the following users and groups <span class="label label-danger" ng-show="appInstall.accessRestrictionOption === 'groups' && !appInstall.isAccessRestrictionValid()">Select at least one user or group</span>
</label>
</div>
<div>
<div style="margin-left: 20px;">
<div class="col-md-5">
Users: &nbsp;
<multiselect ng-model="appInstall.accessRestriction.users" ng-disabled="appInstall.accessRestrictionOption !== 'groups'" options="user.username for user in users" data-multiple="true"></multiselect>
</div>
<div class="col-md-5">
Groups: &nbsp;
<multiselect ng-model="appInstall.accessRestriction.groups" ng-disabled="appInstall.accessRestrictionOption !== 'groups'" options="group.name for group in (groups | ignoreAdminGroup)" data-multiple="true"></multiselect>
</div>
</div>
</div>
<br/>
<br/>
</div>
<p ng-show="appInstall.app.manifest.addons.email" class="text-info">
This app is pre-configured for use with <a href="https://cloudron.io/documentation/email/" target="_blank">Cloudron Email</a>.
</p>
<div class="hide">
<label class="control-label" for="appInstallCertificateInput" ng-show="appInstall.domain.provider !== 'caas'">Certificate (optional)</label>
<div class="has-error text-center" ng-show="appInstall.error.cert && appInstall.domain.provider !== 'caas'">{{ appInstall.error.cert }}</div>
<div class="form-group" ng-class="{ 'has-error': !appInstallForm.certificate.$dirty && appInstall.error.cert }" ng-show="appInstall.domain.provider !== 'caas'">
<div class="input-group">
<input type="file" id="appInstallCertificateFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Certificate" ng-model="appInstall.certificateFileName" id="appInstallCertificateInput" name="certificate" onclick="getElementById('appInstallCertificateFileInput').click();" style="cursor: pointer;" ng-required="appInstall.keyFileName">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('appInstallCertificateFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': !appInstallForm.key.$dirty && appInstall.error.cert }" ng-show="appInstall.domain.provider !== 'caas'">
<div class="input-group">
<input type="file" id="appInstallKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Key" ng-model="appInstall.keyFileName" id="appInstallKeyInput" name="key" onclick="getElementById('appInstallKeyFileInput').click();" style="cursor: pointer;" ng-required="appInstall.certificateFileName">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('appInstallKeyFileInput').click();"></i>
</span>
</div>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="appInstallForm.$invalid || busy"/>
</form>
</div>
<div class="collapse" id="collapseMediaLinksCarousel" data-toggle="false">
<div ng-repeat="mediaLink in appInstall.mediaLinks" class="slick-item" style="background-image: url('{{mediaLink}}');" ng-show="appInstall.mediaLinks.length == 1"></div>
<slick init-onload="true" current-index="0" autoplay="true" arrows="false" autoplay-speed="2000" data="appInstall.mediaLinks" ng-show="appInstall.mediaLinks.length > 1">
<div ng-repeat="mediaLink in appInstall.mediaLinks" class="slick-item" style="background-image: url('{{mediaLink}}');"></div>
</slick>
<div class="appstore-install-description">
<div ng-bind-html="appInstall.app.manifest.description | markdown2html"></div>
</div>
</div>
<div class="collapse" id="collapseResourceConstraint" data-toggle="false">
<h4 class="text-danger">This Cloudron is running low on resources.</h4>
<p ng-show="config.provider === 'caas'">Please upgrade to a bigger plan. Alternately, free up resources by uninstalling unused applications.</p>
<p ng-hide="config.provider === 'caas'">Please upgrade to a server instance with more memory. Alternately, free up resources by uninstalling unused applications.</p>
</div>
<div class="collapse" id="postInstallMessage" data-toggle="false">
<div class="appstore-install-description">
<div ng-bind-html="appInstall.app.manifest.postInstallMessage | postInstallMessage:appInstall.app | markdown2html"></div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" ng-show="appInstall.state !== 'postInstall'" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-default" ng-show="appInstall.state === 'postInstall'" data-dismiss="modal" ng-click="appInstall.switchToAppsView()">Got it</button>
<button type="button" class="btn btn-success" ng-show="config.provider === 'caas' && user.admin && appInstall.state === 'resourceConstraint'" ng-click="showView('/settings')">Upgrade Cloudron</button>
<button type="button" class="btn btn-danger" ng-show="config.provider !== 'caas' && user.admin && appInstall.state === 'resourceConstraint'" ng-click="appInstall.showForm(true)">Install anyway</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'appInfo' && user.admin" ng-click="appInstall.showForm()">Install</button>
<button type="button" class="btn btn-success" ng-show="appInstall.state === 'installForm' && user.admin" ng-click="appInstall.submit()" ng-disabled="appInstallForm.$invalid || appInstall.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="appInstall.busy"></i> Install</button>
</div>
</div>
</div>
</div>
<!-- Modal app not found -->
<div class="modal fade" id="appNotFoundModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">App not found</h4>
</div>
<div class="modal-body">
There is no such app <b>{{ appNotFound.appId }}</b><span ng-show="appNotFound.version"> with version <b>{{ appNotFound.version }}</b></span>.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<div ng-show="!ready" class="loading-banner">
<h1><i class="fa fa-circle-o-notch fa-spin"></i></h1>
</div>
<!-- appstore login -->
<div ng-show="ready && !validAppstoreAccount" class="container card card-small appstore-login ng-cloak">
<div class="col-md-12 text-center">
<h1 ng-show="appstoreLogin.register">Sign up with Cloudron.io</h1>
<h1 ng-hide="appstoreLogin.register">Login to Cloudron.io</h1>
</div>
<div class="col-md-12 text-center" style="margin-bottom: 25px;">
<p>Free 14-day trial, no credit card required</p>
</div>
<div class="col-md-12" style="margin-bottom: 10px;">
<small>
A Cloudron subscription provides access to the Cloudron App Store. This ensures you are running the latest version
and keeps your apps and server secure.
</small>
<small class="text-danger" ng-show="appstoreLogin.error.generic">{{ appstoreLogin.error.generic }}</small>
</div>
<div class="col-md-12">
<br/>
<form name="appstoreLoginForm" role="form" novalidate ng-submit="appstoreLogin.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">Email Address</label>
<input type="email" class="form-control" ng-model="appstoreLogin.email" id="inputAppstoreLoginEmail" name="email" required autofocus>
<div class="control-label" ng-show="(!appstoreLoginForm.email.$dirty && appstoreLogin.error.email) || (appstoreLoginForm.email.$dirty && appstoreLoginForm.email.$invalid) || appstoreLogin.error.email">
<small class="text-danger" ng-show="appstoreLogin.error.email">{{ appstoreLogin.error.email }}</small>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!appstoreLoginForm.password.$dirty && appstoreLogin.error.password) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid) || appstoreLogin.error.generic }">
<label class="control-label">Password</label>
<input type="password" class="form-control" ng-model="appstoreLogin.password" id="inputAppstoreLoginPassword" name="password" required>
<div class="control-label" ng-show="(!appstoreLoginForm.password.$dirty && appstoreLogin.error.password) || (appstoreLoginForm.password.$dirty && appstoreLoginForm.password.$invalid)">
<small ng-show="!appstoreLoginForm.password.$dirty && appstoreLogin.error.password">Wrong password</small>
</div>
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="appstoreLogin.termsAccepted">I accept the Cloudron <a href="https://cloudron.io/legal/license.html" target="_blank">license</a>
</label>
</div>
<br/>
<center>
<button type="submit" class="btn btn-lg btn-success" ng-disabled="appstoreLoginForm.$invalid || appstoreLogin.busy || !appstoreLogin.termsAccepted">
<i class="fa fa-circle-o-notch fa-spin" ng-show="appstoreLogin.busy"></i> <span ng-hide="appstoreLogin.register">Login</span><span ng-show="appstoreLogin.register">Sign up for free trial</span>
</button>
<br/>
<br/>
<a href="" ng-click="appstoreLogin.register = true" ng-hide="appstoreLogin.register">Don't have a account yet?</a>
<a href="" ng-click="appstoreLogin.register = false" ng-show="appstoreLogin.register">Already have a account?</a>
</center>
</form>
</div>
</div>
<div ng-show="ready && validAppstoreAccount" class="ng-cloak" id="appstoreGrid">
<div class="col-md-2">
<br/>
<div>
<form ng-submit="search()">
<div class="input-group">
<input type="text" id="appstoreSearch" class="form-control" style="height: 40px" placeholder="Search" ng-model="searchString" ng-change="search()" autofocus>
</div>
</form>
</div>
<br/>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === '' }" category="">All</a>
<br/>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'analytics' }" category="analytics"><i class="fa fa-bar-chart"></i> Analytics</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'blog' }" category="blog"><i class="fa fa-font"></i> Blog</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'chat' }" category="chat"><i class="fa fa-comments-o"></i> Chat</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'git' }" category="git"><i class="fa fa-code-fork"></i> Code Hosting</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'email' }" category="email"><i class="fa fa-envelope-o"></i> Email</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'sync' }" category="sync"><i class="fa fa-refresh"></i> File Sync</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'finance' }" category="finance"><i class="fa fa-dollar"></i> Finance</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'forum' }" category="forum"><i class="fa fa-users"></i> Forum</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'gallery' }" category="gallery"><i class="fa fa-picture-o"></i> Gallery</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'notes' }" category="notes"><i class="fa fa-sticky-note-o"></i> Notes</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'project' }" category="project"><i class="fa fa-line-chart"></i> Project Management</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'vpn' }" category="vpn"><i class="fa fa-user-secret"></i> VPN</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'hosting' }" category="hosting"><i class="fa fa-bars"></i> Web Hosting</a>
<a href="" class="appstore-category-link" ng-click="showCategory($event);" ng-class="{'category-active': category === 'wiki' }" category="wiki"><i class="fa fa-wikipedia-w"></i> Wiki</a>
<br/>
<br/>
<a href="https://forum.cloudron.io/category/5/app-requests" target="_blank">Missing an app? Let us know.</a>
</div>
<div class="col-md-10" ng-show="apps.length">
<div class="row-no-margin">
<div class="col-sm-1 appstore-item" ng-repeat="app in apps | orderBy:'installCount':true">
<div class="appstore-item-content highlight" ng-click="gotoApp(app)" ng-class="{ 'appstore-item-content-testing': (app.publishState === 'testing' || app.publishState === 'pending_approval') }">
<span class="badge badge-danger appstore-item-badge-testing" ng-show="app.publishState === 'testing'">Testing</span>
<span class="badge badge-warning appstore-item-badge-testing" ng-show="app.publishState === 'pending_approval'">Pending Approval</span>
<div class="appstore-item-content-icon col-same-height">
<img ng-src="{{app.iconUrl}}" onerror="this.onerror=null;this.src='img/appicon_fallback.png'" class="app-icon"/>
</div>
<div class="appstore-item-content-description col-same-height">
<h4 class="appstore-item-content-title">{{ app.manifest.title }}</h4>
<div class="appstore-item-content-tagline text-muted">{{ app.manifest.tagline }}</div>
<!-- <div class="appstore-item-rating"><i class="fa fa-star"></i><i class="fa fa-star"></i><i class="fa fa-star"></i><i class="fa fa-star-half-o"></i><i class="fa fa-star-o"></i></div> -->
</div>
</div>
</div>
</div>
</div>
<div class="col-md-10 animateMeOpacity loading-banner" ng-show="!apps.length">
<h3 class="text-muted">No apps found.</h3>
<a href="https://forum.cloudron.io/category/5/app-requests" target="_blank"><h3>Request an app or vote for one in our forum.</h3></a>
</div>
</div>
+555
View File
@@ -0,0 +1,555 @@
'use strict';
angular.module('Application').controller('AppStoreController', ['$scope', '$location', '$timeout', '$routeParams', 'Client', 'AppStore', function ($scope, $location, $timeout, $routeParams, Client, AppStore) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.HOST_PORT_MIN = 1024;
$scope.HOST_PORT_MAX = 65535;
$scope.ready = false;
$scope.apps = [];
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.users = [];
$scope.groups = [];
$scope.domains = [];
$scope.category = '';
$scope.cachedCategory = ''; // used to cache the selected category while searching
$scope.searchString = '';
$scope.validAppstoreAccount = false;
$scope.appstoreConfig = null;
$scope.showView = function (view) {
// wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already
$('.modal').on('hidden.bs.modal', function () {
$scope.$apply(function () {
$scope.appInstall.reset();
$('.modal').off('hidden.bs.modal');
$location.path(view);
});
});
$('.modal').modal('hide');
};
$scope.appInstall = {
busy: false,
state: 'appInfo',
error: {},
app: {},
location: '',
domain: null,
portBindings: {},
mediaLinks: [],
certificateFile: null,
certificateFileName: '',
keyFile: null,
keyFileName: '',
accessRestrictionOption: 'any',
accessRestriction: { users: [], groups: [] },
customAuth: false,
optionalSso: false,
isAccessRestrictionValid: function () {
var tmp = $scope.appInstall.accessRestriction;
return !!(tmp.users.length || tmp.groups.length);
},
reset: function () {
$scope.appInstall.app = {};
$scope.appInstall.error = {};
$scope.appInstall.location = '';
$scope.appInstall.domain = null;
$scope.appInstall.portBindings = {};
$scope.appInstall.state = 'appInfo';
$scope.appInstall.mediaLinks = [];
$scope.appInstall.certificateFile = null;
$scope.appInstall.certificateFileName = '';
$scope.appInstall.keyFile = null;
$scope.appInstall.keyFileName = '';
$scope.appInstall.accessRestrictionOption = 'any';
$scope.appInstall.accessRestriction = { users: [], groups: [] };
$scope.appInstall.optionalSso = false;
$scope.appInstall.customAuth = false;
$('#collapseInstallForm').collapse('hide');
$('#collapseResourceConstraint').collapse('hide');
$('#collapseMediaLinksCarousel').collapse('show');
$('#postInstallMessage').collapse('hide');
if ($scope.appInstallForm) {
$scope.appInstallForm.$setPristine();
$scope.appInstallForm.$setUntouched();
}
},
showForm: function (force) {
if (Client.enoughResourcesAvailable($scope.appInstall.app) || force) {
$scope.appInstall.state = 'installForm';
$('#collapseMediaLinksCarousel').collapse('hide');
$('#collapseResourceConstraint').collapse('hide');
$('#collapseInstallForm').collapse('show');
$('#appInstallLocationInput').focus();
} else {
$scope.appInstall.state = 'resourceConstraint';
$('#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.domain = $scope.domains.find(function (d) { return $scope.config.adminDomain === d.domain; }); // pre-select the adminDomain
$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 ? 'groups' : 'any';
$scope.appInstall.accessRestriction = app.accessRestriction || { users: [], groups: [] };
var manifest = app.manifest;
$scope.appInstall.optionalSso = !!manifest.optionalSso;
$scope.appInstall.customAuth = !(manifest.addons['ldap'] || manifest.addons['oauth']);
$scope.appInstall.accessRestrictionOption = 'any';
// 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];
}
}
var finalAccessRestriction = null;
if ($scope.appInstall.accessRestrictionOption === 'groups') {
finalAccessRestriction = { users: [], groups: [] };
finalAccessRestriction.users = $scope.appInstall.accessRestriction.users.map(function (u) { return u.id; });
finalAccessRestriction.groups = $scope.appInstall.accessRestriction.groups.map(function (g) { return g.id; });
}
var data = {
location: $scope.appInstall.location || '',
domain: $scope.appInstall.domain.domain,
portBindings: finalPortBindings,
accessRestriction: finalAccessRestriction,
cert: $scope.appInstall.certificateFile,
key: $scope.appInstall.keyFile,
sso: !$scope.appInstall.optionalSso ? undefined : ($scope.appInstall.accessRestrictionOption !== 'nosso')
};
// add sso property for the postInstall message to be shown correctly
$scope.appInstall.app.sso = data.sso;
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 === 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;
$scope.appInstall.postInstall();
});
},
postInstall: function () {
if ($scope.appInstall.app.manifest.postInstallMessage) {
$scope.appInstall.state = 'postInstall';
$('#collapseInstallForm').collapse('hide');
$('#postInstallMessage').collapse('show');
return;
}
$scope.appInstall.switchToAppsView();
},
switchToAppsView: function () {
// 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.$apply(function () {
$location.path('/apps').search({ });
});
});
$('#appInstallModal').modal('hide');
}
};
$scope.appNotFound = {
appId: '',
version: ''
};
$scope.appstoreLogin = {
busy: false,
error: {},
email: '',
password: '',
register: true,
termsAccepted: false,
submit: function () {
$scope.appstoreLogin.error = {};
$scope.appstoreLogin.busy = true;
function login() {
AppStore.login($scope.appstoreLogin.email, $scope.appstoreLogin.password, function (error, result) {
if (error) {
$scope.appstoreLogin.busy = false;
if (error.statusCode === 403) {
$scope.appstoreLogin.error.password = 'Wrong email or password';
$scope.appstoreLogin.password = '';
$('#inputAppstoreLoginPassword').focus();
$scope.appstoreLoginForm.password.$setPristine();
} else {
console.error(error);
}
return;
}
var config = {
userId: result.userId,
token: result.accessToken
};
Client.setAppstoreConfig(config, function (error) {
if (error) {
$scope.appstoreLogin.busy = false;
if (error.statusCode === 406) {
if (error.message === 'wrong user') {
$scope.appstoreLogin.error.generic = 'Wrong cloudron.io account';
$scope.appstoreLogin.email = '';
$scope.appstoreLogin.password = '';
$scope.appstoreLoginForm.email.$setPristine();
$scope.appstoreLoginForm.password.$setPristine();
$('#inputAppstoreLoginEmail').focus();
} else {
console.error(error);
$scope.appstoreLogin.error.generic = 'Please retry later';
}
} else {
console.error(error);
}
return;
}
// check subscription right away after login
$scope.$parent.getSubscription();
fetchAppstoreConfig();
});
});
}
if (!$scope.appstoreLogin.register) return login();
AppStore.register($scope.appstoreLogin.email, $scope.appstoreLogin.password, function (error) {
if (error) {
$scope.appstoreLogin.busy = false;
if (error.statusCode === 409) {
$scope.appstoreLogin.error.email = 'An account with this email already exists';
$scope.appstoreLogin.password = '';
$scope.appstoreLoginForm.email.$setPristine();
$scope.appstoreLoginForm.password.$setPristine();
$('#inputAppstoreLoginEmail').focus();
} else {
console.error(error);
$scope.appstoreLogin.error.generic = 'Please retry later';
}
return;
}
login();
});
}
};
function getAppList(callback) {
AppStore.getApps(function (error, apps) {
if (error) return callback(error);
// ensure we have a tags property for further use
apps.forEach(function (app) {
if (!app.manifest.tags) app.manifest.tags = [];
});
return callback(null, apps);
});
}
// TODO does not support testing apps in search
$scope.search = function () {
if (!$scope.searchString) return $scope.showCategory(null, $scope.cachedCategory);
$scope.category = '';
AppStore.getAppsFast(function (error, apps) {
if (error) return $timeout($scope.search, 1000);
var token = $scope.searchString.toUpperCase();
$scope.apps = apps.filter(function (app) {
if (app.manifest.id.toUpperCase().indexOf(token) !== -1) return true;
if (app.manifest.title.toUpperCase().indexOf(token) !== -1) return true;
if (app.manifest.tagline.toUpperCase().indexOf(token) !== -1) return true;
if (app.manifest.tags.join().toUpperCase().indexOf(token) !== -1) return true;
if (app.manifest.description.toUpperCase().indexOf(token) !== -1) return true;
return false;
});
});
};
$scope.showCategory = function (event, category) {
if (!event) $scope.category = category;
else $scope.category = event.target.getAttribute('category');
$scope.cachedCategory = $scope.category;
$scope.ready = false;
AppStore.getAppsFast(function (error, apps) {
if (error) return $timeout($scope.showCategory.bind(null, event), 1000);
if (!$scope.category) {
$scope.apps = apps;
} else {
$scope.apps = apps.filter(function (app) {
return app.manifest.tags.some(function (tag) { return $scope.category === tag; });
});
}
$scope.ready = true;
document.getElementById('appstoreGrid').scrollIntoView();
});
};
document.getElementById('appInstallCertificateFileInput').onchange = function (event) {
$scope.$apply(function () {
$scope.appInstall.certificateFile = null;
$scope.appInstall.certificateFileName = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
$scope.appInstall.certificateFile = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
document.getElementById('appInstallKeyFileInput').onchange = function (event) {
$scope.$apply(function () {
$scope.appInstall.keyFile = null;
$scope.appInstall.keyFileName = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
$scope.appInstall.keyFile = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
$scope.showAppNotFound = function (appId, version) {
$scope.appNotFound.appId = appId;
$scope.appNotFound.version = version;
$('#appNotFoundModal').modal('show');
};
$scope.gotoApp = function (app) {
$location.path('/appstore/' + app.manifest.id, false).search({ version : app.manifest.version });
};
function hashChangeListener() {
// event listener is called from DOM not angular, need to use $apply
$scope.$apply(function () {
var appId = $location.path().slice('/appstore/'.length);
var version = $location.search().version;
if (appId) {
if (version) {
AppStore.getAppByIdAndVersion(appId, version, function (error, result) {
if (error) {
$scope.showAppNotFound(appId, version);
console.error(error);
return;
}
$scope.appInstall.show(result);
});
} else {
AppStore.getAppById(appId, function (error, result) {
if (error) {
$scope.showAppNotFound(appId, null);
console.error(error);
return;
}
$scope.appInstall.show(result);
});
}
} else {
$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 fetchAppstoreConfig(callback) {
callback = callback || function (error) { if (error) console.error(error); };
// caas always has a valid appstore account
if ($scope.config.provider === 'caas') {
$scope.validAppstoreAccount = true;
return callback();
}
Client.getAppstoreConfig(function (error, result) {
if (error) return callback(error);
if (!result.token || !result.cloudronId) return callback();
$scope.appstoreConfig = result;
AppStore.getCloudronDetails(result, function (error) {
if (error) return callback(error);
$scope.validAppstoreAccount = true;
// clear busy state when a login/signup was performed
$scope.appstoreLogin.busy = false;
callback();
});
});
}
function init() {
$scope.ready = false;
getAppList(function (error, apps) {
if (error) {
console.error(error);
return $timeout(init, 1000);
}
$scope.apps = apps;
fetchUsers();
fetchGroups();
// domains is required since we populate the dropdown with domains[0]
Client.getDomains(function (error, result) {
if (error) console.error(error);
$scope.domains = result;
// show install app dialog immediately if an app id was passed in the query
// hashChangeListener calls $apply, so make sure we don't double digest here
setTimeout(hashChangeListener, 1);
fetchAppstoreConfig(function (error) {
if (error) console.error(error);
$scope.ready = true;
setTimeout(function () { $('#appstoreSearch').focus(); }, 1000);
});
});
});
}
Client.onReady(init);
// note: do not use hide.bs.model since it is called immediately from switchToAppsView which is already in angular scope
$('#appInstallModal').on('hidden.bs.modal', function () {
// clear the appid and version in the search bar when dialog is cancelled
$scope.$apply(function () {
$location.path('/appstore', false).search({ }); // 'false' means do not reload
});
});
window.addEventListener('hashchange', hashChangeListener);
$scope.$on('$destroy', function handler() {
window.removeEventListener('hashchange', hashChangeListener);
});
// setup all the dialog focus handling
['appInstallModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
// autofocus if appstore login is shown
$scope.$watch('validAppstoreAccount', function (newValue, oldValue) {
if (!newValue) setTimeout(function () { $('[name=appstoreLoginForm]').find('[autofocus]:first').focus(); }, 1000);
});
$('.modal-backdrop').remove();
}]);
+231
View File
@@ -0,0 +1,231 @@
<div class="modal fade" id="domainConfigureModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title" ng-show="domainConfigure.adding">Add Domain</h4>
<h4 class="modal-title" ng-hide="domainConfigure.adding">Configure {{ domainConfigure.domain.domain }}</h4>
</div>
<div class="modal-body">
<form name="domainConfigureForm" role="form" novalidate ng-submit="domainConfigure.submit()" autocomplete="off">
<fieldset>
<p class="has-error text-center" ng-show="domainConfigure.error">{{ domainConfigure.error }}</p>
<div class="form-group" ng-class="{ 'has-error': domainConfigureForm.newDomain.$invalid }" ng-show="domainConfigure.adding">
<label class="control-label">Domain name</label>
<input type="text" class="form-control" ng-model="domainConfigure.newDomain" name="newDomain" ng-disabled="domainConfigure.busy" placeholder="example.com" ng-required="domainConfigure.adding" autofocus>
</div>
<div class="form-group">
<label class="control-label">DNS API provider</label>
<select class="form-control" ng-model="domainConfigure.provider" ng-options="a.value as a.name for a in dnsProvider"></select>
</div>
<!-- Route53 -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'route53'">
<label class="control-label">Access key id</label>
<input type="text" class="form-control" ng-model="domainConfigure.accessKeyId" name="accessKeyId" ng-disabled="domainConfigure.busy" ng-minlength="16" ng-maxlength="32" ng-required="domainConfigure.provider === 'route53'">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'route53'">
<label class="control-label">Secret access key</label>
<input type="text" class="form-control" ng-model="domainConfigure.secretAccessKey" name="secretAccessKey" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'route53'">
</div>
<!-- Google Cloud DNS -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'gcdns'">
<div class="input-group">
<input type="file" id="gcdnsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="domainConfigure.gcdnsKey.keyFileName" id="gcdnsKeyInput" name="cert" onclick="getElementById('gcdnsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'gcdns'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcdnsKeyFileInput').click();"></i>
</span>
</div>
</div>
<!-- DigitalOcean -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'digitalocean'">
<label class="control-label">DigitalOcean token</label>
<input type="text" class="form-control" ng-model="domainConfigure.digitalOceanToken" name="digitalOceanToken" ng-disabled="domainConfigure.busy" ng-required="domainConfigure.provider === 'digitalocean'">
</div>
<!-- Cloudflare -->
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare'">
<label class="control-label">Cloudflare token</label>
<input type="text" class="form-control" ng-model="domainConfigure.cloudflareToken" name="cloudflareToken" placeholder="Global API Key" ng-required="domainConfigure.provider === 'cloudflare'" ng-disabled="domainConfigure.busy">
</div>
<div class="form-group" ng-class="{ 'has-error': false }" ng-show="domainConfigure.provider === 'cloudflare'">
<label class="control-label">Cloudflare email</label>
<input type="email" class="form-control" ng-model="domainConfigure.cloudflareEmail" name="cloudflareEmail" placeholder="Cloudflare Account Email" ng-required="domainConfigure.provider === 'cloudflare'" ng-disabled="domainConfigure.busy">
</div>
<p ng-show="domainConfigure.provider === 'route53'">
This domain must be hosted on <a href="https://aws.amazon.com/route53/?nc2=h_m1" target="_blank">AWS Route53</a>.
</p>
<p ng-show="domainConfigure.provider === 'gcdns'">
This domain must be hosted on <a href="https://console.cloud.google.com/net-services/dns/zones" target="_blank">Google Cloud DNS</a>.
</p>
<p ng-show="domainConfigure.provider === 'digitalocean'">
This domain must be hosted on <a href="https://www.digitalocean.com/community/tutorials/how-to-set-up-a-host-name-with-digitalocean#step-two%E2%80%94change-your-domain-server" target="_blank">DigitalOcean</a>.
</p>
<p ng-show="domainConfigure.provider === 'cloudflare'">
This domain must be hosted on <a href="https://www.cloudflare.com" target="_blank">Cloudflare</a>.
</p>
<p ng-show="domainConfigure.provider === 'wildcard'">
Setup <i>A</i> records for <b>*.{{ domainConfigure.newDomain || domainConfigure.domain.domain }}</b> and <b>{{ domainConfigure.newDomain || domainConfigure.domain.domain }}</b> to this server's IP.
</p>
<p ng-show="domainConfigure.provider === 'manual'" class="text-info">
<b>All DNS records have to be setup manually before each app installation.</b>
</p>
<br/>
<!-- Wildcard certificates -->
<label class="control-label">Fallback Certificate (optional)</label>
<p>
Certificates are automatically obtained and renewed from <a href="https://letsencrypt.org/" target="_blank">Lets Encrypt</a>. See the current rate limit <a href="https://letsencrypt.org/docs/rate-limits/" target="_blank">here</a>.
If provided, this wildcard certificate will be used for apps, should getting a Lets Encrypt certificate fail.
</p>
<div class="form-group" ng-class="{ 'has-error': (!fallbackCert.cert.$dirty && fallbackCert.error) }">
<div class="input-group">
<input type="file" id="fallbackCertFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Certificate" ng-model="domainConfigure.fallbackCert.certificateFileName" name="cert" onclick="getElementById('fallbackCertFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('fallbackCertFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group" ng-class="{ 'has-error': (!fallbackCert.key.$dirty && fallbackCert.error) }">
<div class="input-group">
<input type="file" id="fallbackKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Key" ng-model="domainConfigure.fallbackCert.keyFileName" id="fallbackKeyInput" name="key" onclick="getElementById('fallbackKeyFileInput').click();" style="cursor: pointer;" ng-disabled="domainConfigure.busy">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('fallbackKeyFileInput').click();"></i>
</span>
</div>
</div>
<input class="ng-hide" type="submit" ng-disabled="domainConfigureForm.$invalid || domainConfigure.busy"/>
</fieldset>
</form>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="domainConfigure.submit()" ng-disabled="domainConfigureForm.$invalid || domainConfigure.busy">
<i class="fa fa-circle-o-notch fa-spin" ng-show="domainConfigure.busy"></i> Save
</button>
</div>
</div>
</div>
</div>
<!-- Modal domain migrate -->
<div class="modal fade" id="domainMigrateModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Migrate to {{ domainMigrate.domain.domain }} ?</h4>
</div>
<div class="modal-body">
<p>Moving to a custom domain will retain all your apps and data and will take around 15 minutes.</p>
<br/>
<fieldset>
<form role="form" name="domainMigrateForm" ng-submit="domainMigrate.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (domainMigrateForm.password.$dirty && domainMigrateForm.password.$invalid) || (!domainMigrateForm.password.$dirty && domainMigrate.error) }">
<label class="control-label">Provide your password to confirm this action</label>
<div class="control-label" ng-show="(domainMigrateForm.password.$dirty && domainMigrateForm.password.$invalid) || (!domainMigrateForm.password.$dirty && domainMigrate.error)">
<small ng-show=" domainMigrateForm.password.$dirty && domainMigrateForm.password.$invalid">Password required</small>
<small ng-show="!domainMigrateForm.password.$dirty && domainMigrate.error">{{ domainMigrate.error }}</small>
</div>
<input type="password" class="form-control" ng-model="domainMigrate.password" id="domainMigratePasswordInput" name="password" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="domainMigrateForm.$invalid || busy"/>
</form>
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="domainMigrate.submit()" ng-disabled="domainMigrateForm.$invalid || domainMigrate.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="domainMigrate.busy"></i> Migrate</button>
</div>
</div>
</div>
</div>
<!-- Modal domain remove -->
<div class="modal fade" id="domainRemoveModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Really remove {{ domainRemove.domain.domain }} ?</h4>
</div>
<div class="modal-body">
<fieldset>
<form role="form" name="domainRemoveForm" ng-submit="domainRemove.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (domainRemoveForm.password.$dirty && domainRemoveForm.password.$invalid) || (!domainRemoveForm.password.$dirty && domainRemove.error) }">
<label class="control-label">Provide your password to confirm this action</label>
<div class="control-label" ng-show="(domainRemoveForm.password.$dirty && domainRemoveForm.password.$invalid) || (!domainRemoveForm.password.$dirty && domainRemove.error)">
<small ng-show=" domainRemoveForm.password.$dirty && domainRemoveForm.password.$invalid">Password required</small>
<small ng-show="!domainRemoveForm.password.$dirty && domainRemove.error">{{ domainRemove.error }}</small>
</div>
<input type="password" class="form-control" ng-model="domainRemove.password" id="domainRemovePasswordInput" name="password" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="domainRemoveForm.$invalid || busy"/>
</form>
</fieldset>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="domainRemove.submit()" ng-disabled="domainRemoveForm.$invalid || domainRemove.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="domainRemove.busy"></i> Remove</button>
</div>
</div>
</div>
</div>
<div class="content">
<div class="text-left">
<h1>Domains <button class="btn btn-primary btn-outline pull-right" ng-click="domainConfigure.show()"><i class="fa fa-plus"></i> Add Domain</button></h1>
</div>
<div class="card card-large">
<div class="grid-item-top">
<div class="row ng-hide" ng-show="!ready">
<div class="col-lg-12 text-center">
<h2><i class="fa fa-circle-o-notch fa-spin"></i></h2>
</div>
</div>
<div class="row animateMeOpacity ng-hide" ng-show="ready">
<div class="col-lg-12">
<table class="table table-hover">
<thead>
<tr>
<th>Domain</th>
<th class="text-left hidden-xs hidden-sm">Provider</th>
<th style="width: 100px" class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="domain in domains">
<td class="elide-table-cell">
{{ domain.domain }}
</td>
<td class="text-left elide-table-cell hidden-xs hidden-sm">
{{ prettyProviderName(domain) }}
</td>
<td class="text-right no-wrap" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="domainMigrate.show(domain)" ng-show="domain.domain !== config.adminDomain && domain.provider !== 'caas' && provider === 'caas'" title="Migrate Domain"><i class="fa fa-exchange"></i></button>
<button class="btn btn-xs btn-default" ng-click="domainConfigure.show(domain)" ng-show="domain.provider !== 'caas'" title="Edit Domain"><i class="fa fa-pencil"></i></button>
<button class="btn btn-xs btn-danger" ng-click="domainRemove.show(domain)" ng-show="domain.provider !== 'caas'" title="Remove Domain"><i class="fa fa-trash-o"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
+328
View File
@@ -0,0 +1,328 @@
'use strict';
angular.module('Application').controller('DomainsController', ['$scope', '$location', 'Client', 'ngTld', function ($scope, $location, Client, ngTld) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.config = Client.getConfig();
$scope.dnsConfig = null;
$scope.domains = [];
$scope.ready = false;
$scope.tlsConfig = {
provider: $location.search().env === 'dev' ? 'letsencrypt-staging' : 'letsencrypt-prod'
};
// keep in sync with setupdns.js
$scope.dnsProvider = [
{ name: 'AWS Route53', value: 'route53' },
{ name: 'Cloudflare (DNS only)', value: 'cloudflare' },
{ name: 'Digital Ocean', value: 'digitalocean' },
{ name: 'Google Cloud DNS', value: 'gcdns' },
{ name: 'Wildcard', value: 'wildcard' },
{ name: 'Manual (not recommended)', value: 'manual' },
{ name: 'No-op (only for development)', value: 'noop' }
];
$scope.prettyProviderName = function (domain) {
switch (domain.provider) {
case 'caas': return 'Managed Cloudron';
case 'route53': return 'AWS Route53';
case 'cloudflare': return 'Cloudflare (DNS only)';
case 'digitalocean': return 'Digital Ocean';
case 'gcdns': return 'Google Cloud';
case 'manual': return domain.config.wildcard ? 'Wildcard' : 'Manual';
case 'noop': return 'No-op';
default: return 'Unknown';
}
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
// We reused configure also for adding domains to avoid much code duplication
$scope.domainConfigure = {
adding: false,
error: null,
busy: false,
domain: null,
// form model
newDomain: '',
accessKeyId: '',
secretAccessKey: '',
gcdnsKey: { keyFileName: '', content: '' },
digitalOceanToken: '',
cloudflareToken: '',
cloudflareEmail: '',
provider: 'route53',
fallbackCert: {
certificateFile: null,
certificateFileName: '',
keyFile: null,
keyFileName: ''
},
show: function (domain) {
$scope.domainConfigure.reset();
if (domain) {
$scope.domainConfigure.domain = domain;
$scope.domainConfigure.accessKeyId = domain.config.accessKeyId;
$scope.domainConfigure.secretAccessKey = domain.config.secretAccessKey;
$scope.domainConfigure.gcdnsKey.keyFileName = '';
$scope.domainConfigure.gcdnsKey.content = '';
if ($scope.domainConfigure.provider === 'gcdns') {
$scope.domainConfigure.gcdnsKey.keyFileName = domain.config.credentials && domain.config.credentials.client_email;
$scope.domainConfigure.gcdnsKey.content = JSON.stringify({
"project_id": domain.config.projectId,
"credentials": domain.config.credentials
});
}
$scope.domainConfigure.digitalOceanToken = domain.provider === 'digitalocean' ? domain.config.token : '';
$scope.domainConfigure.cloudflareToken = domain.provider === 'cloudflare' ? domain.config.token : '';
$scope.domainConfigure.cloudflareEmail = domain.config.email;
$scope.domainConfigure.provider = domain.provider;
$scope.domainConfigure.provider = ($scope.domainConfigure.provider === 'manual' && domain.config.wildcard) ? 'wildcard' : domain.provider;
} else {
$scope.domainConfigure.adding = true;
}
$('#domainConfigureModal').modal('show');
},
submit: function () {
$scope.domainConfigure.busy = true;
$scope.domainConfigure.error = null;
var provider = $scope.domainConfigure.provider;
var data = {
};
// special case the wildcard provider
if (provider === 'wildcard') {
provider = 'manual';
data.wildcard = true;
}
if (provider === 'route53') {
data.accessKeyId = $scope.domainConfigure.accessKeyId;
data.secretAccessKey = $scope.domainConfigure.secretAccessKey;
} else if (provider === 'gcdns'){
try {
var serviceAccountKey = JSON.parse($scope.domainConfigure.gcdnsKey.content);
data.projectId = serviceAccountKey.project_id;
data.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!data.projectId || !data.credentials || !data.credentials.client_email || !data.credentials.private_key) {
throw 'fields_missing';
}
} catch (e) {
$scope.domainConfigure.error = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.domainConfigure.busy = false;
return;
}
} else if (provider === 'digitalocean') {
data.token = $scope.domainConfigure.digitalOceanToken;
} else if (provider === 'cloudflare') {
data.token = $scope.domainConfigure.cloudflareToken;
data.email = $scope.domainConfigure.cloudflareEmail;
}
var fallbackCertificate = null;
if ($scope.domainConfigure.fallbackCert.certificateFile && $scope.domainConfigure.fallbackCert.keyFile) {
fallbackCertificate = {
cert: $scope.domainConfigure.fallbackCert.certificateFile,
key: $scope.domainConfigure.fallbackCert.keyFile
};
}
// choose the right api, since we reuse this for adding and configuring domains
var func;
if ($scope.domainConfigure.adding) func = Client.addDomain.bind(Client, $scope.domainConfigure.newDomain, provider, data, fallbackCertificate, $scope.tlsConfig);
else func = Client.updateDomain.bind(Client, $scope.domainConfigure.domain.domain, provider, data, fallbackCertificate, $scope.tlsConfig);
func(function (error) {
$scope.domainConfigure.busy = false;
if (error) {
$scope.domainConfigure.error = error.message;
return;
}
$('#domainConfigureModal').modal('hide');
$scope.domainConfigure.reset();
// reload the domains
Client.getDomains(function (error, result) {
if (error) return console.error(error);
$scope.domains = result;
});
});
},
reset: function () {
$scope.domainConfigure.adding = false;
$scope.domainConfigure.newDomain = '';
$scope.domainConfigure.busy = false;
$scope.domainConfigure.error = null;
$scope.domainConfigure.provider = '';
$scope.domainConfigure.accessKeyId = '';
$scope.domainConfigure.secretAccessKey = '';
$scope.domainConfigure.gcdnsKey.keyFileName = '';
$scope.domainConfigure.gcdnsKey.content = '';
$scope.domainConfigure.digitalOceanToken = '';
$scope.domainConfigure.cloudflareToken = '';
$scope.domainConfigure.cloudflareEmail = '';
$scope.domainConfigureForm.$setPristine();
$scope.domainConfigureForm.$setUntouched();
}
};
$scope.domainMigrate = {
busy: false,
error: null,
domain: null,
password: null,
show: function (domain) {
$scope.domainMigrate.reset();
$scope.domainMigrate.domain = domain;
$('#domainMigrateModal').modal('show');
},
submit: function () {
var setupDNSUrl = '/setupdns.html?admin_fqdn=my' + ($scope.domainMigrate.domain.provider === 'caas' ? '-' : '.') + $scope.domainMigrate.domain.domain;
$scope.domainMigrate.busy = true;
$scope.domainMigrate.error = null;
Client.setAdmin($scope.domainMigrate.domain.domain, $scope.domainMigrate.password, function (error) {
if (error && (error.statusCode === 403 || error.statusCode === 409)) {
$scope.domainMigrate.password = '';
$scope.domainMigrate.error = error.message;
$scope.domainMigrateForm.password.$setPristine();
$('#domainMigratePasswordInput').focus();
} else if (error) {
Client.error(error);
} else {
$('#domainMigrateModal').modal('hide');
$scope.domainMigrate.reset();
window.location.href = setupDNSUrl;
}
$scope.domainMigrate.busy = false;
});
},
reset: function () {
$scope.domainMigrate.busy = false;
$scope.domainMigrate.error = null;
$scope.domainMigrate.domain = null;
$scope.domainMigrate.password = '';
$scope.domainMigrateForm.$setPristine();
$scope.domainMigrateForm.$setUntouched();
}
};
$scope.domainRemove = {
busy: false,
error: null,
domain: null,
password: '',
show: function (domain) {
$scope.domainRemove.reset();
$scope.domainRemove.domain = domain;
$('#domainRemoveModal').modal('show');
},
submit: function () {
$scope.domainRemove.busy = true;
$scope.domainRemove.error = null;
Client.removeDomain($scope.domainRemove.domain.domain, $scope.domainRemove.password, function (error) {
if (error && (error.statusCode === 403 || error.statusCode === 409)) {
$scope.domainRemove.password = '';
$scope.domainRemove.error = error.message;
$scope.domainRemoveForm.password.$setPristine();
$('#domainRemovePasswordInput').focus();
} else if (error) {
Client.error(error);
} else {
$('#domainRemoveModal').modal('hide');
$scope.domainRemove.reset();
// reload the domains
Client.getDomains(function (error, result) {
if (error) return console.error(error);
$scope.domains = result;
});
}
$scope.domainRemove.busy = false;
});
},
reset: function () {
$scope.domainRemove.busy = false;
$scope.domainRemove.error = null;
$scope.domainRemove.domain = null;
$scope.domainRemove.password = '';
$scope.domainRemoveForm.$setPristine();
$scope.domainRemoveForm.$setUntouched();
}
};
Client.onReady(function () {
Client.getDomains(function (error, result) {
if (error) return console.error(error);
$scope.domains = result;
$scope.ready = true;
});
});
document.getElementById('gcdnsKeyFileInput').onchange = readFileLocally($scope.domainConfigure.gcdnsKey, 'content', 'keyFileName');
document.getElementById('fallbackCertFileInput').onchange = readFileLocally($scope.domainConfigure.fallbackCert, 'certificateFile', 'certificateFileName');
document.getElementById('fallbackKeyFileInput').onchange = readFileLocally($scope.domainConfigure.fallbackCert, 'keyFile', 'keyFileName');
// setup all the dialog focus handling
['domainConfigureModal', 'domainMigrateModal', 'domainRemoveModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
$('.modal-backdrop').remove();
}]);
+299
View File
@@ -0,0 +1,299 @@
<!-- Modal enable email -->
<div class="modal fade" id="enableEmailModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Cloudron Email Server</h4>
</div>
<div class="modal-body" ng-show="selectedDomain.provider === 'noop' || selectedDomain.provider === 'manual'">
No DNS provider is setup. Displayed DNS records will have to be setup manually.<br/>
</div>
<div class="modal-body" ng-hide="selectedDomain.provider === 'noop' || selectedDomain.provider === 'manual'">
Cloudron will setup Email related DNS records automatically.
If this domain is already configured to handle email with some other provider, it will <b>overwrite</b> those records.
Disabling Cloudron Email later will <b>not</b> put the old records back.
<br/><br/>
Status of DNS Records will show an error while DNS is propagating (~5 minutes).
<br/><br/>
<b>Be sure to enable user and group mailboxes for this domain from the <i>Users</i> view.</b>
<br/><br/>
</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="enableEmail()">I understand, enable</button>
</div>
</div>
</div>
</div>
<!-- Test email sent -->
<div class="modal fade" id="testEmailModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Send test email</h4>
</div>
<div class="modal-body">
<form name="testEmailForm" role="form" novalidate ng-submit="testEmail.submit()" autocomplete="off">
<fieldset>
<p class="has-error text-center" ng-show="testEmail.error">{{ testEmail.error.generic }}</p>
<p>This will send a test email from no-reply@{{selectedDomain.domain}} to the address below.</p>
<br/>
<div class="form-group" ng-class="{ 'has-error': testEmail.error.key }">
<label class="control-label" for="inputTestEmailKey">Email to</label>
<input type="text" class="form-control" ng-model="testEmail.mailTo" id="inputTestMailTo" name="mailTo" ng-disabled="testEmail.busy" placeholder="Email address" autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="testEmailForm.$invalid"/>
</fieldset>
</form>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="testEmail.submit()" ng-disabled="testEmail.$invalid || testEmail.busy">
<i class="fa fa-circle-o-notch fa-spin" ng-show="testEmail.busy"></i><span>Send</span>
</button>
</div>
</div>
</div>
</div>
<div ng-show="!ready" class="loading-banner">
<h1><i class="fa fa-circle-o-notch fa-spin"></i></h1>
</div>
<div class="content" ng-show="ready">
<div class="text-left">
<h1>
Email
<select class="form-control pull-right" style="display: inline-block; width: 200px;" ng-model="selectedDomain" ng-options="a.domain for a in domains" ng-change="refreshDomain()"></select>
</h1>
</div>
<div class="text-left">
<h3>IMAP and SMTP Server</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
Cloudron has a built-in <a ng-href="{{ config.webServerOrigin + '/documentation/email/' }}" target="_blank">email server</a> that allows users to send and receive email for your domain.
Apps can send emails regardless of this setting.
</div>
</div>
<div class="row" ng-show="selectedDomain.mailConfig.enabled">
<br/>
<div class="col-md-12">
<b><a href="" data-toggle="collapse" data-parent="#accordion" data-target="#mail_settings">Mail server settings for email clients</a></b>
<div id="mail_settings" class="panel-collapse collapse">
<br/>
<p><b>Incoming Mail (IMAP)</b><br/>Server: <span ng-click-select>{{config.mailFqdn}}</span><br/>Port: 993 (TLS)</p>
<p><b>Outgoing Mail (SMTP)</b><br/>Server: <span ng-click-select>{{config.mailFqdn}}</span><br/>Port: 587 (STARTTLS)</p>
<p><b>ManageSieve</b><br/>Server: <span ng-click-select>{{config.mailFqdn}}</span><br/>Port: 4190 (TLS)</p>
<p>Use <i>username</i>@{{ selectedDomain.domain }} and the Cloudron password to access mailboxes of this domain</p>
</div>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12" ng-show="selectedDomain.provider !== 'caas'">
<button ng-class="selectedDomain.mailConfig.enabled ? 'btn btn-danger' : 'btn btn-primary'" ng-click="toggleEmailEnabled()" ng-enabled="selectedDomain.mailConfig">{{ selectedDomain.mailConfig.enabled ? "Disable Email" : "Enable Email" }}</button>
</div>
<div class="col-md-12" ng-show="selectedDomain.provider === 'caas'">
<span class="text-danger text-bold">This feature requires the Cloudron to be on <a ng-href="{{ config.webServerOrigin + '/documentation/managed-hosting/#using-a-custom-domain' }}" target="_blank">custom domain</a>.</span>
</div>
</div>
</div>
<div class="text-left">
<h3>Outbound Mail Relay</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
Select the mail server through which Cloudron will send outbound mails:
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<div class="form-group">
<select class="form-control" style="width: 50%;" ng-model="mailRelay.preset" ng-options="a.name for a in mailRelayPresets track by a.provider" ng-change="mailRelay.presetChanged()"></select>
</div>
</div>
</div>
<div class="row" ng-show="mailRelay.preset.provider !== 'cloudron-smtp'">
<div class="col-md-6">
<div>
<form name="mailRelayForm" role="form" ng-submit="mailRelay.submit()" autocomplete="off" novalidate>
<div class="form-group" ng-class="{ 'has-error': (mailRelayForm.host.$dirty && mailRelayForm.host.$invalid) }">
<label class="control-label">SMTP Host</label>
<div class="control-label" ng-show="(!mailRelayForm.host.$dirty && mailRelay.error.host) || (mailRelayForm.host.$dirty && mailRelayForm.host.$invalid)">
<small ng-show="!mailRelayForm.host.$dirty && mailRelay.error.host">{{ mailRelay.error.host }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.host" name="host" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (mailRelayForm.port.$dirty && mailRelayForm.port.$invalid) }">
<label class="control-label">SMTP Port (STARTTLS)</label>
<div class="control-label" ng-show="(!mailRelayForm.port.$dirty && mailRelay.error.port) || (mailRelayForm.port.$dirty && mailRelayForm.port.$invalid)">
<small ng-show="!mailRelayForm.port.$dirty && mailRelay.error.port">{{ mailRelay.error.port }}</small>
</div>
<input type="number" class="form-control" ng-model="mailRelay.relay.port" name="port" required>
</div>
<!-- Postmark and Sendgrid -->
<div ng-show="isProvider('postmark-smtp') || isProvider('sendgrid-smtp')" class="form-group" ng-class="{ 'has-error': (mailRelayForm.serverApiToken.$dirty && mailRelayForm.serverApiToken.$invalid) }">
<label class="control-label">API Token/Key</label>
<div class="control-label" ng-show="(!mailRelayForm.serverApiToken.$dirty && mailRelay.error.serverApiToken) || (mailRelayForm.serverApiToken.$dirty && mailRelayForm.serverApiToken.$invalid)">
<small ng-show="!mailRelayForm.serverApiToken.$dirty && mailRelay.error.serverApiToken">{{ mailRelay.error.serverApiToken }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.serverApiToken" name="serverApiToken" ng-required="isProvider('postmark-smtp') || isProvider('sendgrid-smtp')">
</div>
<!-- Other -->
<div ng-show="!isProvider('postmark-smtp') && !isProvider('sendgrid-smtp')" class="form-group" ng-class="{ 'has-error': (mailRelayForm.username.$dirty && mailRelayForm.username.$invalid) }">
<label class="control-label">Username</label>
<div class="control-label" ng-show="(!mailRelayForm.username.$dirty && mailRelay.error.username) || (mailRelayForm.username.$dirty && mailRelayForm.username.$invalid)">
<small ng-show="!mailRelayForm.username.$dirty && mailRelay.error.username">{{ mailRelay.error.username }}</small>
</div>
<input type="text" class="form-control" ng-model="mailRelay.relay.username" name="username" ng-required="!isProvider('postmark-smtp') && !isProvider('sendgrid-smtp')">
</div>
<div ng-show="!isProvider('postmark-smtp') && !isProvider('sendgrid-smtp')" class="form-group" ng-class="{ 'has-error': (mailRelayForm.password.$dirty && mailRelayForm.password.$invalid) }">
<label class="control-label">Password</label>
<div class="control-label" ng-show="(!mailRelayForm.password.$dirty && mailRelay.error.password) || (mailRelayForm.password.$dirty && mailRelayForm.password.$invalid)">
<small ng-show="!mailRelayForm.password.$dirty && mailRelay.error.password">{{ mailRelay.error.password }}</small>
</div>
<input type="password" class="form-control" ng-model="mailRelay.relay.password" name="password" ng-required="!isProvider('postmark-smtp') && !isProvider('sendgrid-smtp')">
</div>
<input class="ng-hide" type="submit" ng-disabled="mailRelayForm.$invalid"/>
</form>
</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
<button class="btn btn-primary" ng-click="mailRelay.submit()" ng-disabled="(mailRelay.preset.provider !== 'cloudron-smtp' && (!mailRelayForm.$dirty || mailRelayForm.$invalid)) || mailRelay.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="mailRelay.busy"></i> Save</button>
<span class="has-error text-center" ng-show="mailRelay.error">{{ mailRelay.error }}</span>
<span class="text-success text-center text-bold" ng-show="mailRelay.success">Saved</span>
</div>
</div>
</div>
<div class="text-left" ng-show="selectedDomain.mailConfig.enabled">
<h3>Catch-all</h3>
</div>
<div class="card" style="margin-bottom: 15px;" ng-show="selectedDomain.mailConfig.enabled">
<div class="row">
<div class="col-md-12">
Emails sent to non existing addresses will be forwarded to the following accounts:
</div>
</div>
<br/>
<div class="row">
<div class="col-md-6">
<multiselect ng-model="catchall.addresses" options="address for address in catchall.availableAddresses" data-multiple="true"></multiselect>
<button class="btn btn-outline btn-primary" ng-disabled="catchall.busy" ng-click="catchall.submit()"><i class="fa fa-circle-o-notch fa-spin" ng-show="catchall.busy"></i> Save</button>
</div>
</div>
</div>
<div class="text-left" ng-show="selectedDomain.provider !== 'caas' && selectedDomain.mailConfig.relay.provider === 'cloudron-smtp'">
<h3>DNS Records</h3>
</div>
<div class="card" style="margin-bottom: 15px;" ng-show="selectedDomain.provider !== 'caas' && selectedDomain.mailConfig.relay.provider === 'cloudron-smtp'">
<div class="row">
<div class="col-md-12">
Set the following DNS records to guarantee email delivery:
<br/><br/>
<div ng-repeat="record in expectedDnsRecordsTypes">
<div class="row" ng-if="expectedDnsRecords[record.value] && (selectedDomain.mailConfig.enabled || (record.name !== 'DMARC' && record.name !== 'MX'))">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="expectedDnsRecords[record.value].status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_dns_{{ record.value }}">{{ record.name }} record</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!expectedDnsRecords[record.value].status"><i class="fa fa-refresh" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_dns_{{ record.value }}" class="panel-collapse collapse">
<div class="panel-body">
<p>Domain: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].domain }}</tt></b></p>
<p>Record type: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].type }}</tt></b></p>
<p style="overflow: auto; white-space: nowrap;">Expected value: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].expected }}</tt></b></p>
<p style="overflow: auto; white-space: nowrap;">Current value: <b ng-click-select><tt>{{ expectedDnsRecords[record.value].value ? expectedDnsRecords[record.value].value : '[not set]' }}</tt></b></p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="text-left" ng-show="selectedDomain.provider !== 'caas'">
<h3>SMTP Status</h3>
</div>
<div class="card" style="margin-bottom: 15px;" ng-show="selectedDomain.provider !== 'caas'">
<div class="row">
<div class="col-md-12">
<div class="row">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="selectedDomain.mailStatus.relay.status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_outbound_smtp">
{{ selectedDomain.mailConfig.relay.provider === 'cloudron-smtp' ? 'Outbound SMTP (Direct)' : 'Outbound SMTP (Relay)' }}
</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!selectedDomain.mailStatus.relay.status"><i class="fa fa-refresh" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_outbound_smtp" class="panel-collapse collapse">
<div class="panel-body">
<p><b> {{ selectedDomain.mailStatus.relay.value }} </b> </p>
</div>
</div>
</div>
</div>
<div class="row" ng-show="selectedDomain.mailConfig.relay.provider === 'cloudron-smtp'">
<div class="col-xs-12">
<p class="text-muted">
<i ng-hide="refreshBusy" ng-class="selectedDomain.mailStatus.rbl.status ? 'fa fa-check-circle text-success' : 'fa fa-exclamation-triangle text-danger'"></i> &nbsp;
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse_rbl">
IP Address Blacklist Check
</a>
<button class="btn btn-xs btn-default" ng-click="refreshStatus()" ng-disabled="refreshBusy" ng-show="!selectedDomain.mailStatus.rbl.status"><i class="fa fa-refresh" ng-class="{ 'fa-pulse': refreshBusy }"></i></button>
</p>
<div id="collapse_rbl" class="panel-collapse collapse">
<div class="panel-body">
<div>This server's IP {{ selectedDomain.mailStatus.rbl.ip }} is <b ng-hide="selectedDomain.mailStatus.rbl.servers.length">not</b> blacklisted.</div>
<div ng-repeat="server in selectedDomain.mailStatus.rbl.servers">
<a ng-href="{{server.site}}" target="_blank">{{ server.name }}</a>
</div>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<button class="btn btn-primary pull-left" ng-click="testEmail.show()">Send Test Email</button>
</div>
</div>
</div>
</div>
</div>
</div>
+343
View File
@@ -0,0 +1,343 @@
'use strict';
angular.module('Application').controller('EmailController', ['$scope', '$location', '$timeout', '$rootScope', 'Client', 'AppStore', function ($scope, $location, $timeout, $rootScope, Client, AppStore) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.ready = false;
$scope.refreshBusy = true;
$scope.client = Client;
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.domains = [];
$scope.users = [];
$scope.selectedDomain = null;
$scope.expectedDnsRecords = {
mx: { },
dkim: { },
spf: { },
dmarc: { },
ptr: { }
};
$scope.expectedDnsRecordsTypes = [
{ name: 'MX', value: 'mx' },
{ name: 'DKIM', value: 'dkim' },
{ name: 'SPF', value: 'spf' },
{ name: 'DMARC', value: 'dmarc' },
{ name: 'PTR', value: 'ptr' }
];
$scope.showView = function (view) {
// wait for dialog to be fully closed to avoid modal behavior breakage when moving to a different view already
$('.modal').on('hidden.bs.modal', function () {
$('.modal').off('hidden.bs.modal');
$location.path(view);
});
$('.modal').modal('hide');
};
$scope.isProvider = function (provider) {
return $scope.mailRelay.relay.provider === provider;
};
$scope.catchall = {
addresses: [],
availableAddresses: [],
busy: false,
submit: function () {
$scope.catchall.busy = true;
Client.setCatchallAddresses($scope.selectedDomain.domain, $scope.catchall.addresses, function (error) {
if (error) console.error('Unable to add catchall address.', error);
$scope.catchall.busy = false;
});
}
};
$scope.toggleEmailEnabled = function () {
if ($scope.selectedDomain.mailConfig.enabled) {
$scope.disableEmail();
$scope.refreshDomain();
return;
}
// show warning first
$('#enableEmailModal').modal('show');
};
$scope.enableEmail = function () {
$('#enableEmailModal').modal('hide');
Client.enableMailForDomain($scope.selectedDomain.domain, true , function (error) {
if (error) return console.error(error);
$scope.refreshDomain();
});
};
$scope.disableEmail = function () {
Client.enableMailForDomain($scope.selectedDomain.domain, false , function (error) {
if (error) return console.error(error);
$scope.refreshDomain();
});
};
$scope.mailRelayPresets = [
{ provider: 'cloudron-smtp', name: 'Built-in SMTP server' },
{ provider: 'external-smtp', name: 'External SMTP server', host: '', port: 587 },
{ provider: 'ses-smtp', name: 'Amazon SES', host: 'email-smtp.us-east-1.amazonaws.com', port: 587 },
{ provider: 'google-smtp', name: 'Google', host: 'smtp.gmail.com', port: 587 },
{ provider: 'mailgun-smtp', name: 'Mailgun', host: 'smtp.mailgun.org', port: 587 },
{ provider: 'postmark-smtp', name: 'Postmark', host: 'smtp.postmarkapp.com', port: 587 },
{ provider: 'sendgrid-smtp', name: 'SendGrid', host: 'smtp.sendgrid.net', port: 587, username: 'apikey' },
];
$scope.mailRelay = {
error: null,
success: false,
busy: false,
preset: $scope.mailRelayPresets[0],
presetChanged: function () {
$scope.mailRelay.error = null;
$scope.mailRelay.relay.provider = $scope.mailRelay.preset.provider;
$scope.mailRelay.relay.host = $scope.mailRelay.preset.host;
$scope.mailRelay.relay.port = $scope.mailRelay.preset.port;
$scope.mailRelay.relay.username = '';
$scope.mailRelay.relay.password = '';
$scope.mailRelay.relay.serverApiToken = '';
},
// form data to be set on load
relay: {
provider: 'cloudron-smtp',
host: '',
port: 25,
username: '',
password: '',
serverApiToken: ''
},
submit: function () {
$scope.mailRelay.error = null;
$scope.mailRelay.busy = true;
$scope.mailRelay.success = false;
var data = {
provider: $scope.mailRelay.relay.provider,
host: $scope.mailRelay.relay.host,
port: $scope.mailRelay.relay.port
};
// fill in provider specific username/password usage
if (data.provider === 'postmark-smtp') {
data.username = $scope.mailRelay.relay.serverApiToken;
data.password = $scope.mailRelay.relay.serverApiToken;
} else if (data.provider === 'sendgrid-smtp') {
data.username = 'apikey';
data.password = $scope.mailRelay.relay.serverApiToken;
} else {
data.username = $scope.mailRelay.relay.username;
data.password = $scope.mailRelay.relay.password;
}
Client.setMailRelay($scope.selectedDomain.domain, data, function (error) {
$scope.mailRelay.busy = false;
if (error) {
$scope.mailRelay.error = error.message;
return;
}
$scope.selectedDomain.relay = data;
$scope.mailRelay.success = true;
$scope.refreshDomain();
// clear success indicator after 3sec
$timeout(function () { $scope.mailRelay.success = false; }, 3000);
});
}
};
$scope.testEmail = {
busy: false,
error: {},
mailTo: '',
domain: null,
clearForm: function () {
$scope.testEmail.mailTo = '';
},
show: function () {
$scope.testEmail.error = {};
$scope.testEmail.busy = false;
$scope.testEmail.domain = $scope.selectedDomain;
$scope.testEmail.mailTo = $scope.user.email;
$('#testEmailModal').modal('show');
},
submit: function () {
$scope.testEmail.error = {};
$scope.testEmail.busy = true;
Client.sendTestMail($scope.selectedDomain.domain, $scope.testEmail.mailTo, function (error) {
$scope.testEmail.busy = false;
if (error) {
$scope.testEmail.error.generic = error.message;
console.error(error);
$('#inputTestMailTo').focus();
return;
}
$('#testEmailModal').modal('hide');
});
}
};
function collapseDnsRecords() {
$scope.expectedDnsRecordsTypes.forEach(function (record) {
var type = record.value;
$('#collapse_dns_' + type).collapse('hide');
});
$('#collapse_outbound_smtp').collapse('hide');
$('#collapse_rbl').collapse('hide');
}
function showExpectedDnsRecords() {
// open the record details if they are not correct
$scope.expectedDnsRecordsTypes.forEach(function (record) {
var type = record.value;
$scope.expectedDnsRecords[type] = $scope.selectedDomain.mailStatus.dns[type] || {};
if (!$scope.expectedDnsRecords[type].status) {
$('#collapse_dns_' + type).collapse('show');
}
});
if (!$scope.selectedDomain.mailStatus.relay.status) {
$('#collapse_outbound_smtp').collapse('show');
}
if (!$scope.selectedDomain.mailStatus.rbl.status) {
$('#collapse_rbl').collapse('show');
}
}
$scope.refreshDomain = function () {
$scope.refreshBusy = true;
collapseDnsRecords();
Client.getMailConfigForDomain($scope.selectedDomain.domain, function (error, mailConfig) {
if (error) {
$scope.refreshBusy = false;
return console.error(error);
}
// pre-fill the form
$scope.mailRelay.relay.provider = mailConfig.relay.provider;
$scope.mailRelay.relay.host = mailConfig.relay.host;
$scope.mailRelay.relay.port = mailConfig.relay.port;
$scope.mailRelay.relay.username = '';
$scope.mailRelay.relay.password = '';
$scope.mailRelay.relay.serverApiToken = '';
if (mailConfig.relay.provider === 'postmark-smtp') {
$scope.mailRelay.relay.serverApiToken = mailConfig.relay.username;
} else if (mailConfig.relay.provider === 'sendgrid-smtp') {
$scope.mailRelay.relay.serverApiToken = mailConfig.relay.password;
} else {
$scope.mailRelay.relay.username = mailConfig.relay.username;
$scope.mailRelay.relay.password = mailConfig.relay.password;
}
for (var i = 0; i < $scope.mailRelayPresets.length; i++) {
if ($scope.mailRelayPresets[i].provider === mailConfig.relay.provider) {
$scope.mailRelay.preset = $scope.mailRelayPresets[i];
break;
}
}
// catch-all, only allow users with a Cloudron email address
$scope.catchall.availableAddresses = $scope.users.filter(function (u) { return !!u.username && !!u.email; }).map(function (u) { return u.username; });
// dedupe in case to avoid angular breakage
$scope.catchall.addresses = mailConfig.catchAll.filter(function(item, pos, self) {
return self.indexOf(item) == pos;
});
// amend to selected domain to be available for the UI
$scope.selectedDomain.mailConfig = mailConfig;
$scope.selectedDomain.mailStatus = {};
// we will fetch the status without blocking the ui
Client.getMailStatusForDomain($scope.selectedDomain.domain, function (error, mailStatus) {
$scope.refreshBusy = false;
if (error) return console.error(error);
$scope.selectedDomain.mailStatus = mailStatus;
showExpectedDnsRecords();
});
});
};
$scope.refreshStatus = function () {
$scope.refreshBusy = true;
Client.getMailStatusForDomain($scope.selectedDomain.domain, function (error, mailStatus) {
if (error) {
$scope.refreshBusy = false;
return console.error(error);
}
// overwrite the selected domain status to be available for the UI
$scope.selectedDomain.mailStatus = mailStatus;
showExpectedDnsRecords();
$scope.refreshBusy = false;
});
};
Client.onReady(function () {
Client.getUsers(function (error, users) {
if (error) return console.error('Unable to get user listing.', error);
$scope.users = users;
Client.getDomains(function (error, domains) {
if (error) return console.error('Unable to get domain listing.', error);
$scope.domains = domains;
$scope.selectedDomain = $scope.domains[0];
$scope.refreshDomain();
$scope.ready = true;
});
});
});
// setup all the dialog focus handling
['testEmailModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
$('.modal-backdrop').remove();
}]);
+52
View File
@@ -0,0 +1,52 @@
<div class="content content-large">
<div class="text-left">
<h2>Memory</h2>
</div>
<div class="card card-large text-center">
<div class="row">
<div class="col-md-6">
<h3>Apps</h3>
<canvas id="memoryUsageAppsChart" width="200" height="200"></canvas>
</div>
<div class="col-md-6">
<h3>System</h3>
<canvas id="memoryUsageSystemChart" width="200" height="200"></canvas>
</div>
</div>
<br/>
<div class="row">
<div class="col-md-12">
<h4 ng-show="activeApp === 'system'">System</h4>
<h4 ng-show="activeApp !== 'system'">{{ activeApp.location }}</h4>
<br/>
<canvas id="memoryAppChart" width="900" height="300"></canvas>
<p>Memory consumption in MB.</p>
</div>
</div>
</div>
<br/>
<div class="text-left">
<h2>Disk Usage</h2>
</div>
<div class="card card-large text-center">
<div class="row">
<div class="col-md-12">
<h4><span class="badge">{{ diskUsage['system'].sum }} GB</span></h4>
<canvas id="systemDiskUsageChart" width="200" height="200"></canvas>
<p>
<span class="text-success">Free {{ diskUsage['system'].free }} GB</span>
&nbsp;
<span class="text-primary">Used {{ diskUsage['system'].used }} GB</span>
</p>
</div>
</div>
</div>
</div>
+252
View File
@@ -0,0 +1,252 @@
/* global Chart:true */
'use strict';
angular.module('Application').controller('GraphsController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.diskUsage = {};
$scope.memoryUsageSystem = [];
$scope.memoryUsageApps = [];
$scope.activeApp = null;
$scope.memoryChart = null;
$scope.installedApps = Client.getInstalledApps();
function bytesToGigaBytes(value) {
return (value/1024/1024/1024).toFixed(2);
}
function bytesToMegaBytes(value) {
return (value/1024/1024).toFixed(2);
}
// http://stackoverflow.com/questions/1484506/random-color-generator-in-javascript
function getRandomColor() {
var letters = '0123456789ABCDEF'.split('');
var color = '#';
for (var i = 0; i < 6; i++ ) {
color += letters[Math.floor(Math.random() * 16)];
}
return color;
}
function renderDisk(type, free, reserved, used) {
// this will mismatch df output since df -H is SI units (1000)
$scope.diskUsage[type] = {
used: bytesToGigaBytes(used.datapoints[0][0] + reserved.datapoints[0][0]),
free: bytesToGigaBytes(free.datapoints[0][0]),
sum: bytesToGigaBytes(used.datapoints[0][0] + reserved.datapoints[0][0] + free.datapoints[0][0])
};
var tmp = [{
value: $scope.diskUsage[type].used,
color: "#2196F3",
highlight: "#82C4F8",
label: "Used"
}, {
value: $scope.diskUsage[type].free,
color:"#27CE65",
highlight: "#76E59F",
label: "Free"
}];
var ctx = $('#' + type + 'DiskUsageChart').get(0).getContext('2d');
var myChart = new Chart(ctx);
myChart.Doughnut(tmp);
}
$scope.setMemoryApp = function (app, color) {
$scope.activeApp = app;
var timePeriod = 12 * 60; // in minutes
var timeBucketSize = 60; // in minutes
var target;
if (app === 'system') target = 'summarize(collectd.localhost.memory.memory-used, "' + timeBucketSize + 'min", "avg")';
else target = 'summarize(collectd.localhost.table-' + app.id + '-memory.gauge-rss, "' + timeBucketSize + 'min", "avg")';
Client.graphs([target], '-' + timePeriod + 'min', function (error, result) {
if (error) return console.log(error);
// translate the data from bytes to MB
var data = result[0].datapoints.map(function (d) { return parseInt((d[0] / 1024 / 1024).toFixed(2)); });
var labels = data.map(function (d, index) {
var dateTime = new Date(Date.now() - ((timePeriod - (index * timeBucketSize)) * 60 *1000));
return ('0' + dateTime.getHours()).slice(-2) + ':00';
});
var tmp = {
labels: labels,
datasets: [{
label: 'Memory',
fillColor: color || "#82C4F8",
strokeColor: color || "#2196F3",
pointColor: color || "rgba(151,187,205,1)",
pointStrokeColor: "#ffffff",
pointHighlightFill: color || "#82C4F8",
pointHighlightStroke: color || "#82C4F8",
data: data
}]
};
var ctx = $('#memoryAppChart').get(0).getContext('2d');
var chart = new Chart(ctx);
var scaleStepWidth;
if ($scope.activeApp === 'system') {
console.log(Client.getConfig().memory);
scaleStepWidth = Math.round(Client.getConfig().memory / (1024 * 1024) / 10); // scaleSteps is 10
} else {
var memoryLimit = $scope.activeApp.memoryLimit || $scope.activeApp.manifest.memoryLimit || (256 * 1024 * 1024);
scaleStepWidth = Math.round(memoryLimit / (1024 * 1024) / 10); // scaleSteps is 10
}
var options = {
scaleOverride: true,
scaleSteps: 10,
scaleStepWidth: scaleStepWidth,
scaleStartValue: 0
};
if ($scope.memoryChart) $scope.memoryChart.destroy();
$scope.memoryChart = chart.Line(tmp, options);
});
};
$scope.updateDiskGraphs = function () {
// https://graphite.readthedocs.io/en/latest/render_api.html#paths-and-wildcards
// on scaleway, for some reason docker devices are collected as part of collectd
// until we figure why just hardcode popular disk devices - https://www.mjmwired.net/kernel/Documentation/devices.txt
Client.disks(function (error, disks) {
if (error) return console.log(error);
// /dev/sda1 -> sda1
// /dev/mapper/foo -> mapper_foo (see #348)
var appDataDiskName = disks.appsDataDisk.slice(disks.appsDataDisk.indexOf('/', 1) + 1)
appDataDiskName = appDataDiskName.replace(/\//g, '_');
Client.graphs([
'absolute(collectd.localhost.df-' + appDataDiskName + '.df_complex-free)',
'absolute(collectd.localhost.df-' + appDataDiskName + '.df_complex-reserved)',
'absolute(collectd.localhost.df-' + appDataDiskName + '.df_complex-used)'
], '-1min', function (error, data) {
if (error) return console.log(error);
renderDisk('system', data[0], data[1], data[2]);
});
});
};
$scope.updateMemorySystemChart = function () {
var targets = [];
var targetsInfo = [];
targets.push('summarize(collectd.localhost.memory.memory-used, "1min", "avg")');
targetsInfo.push({ label: 'System', color: '#2196F3' });
targets.push('summarize(sum(collectd.localhost.memory.memory-buffered, collectd.localhost.memory.memory-cached), "1min", "avg")');
targetsInfo.push({ label: 'Cached', color: '#f0ad4e' });
targets.push('summarize(collectd.localhost.memory.memory-free, "1min", "avg")');
targetsInfo.push({ label: 'Free', color: '#27CE65' });
Client.graphs(targets, '-1min', function (error, result) {
if (error) return console.log(error);
$scope.memoryUsageSystem = result.map(function (data, index) {
return {
value: bytesToMegaBytes(data.datapoints[0][0]),
color: targetsInfo[index].color,
highlight: targetsInfo[index].color,
label: targetsInfo[index].label
};
});
var ctx = $('#memoryUsageSystemChart').get(0).getContext('2d');
var chart = new Chart(ctx).Doughnut($scope.memoryUsageSystem);
$('#memoryUsageSystemChart').get(0).onclick = function (event) {
$scope.setMemoryApp('system');
};
});
};
// poor man's async
function asyncForEach(items, handler, callback) {
var cur = 0;
if (items.length === 0) return callback();
(function iterator() {
handler(items[cur], function () {
if (cur >= items.length-1) return callback();
++cur;
iterator();
});
})();
}
$scope.updateMemoryAppsChart = function () {
var targets = [];
var targetsInfo = [];
$scope.installedApps.forEach(function (app) {
targets.push('summarize(collectd.localhost.table-' + app.id + '-memory.gauge-rss, "1min", "avg")');
targetsInfo.push({
label: app.location || 'bare domain',
color: getRandomColor(),
app: app
});
});
// we split up the request, to avoid too large query strings into graphite
var tmp = [];
var aggregatedResult= [];
while (targets.length > 0) tmp.push(targets.splice(0, 10));
asyncForEach(tmp, function (targets, callback) {
Client.graphs(targets, '-1min', function (error, result) {
if (error) return callback(error);
aggregatedResult = aggregatedResult.concat(result);
callback(null);
});
}, function (error) {
if (error) return console.log(error);
$scope.memoryUsageApps = aggregatedResult.map(function (data, index) {
return {
value: bytesToMegaBytes(data.datapoints[0][0]),
color: targetsInfo[index].color,
highlight: targetsInfo[index].color,
label: targetsInfo[index].label
};
});
var ctx = $('#memoryUsageAppsChart').get(0).getContext('2d');
var chart = new Chart(ctx).Doughnut($scope.memoryUsageApps);
$('#memoryUsageAppsChart').get(0).onclick = function (event) {
var activeBars = chart.getSegmentsAtEvent(event);
// dismiss non chart clicks
if (!activeBars || !activeBars[0]) return;
// try to find the app for this segment
var selectedDataInfo = targetsInfo.filter(function (info) { return info.label === activeBars[0].label; })[0];
if (selectedDataInfo) $scope.setMemoryApp(selectedDataInfo.app, selectedDataInfo.color);
};
});
};
Client.onReady($scope.updateDiskGraphs);
Client.onReady($scope.updateMemorySystemChart);
Client.onReady($scope.updateMemoryAppsChart);
Client.onReady($scope.setMemoryApp.bind(null, 'system'));
$('.modal-backdrop').remove();
}]);
+499
View File
@@ -0,0 +1,499 @@
<!-- Modal change avatar -->
<div class="modal fade" id="avatarChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change your Cloudron Avatar</h4>
</div>
<div class="modal-body settings-avatar-selector">
<img id="previewAvatar" width="128" height="128" ng-src="{{avatarChange.avatar.data || avatarChange.avatar.url || client.avatar}}"/>
<input type="file" id="avatarFileInput" style="display: none" accept="image/png"/>
<br/>
<br/>
<div class="grid">
<div class="item" ng-repeat="avatar in avatarChange.availableAvatars" style="background-image: url('{{avatar.data || avatar.url}}');" ng-click="avatarChange.setPreviewAvatar(avatar)"></div>
<div class="item add" ng-click="avatarChange.showCustomAvatarSelector()"></div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-success" ng-click="avatarChange.doChangeAvatar()" ng-disabled="avatarChange.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="avatarChange.busy"></i> Change</button>
</div>
</div>
</div>
</div>
<!-- Modal change cloudron name -->
<div class="modal fade" id="cloudronNameChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Change Cloudron Name</h4>
</div>
<div class="modal-body">
<form name="cloudronNameChangeForm" role="form" novalidate ng-submit="cloudronNameChange.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (cloudronNameChangeForm.name.$dirty && cloudronNameChangeForm.name.$invalid) }">
<label class="control-label">Name</label>
<div class="control-label" ng-show="(!cloudronNameChangeForm.name.$dirty && cloudronNameChange.error.name) || (cloudronNameChangeForm.name.$dirty && cloudronNameChangeForm.name.$invalid)">
<small ng-show="cloudronNameChangeForm.name.$error.required">A name is required</small>
<small ng-show="cloudronNameChangeForm.name.$error.maxlength">The name is too long</small>
<small ng-show="!cloudronNameChangeForm.name.$dirty && cloudronNameChange.error.name">{{ cloudronNameChange.error.name }}</small>
</div>
<input type="text" class="form-control" ng-model="cloudronNameChange.name" name="name" id="inputCloudronName" ng-maxlength="30" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="cloudronNameChangeForm.$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="cloudronNameChange.submit()" ng-disabled="cloudronNameChangeForm.$invalid || cloudronNameChange.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="cloudronNameChange.busy"></i> Change</button>
</div>
</div>
</div>
</div>
<!-- Modal backup failed -->
<div class="modal fade" id="createBackupFailedModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Unable to create backup</h4>
</div>
<div class="modal-body">
{{ createBackup.errorMessage }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-success" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<!-- Modal plan change -->
<div class="modal fade" id="planChangeModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close" data-dismiss="modal">&times;</button>
<h4 class="modal-title">Cloudron Change Plan</h4>
</div>
<div class="modal-body">
This will change your plan from <b>{{ currentPlan.name }}</b> to <b>{{ planChange.requestedPlan.name }}</b>.
<br/>
<br/>
Your apps and data will be migrated to the new Cloudron and will take around 15 minutes.
<br/>
<br/>
<br/>
<form name="planChangeForm" role="form" novalidate ng-submit="planChange.doChangePlan(planChangeForm)" autocomplete="off">
<fieldset>
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (!planChangeForm.password.$dirty && planChange.error.password) || (planChangeForm.password.$dirty && planChangeForm.password.$invalid) }">
<label class="control-label">Give your password to verify that you are performing that action</label>
<div class="control-label" ng-show="(!planChangeForm.password.$dirty && planChange.error.password) || (planChangeForm.password.$dirty && planChangeForm.password.$invalid)">
<small ng-show=" planChangeForm.password.$dirty && planChangeForm.password.$invalid">A password is required</small>
<small ng-show="!planChangeForm.password.$dirty && planChange.error.password">Wrong password</small>
</div>
<input type="password" class="form-control" ng-model="planChange.password" id="inputPlanChangePassword" name="password" required autofocus>
</div>
<input class="ng-hide" type="submit" ng-disabled="planChangeForm.$invalid"/>
</fieldset>
</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="planChange.doChangePlan()" ng-disabled="planChange.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="planChange.busy"></i> Confirm</button>
</div>
</div>
</div>
</div>
<!-- modal backup config -->
<div class="modal fade" id="configureBackupModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Configure Backup Storage</h4>
</div>
<div class="modal-body">
<p>Cloudron makes a complete backup of your system every day.</p>
<form name="configureBackupForm" role="form" novalidate ng-submit="configureBackup.submit()" autocomplete="off">
<fieldset>
<p class="has-error text-center" ng-show="configureBackup.error">{{ configureBackup.error.generic }}</p>
<div class="form-group">
<label class="control-label" for="storageProviderProvider">Storage provider <sup><a ng-href="{{ config.webServerOrigin }}/documentation/backups/" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup></label>
<select class="form-control" id="storageProviderProvider" ng-model="configureBackup.provider" ng-options="a.value as a.name for a in storageProvider" ng-change=configureBackup.clearForm()></select>
</div>
<!-- Noop -->
<div class="form-group" ng-show="configureBackup.provider === 'noop'">
<p class="has-error">
This option breaks the backup and restore functionality of Cloudron and should only be used for testing. Please make sure the server is completely backed up using alternate means.
</p>
</div>
<!-- Filesystem -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.backupFolder }" ng-show="configureBackup.provider === 'filesystem'">
<label class="control-label" for="inputConfigureBackupFolder">Local backup directory</label>
<input type="text" class="form-control" ng-model="configureBackup.backupFolder" id="inputConfigureBackupFolder" name="backupFolder" ng-disabled="configureBackup.busy" placeholder="Directory for backups" ng-required="configureBackup.provider === 'filesystem'">
<p class="has-error" ng-show="configureBackup.provider === 'filesystem'">
Please ensure that the backup directory is an external ext4 disk
</p>
</div>
<div class="checkbox" ng-show="configureBackup.provider === 'filesystem'">
<label>
<input type="checkbox" ng-model="configureBackup.useHardlinks" id="inputConfigureUseHardlinks">
Use hardlinks
</input>
</label>
</div>
<!-- S3/Minio/SOS/GCS -->
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.endpoint }" ng-show="configureBackup.provider === 'minio' || configureBackup.provider === 's3-v4-compat'">
<label class="control-label" for="inputConfigureBackupEndpoint">Endpoint</label>
<input type="text" class="form-control" ng-model="configureBackup.endpoint" id="inputConfigureBackupEndpoint" name="endpoint" ng-disabled="configureBackup.busy" placeholder="URL of Minio/S3 Compatible" ng-required="configureBackup.provider === 'minio' || configureBackup.provider === 's3-v4-compat'">
</div>
<div class="checkbox" ng-show="configureBackup.provider === 'minio' || configureBackup.provider === 's3-v4-compat'" >
<label>
<input type="checkbox" ng-model="configureBackup.acceptSelfSignedCerts" id="inputConfigureBackupSelfSigned">
Accept Self-signed certificate
</input>
</label>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.bucket }" ng-show="s3like(configureBackup.provider) || configureBackup.provider === 'gcs'">
<label class="control-label" for="inputConfigureBackupBucket">Bucket name</label>
<input type="text" class="form-control" ng-model="configureBackup.bucket" id="inputConfigureBackupBucket" name="bucket" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.prefix }" ng-show="configureBackup.provider !== 'filesystem'">
<label class="control-label" for="inputConfigureBackupPrefix">Prefix</label>
<input type="text" class="form-control" ng-model="configureBackup.prefix" id="inputConfigureBackupPrefix" name="prefix" ng-disabled="configureBackup.busy" placeholder="Prefix for backup file names">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 's3'">
<label class="control-label" for="inputConfigureBackupRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupRegion" ng-model="configureBackup.region" ng-options="a.value as a.name for a in s3Regions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 's3'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.region }" ng-show="configureBackup.provider === 'digitalocean-spaces'">
<label class="control-label" for="inputConfigureBackupRegion">Region</label>
<select class="form-control" name="region" id="inputConfigureBackupRegion" ng-model="configureBackup.endpoint" ng-options="a.value as a.name for a in doSpacesRegions" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'digitalocean-spaces'"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.accessKeyId }" ng-show="s3like(configureBackup.provider)">
<label class="control-label" for="inputConfigureBackupAccessKeyId">Access key id</label>
<input type="text" class="form-control" ng-model="configureBackup.accessKeyId" id="inputConfigureBackupAccessKeyId" name="accessKeyId" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.secretAccessKey }" ng-show="s3like(configureBackup.provider)">
<label class="control-label" for="inputConfigureBackupSecretAccessKey">Secret access key</label>
<input type="text" class="form-control" ng-model="configureBackup.secretAccessKey" id="inputConfigureBackupSecretAccessKey" name="secretAccessKey" ng-disabled="configureBackup.busy" ng-required="s3like(configureBackup.provider)">
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.gcsKeyInput }" ng-show="configureBackup.provider === 'gcs'">
<label class="control-label" for="gcsKeyInput">Service Account Key</label>
<div class="input-group">
<input type="file" id="gcsKeyFileInput" style="display:none"/>
<input type="text" class="form-control" placeholder="Service Account Key" ng-model="configureBackup.gcsKey.keyFileName" id="gcsKeyInput" name="cert" onclick="getElementById('gcsKeyFileInput').click();" style="cursor: pointer;" ng-disabled="configureBackup.busy" ng-required="configureBackup.provider === 'gcs'">
<span class="input-group-addon">
<i class="fa fa-upload" onclick="getElementById('gcsKeyFileInput').click();"></i>
</span>
</div>
</div>
<div class="form-group" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="storageFormat">Storage Format</label>
<select class="form-control" id="storageFormat" ng-change="configureBackup.key = ''" ng-model="configureBackup.format" ng-options="a.value as a.name for a in formats"></select>
</div>
<div class="form-group" ng-show="configureBackup.provider !== 'noop'">
<label class="control-label" for="storageRetention">Retention Time</label>
<select class="form-control" id="storageRetention" ng-model="configureBackup.retentionSecs" ng-options="a.value as a.name for a in retentionTimes"></select>
</div>
<div class="form-group" ng-class="{ 'has-error': configureBackup.error.key }" ng-show="configureBackup.provider !== 'noop' && configureBackup.format === 'tgz'">
<label class="control-label" for="inputConfigureBackupKey">Encryption key (optional)</label>
<input type="text" class="form-control" ng-model="configureBackup.key" id="inputConfigureBackupKey" name="prefix" ng-disabled="configureBackup.busy" placeholder="Passphrase used to encrypt the backups">
</div>
<input class="ng-hide" type="submit" ng-disabled="configureBackupForm.$invalid"/>
</fieldset>
</form>
</div>
<div class="modal-footer ">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-outline btn-success pull-right" ng-click="configureBackup.submit()" ng-disabled="configureBackupForm.$invalid || configureBackup.busy">
<i class="fa fa-circle-o-notch fa-spin" ng-show="configureBackup.busy"></i><span>Save</span>
</button>
</div>
</div>
</div>
</div>
<div class="content">
<div class="text-left">
<h1>Settings</h1>
</div>
<div class="text-left">
<h3>About</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-xs-4" style="min-width: 150px;">
<div class="settings-avatar" ng-click="avatarChange.showChangeAvatar()" style="background-image: url('{{ client.avatar }}');">
<div class="overlay"></div>
</div>
</div>
<div class="col-xs-8">
<table width="100%">
<tr>
<td class="text-muted" style="vertical-align: top;">Name</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ config.cloudronName }} <a href="" ng-click="cloudronNameChange.show()"><i class="fa fa-pencil text-small"></i></a></td>
</tr>
<tr ng-show="config.provider === 'caas'">
<td class="text-muted" style="vertical-align: top;">Model</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ config.size }} - {{ config.region }}</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Version</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ config.version }}</td>
</tr>
<tr>
<td class="text-muted" style="vertical-align: top;">Provider</td>
<td class="text-right" style="vertical-align: top; white-space: nowrap;">{{ prettyProviderName(config.provider) }}</td>
</tr>
</table>
</div>
</div>
</div>
<div class="text-left" ng-show="config.provider === 'caas'">
<h3>Plans</h3>
</div>
<div class="card" style="margin-bottom: 15px;" ng-show="config.provider === 'caas'">
<div class="row">
<div class="col-xs-12 text-right">
<a href="{{ config.webServerOrigin }}/console.html#/userprofile?view=credit_card" target="_blank">Change payment method</a>
or
<a href="{{ config.webServerOrigin }}/console.html" target="_blank">Cancel this Cloudron</a>
</div>
</div>
<div class="row">
<div class="col-xs-10 plans" style="margin-left: 20px">
<div ng-repeat="plan in availablePlans">
<label>
<input type="radio" ng-model="planChange.requestedPlan" ng-value="plan">
{{ plan.name }} ({{ plan.slug | uppercase }}) - {{ plan.price/100 }}{{ currency }}/month
<span ng-show="currentPlan.name === plan.name" style="font-weight: bold"> (current plan)
</span>
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<button class="btn btn-primary pull-right" ng-disabled="planChange.requestedPlan.name === currentPlan.name" ng-click="planChange.showChangePlan()">Change Plan</button>
</div>
</div>
</div>
<div class="text-left" ng-show="backupConfig.provider !== 'caas'">
<h3>Cloudron.io Account</h3>
</div>
<div class="card" style="margin-bottom: 15px;" ng-show="backupConfig.provider !== 'caas'">
<div class="row">
<div class="col-xs-12">
A Cloudron subscription provides access to the Cloudron App Store. This ensures you are running the latest version and keeps your apps and server secure.
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Account Email</span>
</div>
<div class="col-xs-6 text-right">
<a ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?email=' + appstoreConfig.profile.email }}" target="_blank">{{ appstoreConfig.profile.email }}</a>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Cloudron ID</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ appstoreConfig.cloudronId }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Subscription</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ currentSubscription.plan.name }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-12">
<a class="btn btn-primary pull-right" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email }}" target="_blank" ng-hide="currentSubscription.plan.id === 'free'">Configure</a>
<a class="btn btn-success pull-right" ng-href="{{ config.webServerOrigin + '/console.html#/userprofile?view=subscriptions&email=' + appstoreConfig.profile.email + '&cloudronId=' + appstoreConfig.cloudronId }}" target="_blank" ng-show="currentSubscription.plan.id === 'free'">Setup Subscription</a>
</div>
</div>
</div>
<div class="text-left">
<h3>Backups</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Provider</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ prettyProviderName(backupConfig.provider) }}</span>
</div>
</div>
<div class="row" ng-show="backupConfig.provider !== 'caas'">
<div class="col-xs-6">
<span class="text-muted">Location</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="backupConfig.provider === 'filesystem'">{{ backupConfig.backupFolder }}</span>
<span ng-show="backupConfig.provider === 'minio' || backupConfig.provider === 'exoscale-sos' || backupConfig.provider === 's3-v4-compat' || backupConfig.provider === 'digitalocean-spaces' || backupConfig.provider === 'gcs'">{{ backupConfig.bucket + '/' + backupConfig.prefix }}</span>
<span ng-show="backupConfig.provider === 's3'">{{ backupConfig.region + ' ' + backupConfig.bucket + '/' + backupConfig.prefix }}</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Storage Format</span>
</div>
<div class="col-xs-6 text-right">
<span>{{ backupConfig.format }}</span>
</div>
</div>
<br/>
<div class="row">
<div class="col-xs-4">
<span class="text-muted">Backup ID</span>
</div>
<div class="col-xs-8 text-right">
<span ng-click-select ng-show="lastBackup">{{ lastBackup.id }}</span>
<span ng-hide="lastBackup">No backups have been made yet</span>
</div>
</div>
<div class="row">
<div class="col-xs-6">
<span class="text-muted">Last backup</span>
</div>
<div class="col-xs-6 text-right">
<span ng-show="lastBackup">{{ lastBackup.creationTime | prettyDate }}</span>
<span ng-hide="lastBackup">-</span>
</div>
</div>
<div class="row">
<br/>
<div class="col-md-12">
<div ng-show="createBackup.busy" class="progress progress-striped active animateMe">
<div class="progress-bar progress-bar-success" role="progressbar" style="width: {{ createBackup.percent }}%"></div>
</div>
</div>
</div>
<div class="row">
<div class="col-md-11" ng-show="createBackup.busy">
<p class="text-muted status" style="text-overflow: ellipsis; white-space: nowrap; overflow: hidden">
{{ createBackup.detail || 'Syncing ...' }}
</p>
</div>
</div>
<div class="row">
<div class="col-md-6">
<p ng-show="createBackup.busy">{{ createBackup.message }}</p>
<p ng-hide="createBackup.busy">
<div class="has-error" ng-show="createBackup.percent === 100 && createBackup.result">{{ createBackup.result }}</div>
</p>
</div>
<div class="col-md-6 text-right" ng-show="backupConfig.provider !== 'caas'">
<button class="btn btn-outline btn-primary pull-right" ng-click="configureBackup.show()" ng-disabled="createBackup.busy">Configure</button>
<button class="btn btn-outline btn-primary" ng-click="createBackup.doCreateBackup()" ng-disabled="createBackup.busy" style="margin-right: 10px">Backup now</button>
</div>
</div>
</div>
<div class="text-left">
<h3>App Updates</h3>
</div>
<div class="card" style="margin-bottom: 15px;">
<div class="row">
<div class="col-md-12">
<p>Configure the update schedule for the apps</p>
<p class="text-danger" ng-show="autoUpdate.error"><br/>{{ autoUpdate.error }}</p>
</div>
</div>
<div class="row">
<div class="col-md-12">
<div class="radio">
<label>
<input type="radio" name="scheduleRadio" ng-change="autoUpdate.success = false" ng-model="autoUpdate.pattern" value="00 30 1,3,5,23 * * *">
Every night
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="scheduleRadio" ng-change="autoUpdate.success = false" ng-model="autoUpdate.pattern" value="00 00 3,5 * * 3">
Wednesday night
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="scheduleRadio" ng-change="autoUpdate.success = false" ng-model="autoUpdate.pattern" value="00 00 1,3,5,23 * * 6">
Saturday night
</label>
</div>
<div class="radio">
<label>
<input type="radio" name="scheduleRadio" ng-change="autoUpdate.success = false" ng-model="autoUpdate.pattern" value="never">
Update manually (Not recommended)
</label>
</div>
</div>
</div>
<div class="row">
<div class="col-md-6">
<i class="fa fa-circle-o-notch fa-spin" ng-show="autoUpdate.busy"></i>
<span class="text-success text-bold" ng-show="autoUpdate.success && autoUpdate.pattern === autoUpdate.currentPattern">Saved</span>
</div>
<div class="col-md-6 text-right">
<button class="btn btn-outline btn-primary pull-right" ng-click="autoUpdate.submit()" ng-disabled="autoUpdate.busy || autoUpdate.pattern === autoUpdate.currentPattern"> Save</button>
<button class="btn btn-outline btn-primary" ng-click="autoUpdate.checkNow()" ng-disabled="autoUpdate.busy" style="margin-right: 10px">Check now</button>
</div>
</div>
</div>
</div>
+743
View File
@@ -0,0 +1,743 @@
'use strict';
angular.module('Application').controller('SettingsController', ['$scope', '$location', '$rootScope', '$timeout', 'Client', 'AppStore', function ($scope, $location, $rootScope, $timeout, Client, AppStore) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.client = Client;
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.backupConfig = {};
$scope.appstoreConfig = {};
$scope.lastBackup = null;
$scope.backups = [];
$scope.currency = null;
$scope.availableRegions = [];
$scope.currentRegionSlug = null;
$scope.availablePlans = [];
$scope.currentPlan = null;
$scope.currentSubscription = null;
// List is from http://docs.aws.amazon.com/general/latest/gr/rande.html#s3_region
$scope.s3Regions = [
{ name: 'Asia Pacific (Mumbai)', value: 'ap-south-1' },
{ name: 'Asia Pacific (Seoul)', value: 'ap-northeast-2' },
{ name: 'Asia Pacific (Singapore)', value: 'ap-southeast-1' },
{ name: 'Asia Pacific (Sydney)', value: 'ap-southeast-2' },
{ name: 'Asia Pacific (Tokyo)', value: 'ap-northeast-1' },
{ name: 'Canada (Central)', value: 'ca-central-1' },
{ name: 'EU (Frankfurt)', value: 'eu-central-1' },
{ name: 'EU (Ireland)', value: 'eu-west-1' },
{ name: 'EU (London)', value: 'eu-west-2' },
{ name: 'South America (São Paulo)', value: 'sa-east-1' },
{ name: 'US East (N. Virginia)', value: 'us-east-1' },
{ name: 'US East (Ohio)', value: 'us-east-2' },
{ name: 'US West (N. California)', value: 'us-west-1' },
{ name: 'US West (Oregon)', value: 'us-west-2' },
];
$scope.doSpacesRegions = [
{ name: 'AMS3', value: 'https://ams3.digitaloceanspaces.com' },
{ name: 'NYC3', value: 'https://nyc3.digitaloceanspaces.com' },
{ name: 'SGP1', value: 'https://sgp1.digitaloceanspaces.com' }
];
$scope.storageProvider = [
{ name: 'Amazon S3', value: 's3' },
{ name: 'DigitalOcean Spaces', value: 'digitalocean-spaces' },
{ name: 'Exoscale SOS', value: 'exoscale-sos' },
{ name: 'Filesystem', value: 'filesystem' },
{ name: 'Google Cloud Storage', value: 'gcs' },
{ name: 'Minio', value: 'minio' },
{ name: 'No-op (Only for testing)', value: 'noop' },
{ name: 'S3 API Compatible (v4)', value: 's3-v4-compat' },
];
$scope.retentionTimes = [
{ name: '2 days', value: 2 * 24 * 60 * 60 },
{ name: '1 week', value: 7 * 24 * 60 * 60}, // the default
{ name: '1 month', value: 30 * 24 * 60 * 60},
{ name: 'Forever', value: -1 }
];
$scope.formats = [
{ name: 'Tarball (zipped)', value: 'tgz' },
{ name: 'rsync', value: 'rsync' }
];
$scope.prettyProviderName = function (provider) {
switch (provider) {
case 'caas': return 'Managed Cloudron';
default: return provider;
}
};
$scope.planChange = {
busy: false,
error: {},
password: '',
requestedPlan: null,
showChangePlan: function () {
$('#planChangeModal').modal('show');
},
planChangeReset: function () {
$scope.planChange.error.password = null;
$scope.planChange.password = '';
$scope.planChangeForm.$setPristine();
$scope.planChangeForm.$setUntouched();
},
doChangePlan: function () {
$scope.planChange.busy = true;
var options = {
size: $scope.planChange.requestedPlan.slug,
name: $scope.planChange.requestedPlan.name,
price: $scope.planChange.requestedPlan.price,
region: $scope.currentRegionSlug
};
Client.changePlan(options, $scope.planChange.password, function (error) {
$scope.planChange.busy = false;
if (error) {
if (error.statusCode === 403) {
$scope.planChange.error.password = true;
$scope.planChange.password = '';
$scope.planChangeForm.password.$setPristine();
$('#inputPlanChangePassword').focus();
} else {
console.error('Unable to change plan.', error);
}
} else {
$scope.planChange.planChangeReset();
$('#planChangeModal').modal('hide');
window.location.href = '/update.html';
}
$scope.planChange.busy = false;
});
}
};
$scope.createBackup = {
busy: false,
percent: 0,
message: '',
errorMessage: '',
result: '',
updateStatus: function () {
Client.progress(function (error, data) {
if (error) return window.setTimeout($scope.createBackup.updateStatus, 250);
// check if we are done
if (!data.backup || data.backup.percent >= 100) {
if (data.backup && data.backup.message) console.error('Backup message: ' + data.backup.message); // backup error message
$scope.createBackup.busy = false;
$scope.createBackup.message = '';
$scope.createBackup.detail = '';
$scope.createBackup.percent = 100; // indicates that 'result' is valid
$scope.createBackup.result = data.backup ? data.backup.message : null;
return fetchBackups();
}
$scope.createBackup.busy = true;
$scope.createBackup.percent = data.backup.percent;
$scope.createBackup.message = data.backup.message;
$scope.createBackup.detail = data.backup.detail;
window.setTimeout($scope.createBackup.updateStatus, 500);
});
},
doCreateBackup: function () {
$scope.createBackup.busy = true;
$scope.createBackup.percent = 0;
$scope.createBackup.message = '';
$scope.createBackup.detail = '';
$scope.createBackup.result = '';
$scope.createBackup.errorMessage = '';
Client.backup(function (error) {
if (error) {
if (error.statusCode === 409 && error.message.indexOf('full_backup') !== -1) {
$scope.createBackup.errorMessage = 'Backup already in progress. Please retry later.';
} else if (error.statusCode === 409) {
$scope.createBackup.errorMessage = 'App task is currently in progress. Please retry later.';
} else {
console.error(error);
$scope.createBackup.errorMessage = error.message;
}
$scope.createBackup.busy = false;
$('#createBackupFailedModal').modal('show');
return;
}
$scope.createBackup.updateStatus();
});
}
};
$scope.avatarChange = {
busy: false,
error: {},
avatar: null,
availableAvatars: [{
file: null,
data: null,
url: '/img/avatars/avatar_0.png',
}, {
file: null,
data: null,
url: '/img/avatars/rubber-duck.png'
}, {
file: null,
data: null,
url: '/img/avatars/carrot.png'
}, {
file: null,
data: null,
url: '/img/avatars/cup.png'
}, {
file: null,
data: null,
url: '/img/avatars/football.png'
}, {
file: null,
data: null,
url: '/img/avatars/owl.png'
}, {
file: null,
data: null,
url: '/img/avatars/space-rocket.png'
}, {
file: null,
data: null,
url: '/img/avatars/armchair.png'
}, {
file: null,
data: null,
url: '/img/avatars/cap.png'
}, {
file: null,
data: null,
url: '/img/avatars/pan.png'
}, {
file: null,
data: null,
url: '/img/avatars/meat.png'
}, {
file: null,
data: null,
url: '/img/avatars/umbrella.png'
}, {
file: null,
data: null,
url: '/img/avatars/jar.png'
}],
getBlobFromImg: function (img, callback) {
var size = 256;
var canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
var imageDimensionRatio = img.width / img.height;
var canvasDimensionRatio = canvas.width / canvas.height;
var renderableHeight, renderableWidth, xStart, yStart;
if (imageDimensionRatio > canvasDimensionRatio) {
renderableHeight = canvas.height;
renderableWidth = img.width * (renderableHeight / img.height);
xStart = (canvas.width - renderableWidth) / 2;
yStart = 0;
} else if (imageDimensionRatio < canvasDimensionRatio) {
renderableWidth = canvas.width;
renderableHeight = img.height * (renderableWidth / img.width);
xStart = 0;
yStart = (canvas.height - renderableHeight) / 2;
} else {
renderableHeight = canvas.height;
renderableWidth = canvas.width;
xStart = 0;
yStart = 0;
}
var ctx = canvas.getContext('2d');
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.drawImage(img, xStart, yStart, renderableWidth, renderableHeight);
canvas.toBlob(callback);
},
doChangeAvatar: function () {
$scope.avatarChange.error.avatar = null;
$scope.avatarChange.busy = true;
var img = document.getElementById('previewAvatar');
$scope.avatarChange.avatar.file = $scope.avatarChange.getBlobFromImg(img, function (blob) {
Client.changeCloudronAvatar(blob, function (error) {
if (error) {
console.error('Unable to change cloudron avatar.', error);
} else {
Client.resetAvatar();
}
$('#avatarChangeModal').modal('hide');
$scope.avatarChange.avatarChangeReset();
});
});
},
setPreviewAvatar: function (avatar) {
$scope.avatarChange.avatar = avatar;
},
avatarChangeReset: function () {
$scope.avatarChange.error.avatar = null;
$scope.avatarChange.avatar = null;
$scope.avatarChange.busy = false;
},
showChangeAvatar: function () {
$scope.avatarChange.avatarChangeReset();
$('#avatarChangeModal').modal('show');
},
showCustomAvatarSelector: function () {
$('#avatarFileInput').click();
}
};
$scope.s3like = function (provider) {
return provider === 's3' || provider === 'minio' || provider === 's3-v4-compat' || provider === 'exoscale-sos' || provider === 'digitalocean-spaces';
};
function readFileLocally(obj, file, fileName) {
return function (event) {
$scope.$apply(function () {
obj[file] = null;
obj[fileName] = event.target.files[0].name;
var reader = new FileReader();
reader.onload = function (result) {
if (!result.target || !result.target.result) return console.error('Unable to read local file');
obj[file] = result.target.result;
};
reader.readAsText(event.target.files[0]);
});
};
}
$scope.configureBackup = {
busy: false,
error: {},
provider: '',
bucket: '',
prefix: '',
accessKeyId: '',
secretAccessKey: '',
gcsKey: { keyFileName: '', content: '' },
region: '',
endpoint: '',
backupFolder: '',
retentionSecs: 7 * 24 * 60 * 60,
acceptSelfSignedCerts: false,
useHardlinks: true,
format: 'tgz',
clearForm: function () {
$scope.configureBackup.bucket = '';
$scope.configureBackup.prefix = '';
$scope.configureBackup.accessKeyId = '';
$scope.configureBackup.secretAccessKey = '';
$scope.configureBackup.gcsKey.keyFileName = '';
$scope.configureBackup.gcsKey.content = '';
$scope.configureBackup.endpoint = '';
$scope.configureBackup.region = '';
$scope.configureBackup.backupFolder = '';
$scope.configureBackup.retentionSecs = 7 * 24 * 60 * 60;
$scope.configureBackup.format = 'tgz';
$scope.configureBackup.acceptSelfSignedCerts = false;
$scope.configureBackup.useHardlinks = true;
},
show: function () {
$scope.configureBackup.error = {};
$scope.configureBackup.busy = false;
$scope.configureBackup.provider = $scope.backupConfig.provider;
$scope.configureBackup.bucket = $scope.backupConfig.bucket;
$scope.configureBackup.prefix = $scope.backupConfig.prefix;
$scope.configureBackup.region = $scope.backupConfig.region;
$scope.configureBackup.accessKeyId = $scope.backupConfig.accessKeyId;
$scope.configureBackup.secretAccessKey = $scope.backupConfig.secretAccessKey;
if ($scope.backupConfig.provider === 'gcs') {
$scope.configureBackup.gcsKey.keyFileName = $scope.backupConfig.credentials.client_email;
$scope.configureBackup.gcsKey.content = JSON.stringify({
project_id: $scope.backupConfig.projectId,
client_email: $scope.backupConfig.credentials.client_email,
private_key: $scope.backupConfig.credentials.private_key,
});
}
$scope.configureBackup.endpoint = $scope.backupConfig.endpoint;
$scope.configureBackup.key = $scope.backupConfig.key;
$scope.configureBackup.backupFolder = $scope.backupConfig.backupFolder;
$scope.configureBackup.retentionSecs = $scope.backupConfig.retentionSecs;
$scope.configureBackup.format = $scope.backupConfig.format;
$scope.configureBackup.acceptSelfSignedCerts = !!$scope.backupConfig.acceptSelfSignedCerts;
$scope.configureBackup.useHardlinks = !$scope.backupConfig.noHardlinks;
$('#configureBackupModal').modal('show');
},
submit: function () {
$scope.configureBackup.error = {};
$scope.configureBackup.busy = true;
var backupConfig = {
provider: $scope.configureBackup.provider,
key: $scope.configureBackup.key,
retentionSecs: $scope.configureBackup.retentionSecs,
format: $scope.configureBackup.format
};
// only set provider specific fields, this will clear them in the db
if ($scope.s3like(backupConfig.provider)) {
backupConfig.bucket = $scope.configureBackup.bucket;
backupConfig.prefix = $scope.configureBackup.prefix;
backupConfig.accessKeyId = $scope.configureBackup.accessKeyId;
backupConfig.secretAccessKey = $scope.configureBackup.secretAccessKey;
if ($scope.configureBackup.endpoint) backupConfig.endpoint = $scope.configureBackup.endpoint;
if (backupConfig.provider === 's3') {
if ($scope.configureBackup.region) backupConfig.region = $scope.configureBackup.region;
} else if (backupConfig.provider === 'minio' || backupConfig.provider === 's3-v4-compat') {
backupConfig.region = 'us-east-1';
backupConfig.acceptSelfSignedCerts = $scope.configureBackup.acceptSelfSignedCerts;
} else if (backupConfig.provider === 'exoscale-sos') {
backupConfig.endpoint = 'https://sos-ch-dk-2.exo.io';
backupConfig.region = 'us-east-1';
backupConfig.signatureVersion = 'v4';
} else if (backupConfig.provider === 'digitalocean-spaces') {
backupConfig.region = 'us-east-1';
}
} else if (backupConfig.provider === 'gcs') {
backupConfig.bucket = $scope.configureBackup.bucket;
backupConfig.prefix = $scope.configureBackup.prefix;
try {
var serviceAccountKey = JSON.parse($scope.configureBackup.gcsKey.content);
backupConfig.projectId = serviceAccountKey.project_id;
backupConfig.credentials = {
client_email: serviceAccountKey.client_email,
private_key: serviceAccountKey.private_key
};
if (!backupConfig.projectId || !backupConfig.credentials || !backupConfig.credentials.client_email || !backupConfig.credentials.private_key) {
throw 'fields_missing';
}
} catch (e) {
$scope.configureBackup.error.generic = 'Cannot parse Google Service Account Key: ' + e.message;
$scope.configureBackup.error.gcsKeyInput = true;
$scope.configureBackup.busy = false;
return;
}
} else if (backupConfig.provider === 'filesystem') {
backupConfig.backupFolder = $scope.configureBackup.backupFolder;
backupConfig.noHardlinks = !$scope.configureBackup.useHardlinks;
}
Client.setBackupConfig(backupConfig, function (error) {
$scope.configureBackup.busy = false;
if (error) {
if (error.statusCode === 402) {
$scope.configureBackup.error.generic = error.message;
if (error.message.indexOf('AWS Access Key Id') !== -1) {
$scope.configureBackup.error.accessKeyId = true;
$scope.configureBackup.accessKeyId = '';
$scope.configureBackupForm.accessKeyId.$setPristine();
$('#inputConfigureBackupAccessKeyId').focus();
} else if (error.message.indexOf('not match the signature') !== -1 ) {
$scope.configureBackup.error.secretAccessKey = true;
$scope.configureBackup.secretAccessKey = '';
$scope.configureBackupForm.secretAccessKey.$setPristine();
$('#inputConfigureBackupSecretAccessKey').focus();
} else if (error.message.toLowerCase() === 'access denied') {
$scope.configureBackup.error.bucket = true;
$scope.configureBackup.bucket = '';
$scope.configureBackupForm.bucket.$setPristine();
$('#inputConfigureBackupBucket').focus();
} else if (error.message.indexOf('ECONNREFUSED') !== -1) {
$scope.configureBackup.error.generic = 'Unknown region';
$scope.configureBackup.error.region = true;
$scope.configureBackupForm.region.$setPristine();
$('#inputConfigureBackupRegion').focus();
} else if (error.message.toLowerCase() === 'wrong region') {
$scope.configureBackup.error.generic = 'Wrong S3 Region';
$scope.configureBackup.error.region = true;
$scope.configureBackupForm.region.$setPristine();
$('#inputConfigureBackupRegion').focus();
} else {
$('#inputConfigureBackupBucket').focus();
}
} else if (error.statusCode === 400) {
$scope.configureBackup.error.generic = error.message;
if ($scope.configureBackup.provider === 'filesystem') {
$scope.configureBackup.error.backupFolder = true;
}
} else {
console.error('Unable to change provider.', error);
}
return;
}
// $scope.configureBackup.reset();
$('#configureBackupModal').modal('hide');
// now refresh the ui
Client.refreshConfig();
getBackupConfig();
});
}
};
document.getElementById('gcsKeyFileInput').onchange = readFileLocally($scope.configureBackup.gcsKey, 'content', 'keyFileName');
$scope.autoUpdate = {
busy: false,
success: false,
error: '',
pattern: '',
currentPattern: '',
checkNow: function () {
$scope.autoUpdate.busy = true;
Client.checkForUpdates(function (error) {
if (error) $scope.autoUpdate.error = error.message;
$scope.autoUpdate.busy = false;
});
},
submit: function () {
if ($scope.autoUpdate.pattern === $scope.autoUpdate.currentPattern) return;
$scope.autoUpdate.error = '';
$scope.autoUpdate.busy = true;
$scope.autoUpdate.success = false;
Client.setAppAutoupdatePattern($scope.autoUpdate.pattern, function (error) {
if (error) $scope.autoUpdate.error = error.message;
else $scope.autoUpdate.currentPattern = $scope.autoUpdate.pattern;
$scope.autoUpdate.busy = false;
$scope.autoUpdate.success = true;
});
}
};
function fetchBackups() {
Client.getBackups(function (error, backups) {
if (error) return console.error(error);
$scope.backups = backups;
if ($scope.backups.length > 0) {
$scope.lastBackup = backups[0];
} else {
$scope.lastBackup = null;
}
});
}
function getBackupConfig() {
Client.getBackupConfig(function (error, backupConfig) {
if (error) return console.error(error);
$scope.backupConfig = backupConfig;
// Check if a proper storage backend is configured. TODO: this check fails if /var/backups is actually external
if (backupConfig.provider === 'filesystem' && backupConfig.backupFolder === '/var/backups') {
var actionScope = $scope.$new(true);
actionScope.action = '/#/settings';
Client.notify('Backup Configuration', 'Please setup an external backup storage to avoid data loss', false, 'info', actionScope);
}
});
}
function getAutoupdatePattern() {
Client.getAppAutoupdatePattern(function (error, result) {
if (error) return console.error(error);
$scope.autoUpdate.currentPattern = result.pattern;
$scope.autoUpdate.pattern = result.pattern;
});
}
function getSubscription() {
AppStore.getSubscription($scope.appstoreConfig, function (error, result) {
if (error) return console.error(error);
$scope.currentSubscription = result;
// check again to give more immediate feedback once a subscription was setup
if (result.plan.id === 'free') $timeout(getSubscription, 10000);
});
}
function getPlans() {
AppStore.getSizes(function (error, result) {
if (error) return console.error(error);
var found = false;
var SIZE_SLUGS = [ '512mb', '1gb', '2gb', '4gb', '8gb', '16gb', '32gb', '48gb', '64gb' ];
result = result.filter(function (size) {
// only show plans bigger than the current size
if (found) return true;
found = SIZE_SLUGS.indexOf(size.slug) > SIZE_SLUGS.indexOf($scope.config.plan.slug);
return found;
});
angular.copy(result, $scope.availablePlans);
// prepend the current plan
$scope.availablePlans.unshift($scope.config.plan);
$scope.planChange.requestedPlan = $scope.availablePlans[0]; // need the reference to preselect
AppStore.getRegions(function (error, result) {
if (error) return console.error(error);
angular.copy(result, $scope.availableRegions);
$scope.currentRegionSlug = $scope.config.region;
});
});
}
$('#avatarFileInput').get(0).onchange = function (event) {
var fr = new FileReader();
fr.onload = function () {
$scope.$apply(function () {
var tmp = {
file: event.target.files[0],
data: fr.result,
url: null
};
$scope.avatarChange.availableAvatars.push(tmp);
$scope.avatarChange.setPreviewAvatar(tmp);
});
};
fr.readAsDataURL(event.target.files[0]);
};
$scope.cloudronNameChange = {
busy: false,
error: {},
name: '',
reset: function () {
$scope.cloudronNameChange.busy = false;
$scope.cloudronNameChange.error.name = null;
$scope.cloudronNameChange.name = '';
$scope.cloudronNameChangeForm.$setUntouched();
$scope.cloudronNameChangeForm.$setPristine();
},
show: function () {
$scope.cloudronNameChange.reset();
$scope.cloudronNameChange.name = $scope.config.cloudronName;
$('#cloudronNameChangeModal').modal('show');
},
submit: function () {
$scope.cloudronNameChange.error.name = null;
$scope.cloudronNameChange.busy = true;
Client.changeCloudronName($scope.cloudronNameChange.name, function (error) {
$scope.cloudronNameChange.busy = false;
if (error) {
if (error.statusCode === 400) {
$scope.cloudronNameChange.error.name = 'Invalid name';
$scope.cloudronNameChange.name = '';
$('#inputCloudronName').focus();
$scope.cloudronNameChangeForm.password.$setPristine();
} else {
console.error('Unable to change name.', error);
return;
}
}
$scope.cloudronNameChange.reset();
$('#cloudronNameChangeModal').modal('hide');
Client.refreshConfig();
});
}
};
Client.onReady(function () {
fetchBackups();
getBackupConfig();
getAutoupdatePattern();
// show backup status
$scope.createBackup.updateStatus();
if ($scope.config.provider === 'caas') {
getPlans();
$scope.currentPlan = $scope.config.plan;
$scope.currency = $scope.config.currency === 'eur' ? '€' : '$';
} else {
Client.getAppstoreConfig(function (error, result) {
if (error) return console.error(error);
if (result.token) {
$scope.appstoreConfig = result;
AppStore.getProfile(result.token, function (error, result) {
if (error) return console.error(error);
$scope.appstoreConfig.profile = result;
getSubscription();
});
}
});
}
});
// setup all the dialog focus handling
['planChangeModal', 'appstoreLoginModal', 'cloudronNameChangeModal', 'configureBackupModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
$('.modal-backdrop').remove();
}]);
+81
View File
@@ -0,0 +1,81 @@
<div class="content">
<div class="text-left">
<h1>Support</h1>
</div>
<div class="text-left">
<h3>Documentation and Forum</h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row animateMeOpacity">
<div class="col-lg-12">
For user manuals and app development related questions, please refer to our <a href="{{ config.webServerOrigin }}/documentation.html" target="_blank"> documentation</a>.
<br/><br/>
For any other questions, search and ask in our <a href="https://forum.cloudron.io/" target="_blank">forum</a> or write to <a href="mailto:support@cloudron.io">support@cloudron.io</a>.
</div>
</div>
</div>
</div>
<div class="text-left">
<h3>Feedback</h3>
</div>
<div class="card">
<div class="grid-item-top">
<div class="row animateMeOpacity">
<div class="col-lg-12">
We would love to hear your feedback. Help us improve our product by reporting any bugs or feature requests.
<br/>
<br/>
<form name="feedbackForm" ng-submit="submitFeedback()">
<div class="form-group">
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.type" required>
<option value="app_error">App Error</option>
<option value="ticket">Bug Report</option>
</select>
</div>
<div class="form-group" ng-show="feedback.type === 'app_error'">
<select class="form-control" name="type" style="width: 50%;" ng-model="feedback.appId" ng-required="feedback.type === 'app_error'">
<option value="" disabled selected>Select App</option>
<option ng-repeat="app in apps" value="{{ app.id }}">{{ app.fqdn }}</option>
</select>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.subject.$dirty && feedbackForm.subject.$invalid) }">
<input type="text" class="form-control" name="subject" placeholder="Topic" ng-model="feedback.subject" ng-maxlength="512" ng-minlength="1" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (feedbackForm.description.$dirty && feedbackForm.description.$invalid) }">
<textarea class="form-control" name="description" rows="3" placeholder="Describe your issue" ng-model="feedback.description" ng-minlength="1" required></textarea>
</div>
<button type="submit" class="btn btn-primary" ng-disabled="feedbackForm.$invalid || feedback.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="feedback.busy"></i> Submit</button>
<span ng-show="feedback.error" class="text-danger text-bold">{{feedback.error}}</span>
<span ng-show="feedback.success" class="text-success text-bold">Thank You!</span>
</form>
</div>
</div>
</div>
</div>
<div class="text-left" ng-show="config.provider !== 'caas' && user.admin">
<h3>Remote Support</h3>
</div>
<div class="card" ng-show="config.provider !== 'caas' && user.admin">
<div class="grid-item-top">
<div class="row animateMeOpacity">
<div class="col-lg-12">
Enable this option to allow Cloudron engineers to connect to this server via SSH.
<br/>
<br/>
<b>Only enable this option if you were asked to do so from Cloudron support!</b>
<br/>
<br/>
<button class="btn" ng-class="{ 'btn-danger': !sshSupportEnabled, 'btn-primary': sshSupportEnabled }" ng-click="toggleSshSupport()">{{ sshSupportEnabled ? 'Disable SSH support access' : 'Enable SSH support access' }}</button>
</div>
</div>
</div>
</div>
</div>
+73
View File
@@ -0,0 +1,73 @@
'use strict';
angular.module('Application').controller('SupportController', ['$scope', '$location', 'Client', function ($scope, $location, Client) {
$scope.config = Client.getConfig();
$scope.user = Client.getUserInfo();
$scope.apps = Client.getInstalledApps();
$scope.feedback = {
error: null,
success: false,
busy: false,
subject: '',
type: 'ticket',
description: '',
appId: ''
};
$scope.sshSupportEnabled = false;
function resetFeedback() {
$scope.feedback.subject = '';
$scope.feedback.description = '';
$scope.feedback.type = 'ticket';
$scope.feedback.appId = '';
$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, $scope.feedback.appId, function (error) {
if (error) {
$scope.feedback.error = error.message;
} else {
$scope.feedback.success = true;
resetFeedback();
}
$scope.feedback.busy = false;
});
};
var CLOUDRON_SUPPORT_PUBLIC_KEY = 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAABAQDQVilclYAIu+ioDp/sgzzFz6YU0hPcRYY7ze/LiF/lC7uQqK062O54BFXTvQ3ehtFZCx3bNckjlT2e6gB8Qq07OM66De4/S/g+HJW4TReY2ppSPMVNag0TNGxDzVH8pPHOysAm33LqT2b6L/wEXwC6zWFXhOhHjcMqXvi8Ejaj20H1HVVcf/j8qs5Thkp9nAaFTgQTPu8pgwD8wDeYX1hc9d0PYGesTADvo6HF4hLEoEnefLw7PaStEbzk2fD3j7/g5r5HcgQQXBe74xYZ/1gWOX2pFNuRYOBSEIrNfJEjFJsqk3NR1+ZoMGK7j+AZBR4k0xbrmncQLcQzl6MMDzkp support@cloudron.io';
var CLOUDRON_SUPPORT_PUBLIC_KEY_IDENTIFIER = 'support@cloudron.io';
$scope.toggleSshSupport = function () {
if ($scope.sshSupportEnabled) {
Client.delAuthorizedKey(CLOUDRON_SUPPORT_PUBLIC_KEY_IDENTIFIER, function (error) {
if (error) return console.error(error);
$scope.sshSupportEnabled = false;
});
} else {
Client.addAuthorizedKey(CLOUDRON_SUPPORT_PUBLIC_KEY, function (error) {
if (error) return console.error(error);
$scope.sshSupportEnabled = true;
});
}
};
Client.onReady(function () {
Client.getAuthorizedKeys(function (error, keys) {
if (error) return console.error(error);
$scope.sshSupportEnabled = keys.some(function (k) { return k.key === CLOUDRON_SUPPORT_PUBLIC_KEY; });
});
});
$('.modal-backdrop').remove();
}]);
+157
View File
@@ -0,0 +1,157 @@
<!-- Modal add client -->
<div class="modal fade" id="clientAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Add OAuth Client</h4>
</div>
<div class="modal-body">
<form name="clientAddForm" role="form" novalidate ng-submit="clientAdd.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (clientAddForm.name.$dirty && clientAddForm.name.$invalid) || (!clientAddForm.name.$dirty && clientAdd.error.name) }">
<label class="control-label">Application name</label>
<div class="control-label" ng-show="(!clientAddForm.name.$dirty && clientAdd.error.name) || (clientAddForm.name.$dirty && clientAddForm.name.$invalid)">
<small ng-show="clientAddForm.name.$error.required">A name is required</small>
<small ng-show="!clientAddForm.name.$dirty && clientAdd.error.name">{{ clientAdd.error.name }}</small>
</div>
<input type="text" class="form-control" ng-model="clientAdd.name" name="name" id="clientAddName" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (clientAddForm.redirectURI.$dirty && clientAddForm.redirectURI.$invalid) || (!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI) }">
<label class="control-label">Authorization callback URL</label>
<div class="control-label" ng-show="!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI">
<small ng-show="!clientAddForm.redirectURI.$dirty && clientAdd.error.redirectURI">{{ clientAdd.error.redirectURI }}</small>
</div>
<input type="text" class="form-control" ng-model="clientAdd.redirectURI" name="redirectURI" id="clientAddRedirectURI" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (clientAddForm.scope.$dirty && clientAddForm.scope.$invalid) || (!clientAddForm.scope.$dirty && clientAdd.error.scope) }">
<label class="control-label">Scope</label>
<div class="control-label" ng-show="(!clientAddForm.scope.$dirty && clientAdd.error.scope) || (clientAddForm.scope.$dirty && clientAddForm.scope.$invalid)">
<small ng-show="clientAddForm.scope.$error.required">A scope is required</small>
<small ng-show="!clientAddForm.scope.$dirty && clientAdd.error.scope">{{ clientAdd.error.scope }}</small>
</div>
<input type="text" class="form-control" ng-model="clientAdd.scope" name="scope" id="clientAddScope" placeholder="Specify any number of scope separated by a comma ','" required>
</div>
<input class="hide" type="submit" ng-disabled="clientAddForm.$invalid || clientAdd.busy"/>
</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="clientAdd.submit()" ng-disabled="clientAddForm.$invalid || clientAdd.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="clientAdd.busy"></i> Add OAuth Client</button>
</div>
</div>
</div>
</div>
<!-- Modal remove client -->
<div class="modal fade" id="clientRemoveModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Remove OAuth Client</h4>
</div>
<div class="modal-body">
<p>
Removing client <b>{{ clientRemove.client.appId }}</b> will also remove all access from scripts and apps using those credentials.
You may want to consult the other Cloudron admins first.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" ng-click="clientRemove.submit()" ng-disabled="clientRemove.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="clientRemove.busy"></i> Remove OAuth Client</button>
</div>
</div>
</div>
</div>
<!-- Modal add token -->
<div class="modal fade" id="tokenAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">New token created</h4>
</div>
<div class="modal-body"><b ng-click-select>{{ tokenAdd.token.accessToken }}</b></div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Done</button>
</div>
</div>
</div>
</div>
<div class="content">
<div class="text-left">
<h3>Access Tokens <button class="btn btn-xs btn-primary btn-outline pull-right" ng-click="tokenAdd.show(apiClient)"><i class="fa fa-plus"></i> New Token</button> </h3>
</div>
<!-- we will always at least have the webadmin token here, so activeClients always will have one entry with at least one token -->
<div class="card">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<p>These tokens can be used to access the <a ng-href="{{ config.webServerOrigin + '/documentation/developer/api/' }}" target="_blank">Cloudron API</a>.</p>
<br/>
<h4 class="text-muted">Active Tokens</h4>
<hr/>
<p ng-repeat="token in apiClient.activeTokens">
<span ng-click-select>{{ token.accessToken }}</span> <button class="btn btn-xs btn-danger pull-right" ng-click="removeToken(apiClient, token)" title="Revoke Token"><i class="fa fa-trash-o"></i></button>
</p>
</div>
</div>
</div>
</div>
<br/>
<div class="text-left">
<h3>OAuth Apps<button class="btn btn-xs btn-primary btn-outline pull-right" ng-click="clientAdd.show()"><i class="fa fa-plus"></i> New OAuth Client</button></h3>
</div>
<!-- we will always at least have the webadmin token here, so activeClients always will have one entry with at least one token -->
<div class="card" ng-repeat="client in activeClients | activeOAuthClients:user">
<div class="grid-item-top">
<div class="row">
<div class="col-xs-12">
<h4 class="text-muted">
{{client.name}} <span ng-show="client.type !== 'external' && client.type !== 'built-in'">on {{client.domain}}</span>
</h4>
</div>
</div>
<div class="row">
<div class="col-xs-12">
<div class="row">
<div class="col-xs-12">
<b>{{ client.activeTokens.length }}</b> active token(s).
<br/>
<a href="" data-toggle="collapse" data-parent="#accordion" data-target="#collapse{{client.id}}">Advanced</a>
<div id="collapse{{client.id}}" class="panel-collapse collapse">
<div class="panel-body">
<h4 class="text-muted">Credentials <button class="btn btn-xs btn-danger pull-right" ng-click="clientRemove.show(client)" title="Remove OAuth Client" ng-show="client.type === 'external'">Remove OAuth Client</button></h4>
<hr/>
<p>Scope: <b ng-click-select>{{ client.scope }}</b></p>
<p>RedirectURI: <b ng-click-select>{{ client.redirectURI }}</b></p>
<p>Client ID: <b ng-click-select>{{ client.id }}</b></p>
<p ng-show="client.clientSecret" style="overflow: auto; white-space: nowrap;">Client Secret: <b ng-click-select>{{ client.clientSecret }}</b></p>
<br/>
<h4 class="text-muted">Tokens
<div class="pull-right">
<button class="btn btn-xs btn-default" ng-click="removeAccessTokens(client)" ng-disabled="!client.activeTokens.length || client.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="client.busy"></i> Revoke All</button>
<button class="btn btn-xs btn-primary btn-outline" ng-click="tokenAdd.show(client)"><i class="fa fa-plus"></i> New Token</button>
</div>
</h4>
<hr/>
<p ng-repeat="token in client.activeTokens">
<b ng-click-select>{{ token.accessToken }}</b> <button class="btn btn-xs btn-danger pull-right" ng-click="removeToken(client, token)" title="Revoke Token"><i class="fa fa-trash-o"></i></button>
</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
+190
View File
@@ -0,0 +1,190 @@
'use strict';
angular.module('Application').controller('TokensController', ['$scope', 'Client', function ($scope, Client) {
$scope.user = Client.getUserInfo();
$scope.config = Client.getConfig();
$scope.activeClients = [];
$scope.apiClient = {};
$scope.clientAdd = {
busy: false,
error: {},
name: '',
scope: '',
redirectURI: '',
show: function () {
$scope.clientAdd.busy = false;
$scope.clientAdd.error = {};
$scope.clientAdd.name = '';
$scope.clientAdd.scope = 'profile';
$scope.clientAdd.redirectURI = '';
$scope.clientAddForm.$setUntouched();
$scope.clientAddForm.$setPristine();
$('#clientAddModal').modal('show');
},
submit: function () {
$scope.clientAdd.busy = true;
$scope.clientAdd.error = {};
var CLIENT_REDIRECT_URI_FALLBACK = Client.apiOrigin || location.origin;
Client.createOAuthClient($scope.clientAdd.name, $scope.clientAdd.scope, $scope.clientAdd.redirectURI || CLIENT_REDIRECT_URI_FALLBACK, function (error) {
$scope.clientAdd.busy = false;
if (error && error.statusCode === 400) {
if (error.message.indexOf('redirectURI must be a valid uri') === 0) {
$scope.clientAdd.error.redirectURI = error.message;
$scope.clientAddForm.redirectURI.$setPristine();
$('#clientAddRedirectURI').focus();
} else if (error.message.indexOf('Username can only contain alphanumerals and dash') === 0) {
$scope.clientAdd.error.name = error.message;
$scope.clientAddForm.name.$setPristine();
$('#clientAddName').focus();
} else if (error.message.indexOf('Invalid scope') === 0) {
$scope.clientAdd.error.scope = error.message;
$scope.clientAddForm.scope.$setPristine();
$('#clientAddScope').focus();
} else {
console.error(error);
}
return;
} else if (error && error.statusCode === 412) {
var actionScope = $scope.$new(true);
actionScope.action = '/#/settings';
Client.notify('Not allowed', 'You have to enable the external API in the settings.', false, 'error', actionScope);
return;
} else if (error) return console.error('Unable to create API client.', error.statusCode, error.message);
refresh();
$('#clientAddModal').modal('hide');
});
}
};
$scope.clientRemove = {
busy: false,
client: {},
show: function (client) {
$scope.clientRemove.busy = false;
$scope.clientRemove.client = client;
$('#clientRemoveModal').modal('show');
},
submit: function () {
$scope.clientRemove.busy = true;
Client.delOAuthClient($scope.clientRemove.client.id, function (error) {
$scope.clientRemove.busy = false;
if (error && error.statusCode === 412) {
var actionScope = $scope.$new(true);
actionScope.action = '/#/settings';
Client.notify('Not allowed', 'You have to enable the external API in the settings.', false, 'error', actionScope);
return;
} else if (error) {
return console.error(error);
}
$scope.clientRemove.client = {};
refresh();
$('#clientRemoveModal').modal('hide');
});
}
};
$scope.tokenAdd = {
busy: false,
token: {},
show: function (client) {
$scope.tokenAdd.busy = true;
$scope.tokenAdd.token = {};
var expiresAt = Date.now() + 100 * 365 * 24 * 60 * 60 * 1000; // ~100 years from now
Client.createTokenByClientId(client.id, expiresAt, function (error, result) {
if (error && error.statusCode === 412) {
var actionScope = $scope.$new(true);
actionScope.action = '/#/settings';
Client.notify('Not allowed', 'You have to enable the external API in the settings.', false, 'error', actionScope);
return;
} else if (error) {
return console.error(error);
}
$scope.tokenAdd.busy = false;
$scope.tokenAdd.token = result;
$('#tokenAddModal').modal('show');
refreshClientTokens(client);
});
}
};
$scope.removeToken = function (client, token) {
Client.delToken(client.id, token.accessToken, function (error) {
if (error) console.error(error);
refreshClientTokens(client);
});
};
$scope.removeAccessTokens = function (client) {
client.busy = true;
Client.delTokensByClientId(client.id, function (error) {
if (error) console.error(error);
client.busy = false;
refreshClientTokens(client);
});
};
function refreshClientTokens(client) {
Client.getTokensByClientId(client.id, function (error, result) {
if (error) console.error(error);
client.activeTokens = result || [];
});
}
function refresh() {
Client.getOAuthClients(function (error, activeClients) {
if (error) return console.error(error);
activeClients.forEach(refreshClientTokens);
$scope.activeClients = activeClients.filter(function (c) { return c.id !== 'cid-sdk'; });
$scope.apiClient = activeClients.filter(function (c) { return c.id === 'cid-sdk'; })[0];
});
}
Client.onReady(refresh);
// setup all the dialog focus handling
['clientAddModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
$('.modal-backdrop').remove();
}]);
+385
View File
@@ -0,0 +1,385 @@
<!-- Modal add user -->
<div class="modal fade" id="userAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Add User</h4>
</div>
<div class="modal-body">
<form name="useradd_form" role="form" ng-submit="useradd.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (useradd_form.email.$dirty && useradd_form.email.$invalid) || (!useradd_form.email.$dirty && useradd.error.email) }">
<label class="control-label">Email</label>
<div class="control-label" ng-show="(!useradd_form.email.$dirty && useradd.error.email) || (useradd_form.email.$dirty && useradd_form.email.$invalid) || (!useradd_form.email.$dirty && useradd.error.email)">
<small ng-show="useradd_form.email.$error.required">An email is required</small>
<small ng-show="useradd_form.email.$error.email">This is not a valid email</small>
<small ng-show="!useradd_form.email.$dirty && useradd.error.email">{{ useradd.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="useradd.email" name="email" id="inputUserAddEmail" required autofocus>
</div>
<div class="form-group" ng-class="{ 'has-error': (useradd_form.username.$dirty && useradd_form.username.$invalid) || (!useradd_form.username.$dirty && useradd.error.username) }">
<label class="control-label">Username</label>
<div class="control-label" ng-show="(!useradd_form.username.$dirty && useradd.error.username) || (useradd_form.username.$dirty && useradd_form.username.$invalid) || (!useradd_form.username.$dirty && useradd.error.username)">
<small ng-show="useradd_form.username.$error.username">This is not a valid username</small>
<small ng-show="!useradd_form.username.$dirty && useradd.error.username">{{ useradd.error.username }}</small>
</div>
<input type="text" class="form-control" ng-model="useradd.username" name="username" id="inputUserAddUsername" placeholder="Optional">
</div>
<div class="form-group" ng-class="{ 'has-error': (useradd_form.displayName.$dirty && useradd_form.displayName.$invalid) || (!useradd_form.displayName.$dirty && useradd.error.displayName) }">
<label class="control-label">Display Name</label>
<div class="control-label" ng-show="(!useradd_form.displayName.$dirty && useradd.error.displayName) || (useradd_form.displayName.$dirty && useradd_form.displayName.$invalid) || (!useradd_form.displayName.$dirty && useradd.error.displayName)">
<small ng-show="useradd_form.displayName.$error.displayName">This is not a valid displayName</small>
<small ng-show="!useradd_form.displayName.$dirty && useradd.error.displayName">{{ useradd.error.displayName }}</small>
</div>
<input type="text" class="form-control" ng-model="useradd.displayName" name="displayName" id="inputUserAddDisplayName" placeholder="Optional">
</div>
<div class="checkbox">
<label>
<input type="checkbox" ng-model="useradd.sendInvite" id="inputUserAddSendInvite"> Send an invitation email now
</label>
</div>
<input class="ng-hide" type="submit" ng-disabled="useradd_form.$invalid || useradd.busy"/>
</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="useradd.submit()" ng-disabled="useradd_form.$invalid || useradd.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="useradd.busy"></i> Add User</button>
</div>
</div>
</div>
</div>
<!-- Modal remove user -->
<div class="modal fade" id="userRemoveModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Delete user {{ userremove.userInfo.username || userremove.userInfo.email }}</h4>
</div>
<div class="modal-body">
<form name="userremove_form" role="form" ng-submit="userremove.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (userremove_form.password.$dirty && userremove_form.password.$invalid) || (!userremove_form.password.$dirty && userremove.error.password)}">
<label class="control-label">Give your password to verify this action</label>
<input type="password" class="form-control" ng-model="userremove.password" id="inputUserRemovePassword" name="password" placeholder="Your Password" required autofocus>
<div class="control-label" ng-show="(!userremove_form.password.$dirty && userremove.error.password) || (userremove_form.password.$dirty && userremove_form.password.$invalid)">
<small ng-show="userremove_form.password.$error.required && !userremove.error.password">A password is required</small>
<small ng-show="!useradd_form.email.$dirty && userremove.error.password">{{ userremove.error.password }}</small>
</div>
</div>
<input class="hide" type="submit" ng-disabled="userremove_form.$invalid || userremove.busy"/>
</form>
</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-click="userremove.submit()" ng-disabled="userremove_form.$invalid || userremove.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="userremove.busy"></i> Delete</button>
</div>
</div>
</div>
</div>
<!-- Modal edit user -->
<div class="modal fade" id="userEditModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Edit user {{ useredit.userInfo.username || useredit.userInfo.email }}</h4>
</div>
<div class="modal-body">
<form name="useredit_form" role="form" ng-submit="useredit.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && useredit.error.email) }">
<label class="control-label">Primary email</label>
<div class="control-label" ng-show="(!useredit_form.email.$dirty && useredit.error.email) || (useredit_form.email.$dirty && useredit_form.email.$invalid) || (!useredit_form.email.$dirty && useredit.error.email)">
<small ng-show="useredit_form.email.$error.required">An email is required</small>
<small ng-show="useredit_form.email.$error.email">This is not a valid email</small>
<small ng-show="!useredit_form.email.$dirty && useredit.error.email">{{ useredit.error.email }}</small>
</div>
<input type="email" class="form-control" ng-model="useredit.email" name="email" required>
</div>
<div class="form-group" ng-class="{ 'has-error': (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail) }">
<label class="control-label">Password recovery email</label>
<div class="control-label" ng-show="(!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail) || (useredit_form.fallbackEmail.$dirty && useredit_form.fallbackEmail.$invalid) || (!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail)">
<small ng-show="useredit_form.fallbackEmail.$error.required">An email is required</small>
<small ng-show="useredit_form.fallbackEmail.$error.fallbackEmail">This is not a valid email</small>
<small ng-show="!useredit_form.fallbackEmail.$dirty && useredit.error.fallbackEmail">{{ useredit.error.fallbackEmail }}</small>
</div>
<input type="fallbackEmail" class="form-control" ng-model="useredit.fallbackEmail" name="fallbackEmail" required>
</div>
<div class="form-group">
<label class="control-label">Groups</label>
<div>
<span ng-repeat="group in groups | ignoreAdminGroup" ng-show="group.id !== 'admin'">
<button class="btn btn-default" type="button" ng-click="useredit.toggleGroup(group);" ng-class="{ 'btn-primary': (useredit.groupIds.indexOf(group.id) !== -1) }">{{ group.name }}</button>
</span>
</div>
<div ng-show="groups.length <= 1">No groups available.</div>
</div>
<h2 ng-show="useredit.busyFetching"><center><i class="fa fa-circle-o-notch fa-spin"></i></center></h2>
<div ng-hide="useredit.busyFetching || useredit.availableEmailDomains.length === 0" class="form-group">
<label class="control-label">Email mailboxes</label>
<div>
<multiselect ng-model="useredit.selectedEmailDomains" options="d.address for d in useredit.availableEmailDomains" data-multiple="true"></multiselect>
</div>
</div>
<div class="form-group" ng-hide="useredit.busyFetching || !useredit.userInfo.username || useredit.selectedEmailDomains.length === 0" ng-class="{ 'has-error': useredit.error.aliases }">
<label class="control-label">Email aliases</label>
<div class="control-label" ng-show="useredit.error.aliases">
<small>{{ useredit.error.aliases }}</small>
</div>
<div class="input-group form-inline" ng-repeat="emailDomain in useredit.selectedEmailDomains" style="margin-top: 10px;">
<tag-input class="form-group form-control" placeholder="Separate aliases by comma" taglist="useredit.aliases[emailDomain.address]" name="aliases"></tag-input>
<div class="input-group-addon">
@{{ emailDomain.domain.domain }}
</div>
</div>
</div>
<div class="form-group" ng-hide="isMe(useredit.userInfo)">
<div class="checkbox">
<label>
<input type="checkbox" ng-model="useredit.superuser"> User is an <a href="https://cloudron.io/documentation/user-management/#administrators" target="_blank">administrator</a>
</label>
</div>
</div>
<input class="hide" type="submit" ng-disabled="useredit_form.$invalid || useredit.busy"/>
</form>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" ng-click="useredit.submit()" ng-disabled="useredit_form.$invalid || useredit.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="useredit.busy"></i> Save</button>
</div>
</div>
</div>
</div>
<!-- Modal add group -->
<div class="modal fade" id="groupAddModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Add Group</h4>
</div>
<div class="modal-body">
<form name="groupAddForm" role="form" novalidate ng-submit="groupAdd.submit()" autocomplete="off">
<div class="form-group" ng-class="{ 'has-error': (groupAddForm.name.$dirty && groupAddForm.name.$invalid) || (!groupAddForm.name.$dirty && groupAdd.error.name) }">
<label class="control-label" for="groupAddName">Name</label>
<div class="control-label" ng-show="(!groupAddForm.name.$dirty && groupAdd.error.name) || (groupAddForm.name.$dirty && groupAddForm.name.$invalid) || (!groupAddForm.name.$dirty && groupAdd.error.name)">
<small ng-show="groupAddForm.name.$error.required">A name is required</small>
<small ng-show="groupAddForm.name.$error.minlength">The name is too short</small>
<small ng-show="groupAddForm.name.$error.maxlength">The name is too long</small>
<small ng-show="!groupAddForm.name.$dirty && groupAdd.error.name">{{ groupAdd.error.name }}</small>
</div>
<input type="text" class="form-control" ng-model="groupAdd.name" id="groupAddName" name="name" ng-maxlength="200" ng-minlength="1" required autofocus>
</div>
<input class="hide" type="submit" ng-disabled="groupAddForm.$invalid || groupAdd.busy"/>
</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="groupAdd.submit()" ng-disabled="groupAddForm.$invalid || groupAdd.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="groupAdd.busy"></i> Add Group</button>
</div>
</div>
</div>
</div>
<!-- Modal edit group -->
<div class="modal fade" id="groupEditModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Edit group {{ groupEdit.group.name }}</h4>
</div>
<div class="modal-body" ng-show="groupEdit.busyFetching">
<h2><center><i class="fa fa-circle-o-notch fa-spin"></i></center></h2>
</div>
<div class="modal-body" ng-hide="groupEdit.busyFetching || groupEdit.availableLists.length === 0">
<form name="groupEditForm" role="form" ng-submit="groupEdit.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group">
<label class="control-label">Email mailinglists on domains</label>
<p>Each user in this group will receive the mails sent to this group's email address.</p>
<div>
<multiselect ng-model="groupEdit.selectedLists" options="l.address for l in groupEdit.availableLists" data-multiple="true"></multiselect>
</div>
</div>
<input class="hide" type="submit" ng-disabled="groupEditForm.$invalid || groupEdit.busy"/>
</form>
</div>
<div class="modal-body" ng-show="!groupEdit.busyFetching && groupEdit.availableLists.length === 0">
Enable Email on a domain to configure group mailing lists here.
</div>
<div class="modal-footer">
<button type="button" class="btn btn-default" data-dismiss="modal">Close</button>
<button type="button" class="btn btn-success" ng-click="groupEdit.submit()" ng-disabled="groupEditForm.$invalid || groupEdit.busy || groupEdit.availableLists.length === 0"><i class="fa fa-circle-o-notch fa-spin" ng-show="groupEdit.busy"></i> Save</button>
</div>
</div>
</div>
</div>
<!-- Modal remove group -->
<div class="modal fade" id="groupRemoveModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Delete group {{ groupRemove.group.name }}</h4>
</div>
<div class="modal-body">
<div ng-show="groupRemove.memberCount" class="text-danger">
<b>This group still has {{ groupRemove.memberCount }} member(s). Are you sure this group is not used?</b>
<br/>
<br/>
</div>
<form name="groupRemoveForm" role="form" novalidate ng-submit="groupRemove.submit()" autocomplete="off">
<input type="password" style="display: none;">
<div class="form-group" ng-class="{ 'has-error': (groupRemoveForm.password.$dirty && groupRemoveForm.password.$invalid) || (!groupRemoveForm.password.$dirty && groupRemove.error.password)}">
<label class="control-label" for="groupRemovePasswordInput">Give your password to verify this action</label>
<div class="control-label" ng-show="(!groupRemoveForm.password.$dirty && groupRemove.error.password) || (groupRemoveForm.password.$dirty && groupRemoveForm.password.$invalid)">
<small ng-show="groupRemoveForm.password.$error.required && !groupRemove.error.password">A password is required</small>
<small ng-show="!groupRemoveForm.password.$dirty && groupRemove.error.password">{{ groupRemove.error.password }}</small>
</div>
<input type="password" class="form-control" ng-model="groupRemove.password" id="groupRemovePasswordInput" name="password" placeholder="Password" required autofocus>
</div>
<input class="hide" type="submit" ng-disabled="groupRemoveForm.$invalid || groupRemove.busy"/>
</form>
</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-click="groupRemove.submit()" ng-disabled="groupRemoveForm.$invalid || groupRemove.busy"><i class="fa fa-circle-o-notch fa-spin" ng-show="groupRemove.busy"></i> Delete</button>
</div>
</div>
</div>
</div>
<!-- Modal invite sent -->
<div class="modal fade" id="inviteSentModal" tabindex="-1" role="dialog">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h4 class="modal-title">Invite Sent</h4>
</div>
<div class="modal-body">
<p>An email has been sent to <b>{{ inviteSent.email }}</b>.</p>
<p>You can also share this invite link directly:</p>
<div class="input-group">
<input type="text" id="setupLinkInput" class="form-control" ng-value="inviteSent.setupLink" readonly/>
<span class="input-group-btn">
<button class="btn btn-default" id="setupLinkButton" type="button" data-clipboard-target="#setupLinkInput"><i class="fa fa-clipboard"></i></button>
</span>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary" data-dismiss="modal">OK</button>
</div>
</div>
</div>
</div>
<div class="content content-large">
<div class="text-left">
<h1>Users
<sup><a ng-href="{{ config.webServerOrigin }}/documentation/user-management/#users" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<button class="btn btn-primary btn-outline pull-right" ng-click="useradd.show()">
<i class="fa fa-user-plus"></i> New User
</button>
</h1>
</div>
<div class="card card-large">
<div class="grid-item-top">
<div class="row ng-hide" ng-show="!ready">
<div class="col-lg-12 text-center">
<h2><i class="fa fa-circle-o-notch fa-spin"></i></h2>
</div>
</div>
<div class="row animateMeOpacity ng-hide" ng-show="ready">
<div class="col-lg-12">
<table class="table table-hover">
<thead>
<tr>
<th style="width: 1px;"></th>
<th>User</th>
<th 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>
<i class="fa fa-briefcase arrow" ng-show="user.admin" uib-tooltip="This user is an admin and can manage apps, groups and other users"></i>
</td>
<td class="hand elide-table-cell" ng-click="useredit.show(user)" ng-show="user.username">
{{ user.username }} &nbsp; <span class="text-muted">{{ user.email }}</span>
</td>
<td class="hand elide-table-cell" ng-click="useredit.show(user)" ng-hide="user.username">
<span class="text-muted" uib-tooltip="User is not activated yet">{{ user.fallbackEmail }}</span>
</td>
<td class="text-left hand elide-table-cell hidden-xs hidden-sm" ng-click="useredit.show(user)">
<span class="group-badge" ng-repeat="groupId in user.groupIds | ignoreAdminGroup">
{{ groupsById[groupId].name }}
</span>
</td>
</td>
<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 invitation email"><i class="fa fa-paper-plane-o"></i></button>
<button class="btn btn-xs btn-default" ng-click="useredit.show(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="userremove.show(user)" title="Remove User"><i class="fa fa-trash-o"></i></button>
</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
<br/>
<div class="text-left">
<h1>Groups
<sup><a ng-href="{{ config.webServerOrigin }}/documentation/user-management/#groups" class="help" target="_blank"><i class="fa fa-question-circle"></i></a></sup>
<button class="btn btn-primary btn-outline pull-right" ng-click="groupAdd.show()"><i class="fa fa-plus"></i> New Group</button>
</h1>
</div>
<div class="card card-large">
<div class="grid-item-top">
<div class="row ng-hide" ng-show="!ready">
<div class="col-lg-12 text-center">
<h2><i class="fa fa-circle-o-notch fa-spin"></i></h2>
</div>
</div>
<div class="row animateMeOpacity ng-hide" ng-show="ready">
<div class="col-lg-12">
<table class="table table-hover">
<thead>
<tr>
<th>Name</th>
<th style="width: 300px" class="text-right">Actions</th>
</tr>
</thead>
<tbody>
<tr ng-repeat="group in groups | ignoreAdminGroup">
<td class="hand" style="text-overflow: ellipsis; white-space: nowrap;" ng-click="groupEdit.show(group)">
{{ group.name }}
</td>
<td class="text-right" style="vertical-align: bottom">
<button class="btn btn-xs btn-default" ng-click="groupEdit.show(group)" title="Edit Group"><i class="fa fa-pencil"></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>
</table>
</div>
</div>
</div>
</div>
</div>
+623
View File
@@ -0,0 +1,623 @@
'use strict';
/* global Clipboard */
// poor man's async
function asyncForEach(items, handler, callback) {
var cur = 0;
if (items.length === 0) return callback();
(function iterator() {
handler(items[cur], function () {
if (cur >= items.length-1) return callback();
++cur;
iterator();
});
})();
}
angular.module('Application').controller('UsersController', ['$scope', '$location', '$timeout', 'Client', function ($scope, $location, $timeout, Client) {
Client.onReady(function () { if (!Client.getUserInfo().admin) $location.path('/'); });
$scope.ready = false;
$scope.users = [];
$scope.groups = [];
$scope.groupsById = { };
$scope.config = Client.getConfig();
$scope.userInfo = Client.getUserInfo();
$scope.emailDomains = [];
$scope.userremove = {
busy: false,
error: {},
userInfo: {},
password: '',
show: function (userInfo) {
$scope.userremove.error.username = null;
$scope.userremove.error.password = null;
$scope.userremove.password = '';
$scope.userremove.userInfo = userInfo;
$scope.userremove_form.$setPristine();
$scope.userremove_form.$setUntouched();
$('#userRemoveModal').modal('show');
},
submit: function () {
$scope.userremove.error.password = null;
$scope.userremove.busy = true;
Client.removeUser($scope.userremove.userInfo.id, $scope.userremove.password, function (error) {
$scope.userremove.busy = false;
if (error && error.statusCode === 403) {
$scope.userremove.error.password = 'Wrong password';
$scope.userremove.password = '';
$scope.userremove_form.password.$setPristine();
$('#inputUserRemovePassword').focus();
return;
}
if (error) return console.error('Unable to delete user.', error);
$scope.userremove.userInfo = {};
$scope.userremove.password = '';
$scope.userremove_form.$setPristine();
$scope.userremove_form.$setUntouched();
refresh();
$('#userRemoveModal').modal('hide');
});
}
};
$scope.useradd = {
busy: false,
alreadyTaken: false,
error: {},
email: '',
username: '',
displayName: '',
sendInvite: true,
show: function () {
$scope.useradd.error = {};
$scope.useradd.email = '';
$scope.useradd.username = '';
$scope.useradd.displayName = '';
$scope.useradd_form.$setUntouched();
$scope.useradd_form.$setPristine();
$('#userAddModal').modal('show');
},
submit: function () {
$scope.useradd.busy = true;
$scope.useradd.alreadyTaken = false;
$scope.useradd.error.email = null;
$scope.useradd.error.username = null;
$scope.useradd.error.displayName = null;
Client.createUser($scope.useradd.username || null, $scope.useradd.email, $scope.useradd.displayName, $scope.useradd.sendInvite, function (error) {
$scope.useradd.busy = false;
if (error && error.statusCode === 409) {
if (error.message.toLowerCase().indexOf('email') !== -1) {
$scope.useradd.error.email = 'Email already taken';
$scope.useradd_form.email.$setPristine();
$('#inputUserAddEmail').focus();
} else if (error.message.toLowerCase().indexOf('username') !== -1 || error.message.toLowerCase().indexOf('mailbox') !== -1) {
$scope.useradd.error.username = 'Username already taken';
$scope.useradd_form.username.$setPristine();
$('#inputUserAddUsername').focus();
} else {
// should not happen!!
console.error(error.message);
}
return;
}
if (error && error.statusCode === 400) {
if (error.message.toLowerCase().indexOf('email') !== -1) {
$scope.useradd.error.email = 'Invalid Email';
$scope.useradd.error.emailAttempted = $scope.useradd.email;
$scope.useradd_form.email.$setPristine();
$('#inputUserAddEmail').focus();
} else if (error.message.toLowerCase().indexOf('username') !== -1) {
$scope.useradd.error.username = error.message;
$scope.useradd_form.username.$setPristine();
$('#inputUserAddUsername').focus();
} else {
console.error('Unable to create user.', error.statusCode, error.message);
}
return;
}
if (error) return console.error('Unable to create user.', error.statusCode, error.message);
$scope.useradd.error = {};
$scope.useradd.email = '';
$scope.useradd.username = '';
$scope.useradd.displayName = '';
$scope.useradd_form.$setUntouched();
$scope.useradd_form.$setPristine();
refresh();
$('#userAddModal').modal('hide');
});
}
};
$scope.useredit = {
busy: false,
busyFetching: false,
error: {},
userInfo: {},
email: '',
fallbackEmail: '',
aliases: {},
selectedEmailDomains: [],
currentEmailDomains: [],
availableEmailDomains: [],
superuser: false,
show: function (userInfo) {
$scope.useredit.busyFetching = true;
$scope.useredit.error = {};
$scope.useredit.email = userInfo.email;
$scope.useredit.fallbackEmail = userInfo.fallbackEmail;
$scope.useredit.userInfo = userInfo;
$scope.useredit.groupIds = angular.copy(userInfo.groupIds);
$scope.useredit.superuser = userInfo.groupIds.indexOf('admin') !== -1;
$scope.useredit.availableEmailDomains = userInfo.username ? $scope.emailDomains.map(function (d) { return { domain: d, address: userInfo.username + '@' + d.domain }; }) : []; // username can be null if invited user has not signed up yet
$scope.useredit.currentEmailDomains = [];
$scope.useredit.selectedEmailDomains = [];
$scope.useredit.aliases = {};
// fetch user's mailboxes and aliases
var tmp = [];
asyncForEach($scope.emailDomains, function (domain, callback) {
Client.getUserMailbox(domain.domain, userInfo.id, function (error) {
if (error) return callback();
var d = $scope.useredit.availableEmailDomains.find(function (d) { return d.domain.domain === domain.domain; });
if (!d) {
console.error('Unable to map domain, this should never happen.');
return callback();
}
$scope.useredit.currentEmailDomains.push(d);
tmp.push(d);
Client.getAliases(domain.domain, userInfo.id, function (error, result) {
if (error) return callback();
$scope.useredit.aliases[d.address] = result.join(',');
callback();
});
});
}, function (error) {
$scope.useredit.busyFetching = false;
if (error) return console.error(error);
// we need this copy as angular multiselect cannot deal with dynamic arrays!
$scope.useredit.selectedEmailDomains = tmp;
});
$scope.useredit_form.$setPristine();
$scope.useredit_form.$setUntouched();
// clear any alias error when the model changes. this is required because tagInput directive is not angular forms aware
// http://blog.revolunet.com/blog/2013/11/28/create-resusable-angularjs-input-component/ has some notes on how to do that
$scope.$watch('useredit.aliases', function () {
$scope.useredit.error.aliases = null;
});
$('#userEditModal').modal('show');
},
toggleGroup: function (group) {
var pos = $scope.useredit.groupIds.indexOf(group.id);
if (pos === -1) {
$scope.useredit.groupIds.push(group.id);
} else {
$scope.useredit.groupIds.splice(pos, 1);
}
},
submit: function () {
$scope.useredit.error = {};
$scope.useredit.busy = true;
var userId = $scope.useredit.userInfo.id;
var data = {
id: userId,
email: $scope.useredit.email,
fallbackEmail: $scope.useredit.fallbackEmail
};
Client.updateUser(data, function (error) {
if (error) {
$scope.useredit.busy = false;
if (error.statusCode === 409) {
$scope.useredit.error.email = 'Email already taken';
$scope.useredit_form.email.$setPristine();
$('#inputUserEditEmail').focus();
} else {
console.error('Unable to update user:', error);
}
return;
}
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) {
if (error) return console.error('Unable to update groups for user:', error);
var addedEmailDomains = $scope.useredit.selectedEmailDomains.filter(function (s) {
return !$scope.useredit.currentEmailDomains.find(function (c) {
return c.domain.domain === s.domain.domain;
});
});
var removedEmailDomains = $scope.useredit.currentEmailDomains.filter(function (c) {
return !$scope.useredit.selectedEmailDomains.find(function (s) {
return s.domain.domain === c.domain.domain;
});
});
asyncForEach(removedEmailDomains, function (emailDomain, callback) {
// cleanup aliases first
Client.setAliases(emailDomain.domain.domain, userId, [], function (error) {
if (error) return callback(error);
Client.disableUserMailbox(emailDomain.domain.domain, userId, callback);
});
}, function (error) {
if (error) {
$scope.useredit.busy = false;
return console.error('Unable to remove mailboxes and aliases.', error);
}
// enable email on domains
asyncForEach(addedEmailDomains, function (emailDomain, callback) {
Client.enableUserMailbox(emailDomain.domain.domain, userId, function (error) {
if (error) return callback(error);
var aliases = $scope.useredit.aliases[emailDomain.address] ? $scope.useredit.aliases[emailDomain.address].split(',') : [];
Client.setAliases(emailDomain.domain.domain, userId, aliases, callback);
});
}, function (error) {
if (error) {
$scope.useredit.busy = false;
return console.error('Unable to remove mailboxes and aliases.', error);
}
// sync the aliases for enabled domains
asyncForEach($scope.useredit.selectedEmailDomains, function (emailDomain, callback) {
var aliases = $scope.useredit.aliases[emailDomain.address] ? $scope.useredit.aliases[emailDomain.address].split(',') : [];
Client.setAliases(emailDomain.domain.domain, userId, aliases, callback);
}, function (error) {
$scope.useredit.busy = false;
if (error) return console.error('unable to adjust email addresses.', error);
$scope.useredit.userInfo = {};
$scope.useredit.email = '';
$scope.useredit.superuser = false;
$scope.useredit.groupIds = [];
$scope.useredit.availableEmailDomains = [];
$scope.useredit.currentEmailDomains = [];
$scope.useredit.selectedEmailDomains = [];
$scope.useredit.aliases = '';
$scope.useredit_form.$setPristine();
$scope.useredit_form.$setUntouched();
refresh();
$('#userEditModal').modal('hide');
});
});
});
});
});
}
};
$scope.groupAdd = {
busy: false,
error: {},
name: '',
show: function () {
$scope.groupAdd.busy = false;
$scope.groupAdd.error = {};
$scope.groupAdd.name = '';
$scope.groupAddForm.$setUntouched();
$scope.groupAddForm.$setPristine();
$('#groupAddModal').modal('show');
},
submit: function () {
$scope.groupAdd.busy = true;
$scope.groupAdd.error = {};
Client.createGroup($scope.groupAdd.name, function (error) {
$scope.groupAdd.busy = false;
if (error && error.statusCode === 409) {
$scope.groupAdd.error.name = 'Name already taken';
$scope.groupAddForm.name.$setPristine();
$('#groupAddName').focus();
return;
}
if (error && error.statusCode === 400) {
$scope.groupAdd.error.name = error.message;
$scope.groupAddForm.name.$setPristine();
$('#groupAddName').focus();
return;
}
if (error) return console.error('Unable to create group.', error.statusCode, error.message);
refresh();
$('#groupAddModal').modal('hide');
});
}
};
$scope.inviteSent = {
email: '',
setupLink: ''
};
$scope.groupEdit = {
busy: false,
busyFetching: false,
error: {},
group: null,
selectedLists: [],
currentLists: [],
availableLists: [],
show: function (group) {
$scope.groupEdit.busy = false;
$scope.groupEdit.busyFetching = true;
$scope.groupEdit.error = {};
$scope.groupEdit.availableLists = $scope.emailDomains.map(function (domain) { return { domain: domain, address: group.name + '@' + domain.domain }; });
$scope.groupEdit.selectedLists = [];
$scope.groupEdit.currentLists = [];
$scope.groupEdit.group = angular.copy(group);
var tmp = [];
asyncForEach($scope.emailDomains, function (domain, callback) {
Client.getMailingList(domain.domain, $scope.groupEdit.group.id, function (error) {
if (error) return callback(error);
var list = $scope.groupEdit.availableLists.find(function (list) { return list.domain.domain === domain.domain; });
tmp.push(list);
$scope.groupEdit.currentLists.push(list);
callback();
});
}, function (error) {
$scope.groupEdit.busyFetching = false;
if (error) return console.error('Unable to get mailing lists.', error);
$scope.groupEdit.selectedLists = tmp;
});
$scope.groupEditForm.$setUntouched();
$scope.groupEditForm.$setPristine();
$('#groupEditModal').modal('show');
},
submit: function () {
$scope.groupEdit.busy = true;
var addedLists = $scope.groupEdit.selectedLists.filter(function (s) {
return !$scope.groupEdit.currentLists.find(function (c) {
return c.domain.domain === s.domain.domain;
});
});
var removedLists = $scope.groupEdit.currentLists.filter(function (c) {
return !$scope.groupEdit.selectedLists.find(function (s) {
return s.domain.domain === c.domain.domain;
});
});
asyncForEach(addedLists, function (list, callback) {
Client.addMailingList(list.domain.domain, $scope.groupEdit.group.id, callback);
}, function (error) {
if (error) {
$scope.groupEdit.busy = false;
return console.error('Failed to add group to mailinglists.', error);
}
asyncForEach(removedLists, function (list, callback) {
Client.removeMailingList(list.domain.domain, $scope.groupEdit.group.id, callback);
}, function (error) {
$scope.groupEdit.busy = false;
if (error) {
return console.error('Failed to remove group to mailinglists.', error);
}
$('#groupEditModal').modal('hide');
});
});
}
};
$scope.groupRemove = {
busy: false,
error: {},
group: null,
password: '',
memberCount: 0,
show: function (group) {
$scope.groupRemove.busy = false;
$scope.groupRemove.error = {};
$scope.groupRemove.password = '';
$scope.groupRemove.group = angular.copy(group);
$scope.groupRemoveForm.$setUntouched();
$scope.groupRemoveForm.$setPristine();
Client.getGroup(group.id, function (error, result) {
if (error) return console.error('Unable to fetch group information.', error.statusCode, error.message);
$scope.groupRemove.memberCount = result.userIds.length;
$('#groupRemoveModal').modal('show');
});
},
submit: function () {
$scope.groupRemove.busy = true;
$scope.groupRemove.error = {};
Client.removeGroup($scope.groupRemove.group.id, $scope.groupRemove.password, function (error) {
$scope.groupRemove.busy = false;
if (error && error.statusCode === 403) {
$scope.groupRemove.error.password = 'Wrong password';
$scope.groupRemove.password = '';
$scope.groupRemoveForm.password.$setPristine();
$('#groupRemovePasswordInput').focus();
return;
}
if (error) return console.error('Unable to remove group.', error.statusCode, error.message);
refresh();
$('#groupRemoveModal').modal('hide');
});
}
};
$scope.isMe = function (user) {
return user.username === Client.getUserInfo().username;
};
$scope.isAdmin = function (user) {
return !!user.admin;
};
$scope.sendInvite = function (user) {
$scope.inviteSent.email = user.fallbackEmail;
$scope.inviteSent.setupLink = '';
Client.sendInvite(user, function (error, resetToken) {
if (error) return console.error(error);
// Client.notify('', 'Invitation was successfully sent to ' + user.email + '.', false, 'success');
$scope.inviteSent.setupLink = location.origin + '/api/v1/session/account/setup.html?reset_token=' + resetToken;
$('#inviteSentModal').modal('show');
});
};
$scope.copyToClipboard = function (value) {
document.execCommand('copy');
};
function refresh() {
Client.getGroups(function (error, result) {
if (error) return console.error('Unable to get group listing.', error);
$scope.groups = result;
$scope.groupsById = { };
for (var i = 0; i < result.length; i++) {
$scope.groupsById[result[i].id] = result[i];
}
Client.getUsers(function (error, result) {
if (error) return console.error('Unable to get user listing.', error);
$scope.users = result;
Client.getDomains(function (error, result) {
if (error) return console.error('Unable to get domain listing.', error);
// reset so we can push the fresh config
$scope.emailDomains = [];
asyncForEach(result, function (domain, callback) {
Client.getMailConfigForDomain(domain.domain, function (error, mailConfig) {
if (error) return callback(error);
domain.mailConfig = mailConfig;
// only collect domains where email is enabled
if (mailConfig.enabled) $scope.emailDomains.push(domain);
callback();
});
}, function (error) {
if (error) return console.error('Unable to get mail config for domains.', error);
$scope.ready = true;
});
});
});
});
}
Client.onReady(refresh);
// setup all the dialog focus handling
['userAddModal', 'userRemoveModal', 'userEditModal', 'groupAddModal', 'groupRemoveModal'].forEach(function (id) {
$('#' + id).on('shown.bs.modal', function () {
$(this).find("[autofocus]:first").focus();
});
});
var clipboard = new Clipboard('#setupLinkButton');
clipboard.on('success', function(e) {
$('#setupLinkButton').tooltip({
title: 'Copied!',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#setupLinkButton').tooltip('hide'); }, 2000);
e.clearSelection();
});
clipboard.on('error', function(e) {
$('#setupLinkButton').tooltip({
title: 'Press Ctrl+C to copy',
trigger: 'manual'
}).tooltip('show');
$timeout(function () { $('#setupLinkButton').tooltip('hide'); }, 2000);
});
$('.modal-backdrop').remove();
}]);