move files to dashboard/
This commit is contained in:
@@ -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 }} </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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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;"> </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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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:
|
||||
<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:
|
||||
<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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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">Let’s 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 Let’s 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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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>
|
||||
|
||||
<span class="text-primary">Used {{ diskUsage['system'].used }} GB</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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">×</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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}]);
|
||||
@@ -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 }} <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>
|
||||
@@ -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();
|
||||
}]);
|
||||
Reference in New Issue
Block a user