Browse Source

Settings to vuejs

Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
tags/v14.0.0beta1
John Molakvoæ (skjnldsv) 6 years ago
parent
commit
c8f670dd8f
No account linked to committer's email address
42 changed files with 2655 additions and 6610 deletions
  1. 180
    3
      core/css/inputs.scss
  2. 6
    0
      settings/.babelrc
  3. 9
    0
      settings/.editorconfig
  4. 12
    0
      settings/.gitignore
  5. 0
    1018
      settings/Controller/UsersController.php
  6. 18
    0
      settings/README.md
  7. 171
    95
      settings/css/settings.scss
  8. 11
    0
      settings/index.html
  9. 568
    0
      settings/js/main.js
  10. 1
    0
      settings/js/main.js.map
  11. 0
    213
      settings/js/users/deleteHandler.js
  12. 0
    78
      settings/js/users/filter.js
  13. 0
    385
      settings/js/users/groups.js
  14. 0
    1189
      settings/js/users/users.js
  15. 24
    0
      settings/main.php
  16. 42
    0
      settings/package.json
  17. 1
    1
      settings/routes.php
  18. 3
    0
      settings/src/.jshintrc
  19. 16
    0
      settings/src/App.vue
  20. 32
    0
      settings/src/components/appNavigation.vue
  21. 108
    0
      settings/src/components/appNavigation/navigationItem.vue
  22. 18
    0
      settings/src/components/popoverMenu.vue
  23. 23
    0
      settings/src/components/popoverMenu/popoverItem.vue
  24. 205
    0
      settings/src/components/userList.vue
  25. 370
    0
      settings/src/components/userList/userRow.vue
  26. 20
    0
      settings/src/main.js
  27. 23
    0
      settings/src/router.js
  28. 50
    0
      settings/src/store/api.js
  29. 24
    0
      settings/src/store/index.js
  30. 18
    0
      settings/src/store/settings.js
  31. 380
    0
      settings/src/store/users.js
  32. 152
    0
      settings/src/views/Users.vue
  33. 26
    8
      settings/templates/settings.php
  34. 0
    80
      settings/templates/users/main.php
  35. 0
    3
      settings/templates/users/part.createuser.php
  36. 0
    69
      settings/templates/users/part.grouplist.php
  37. 0
    35
      settings/templates/users/part.setquota.php
  38. 0
    149
      settings/templates/users/part.userlist.php
  39. 0
    220
      settings/tests/js/users/deleteHandlerSpec.js
  40. 36
    42
      settings/users.php
  41. 108
    0
      settings/webpack.config.js
  42. 0
    3022
      tests/Settings/Controller/UsersControllerTest.php

+ 180
- 3
core/css/inputs.scss View File

@@ -67,6 +67,13 @@ div[contenteditable=true],
cursor: default;
opacity: 0.5;
}
&:required {
box-shadow: none;
}
&:invalid {
box-shadow: none !important;
border-color: $color-error;
}
/* Primary action button, use sparingly */
&.primary {
background-color: $color-primary-element;
@@ -216,7 +223,8 @@ input {
margin-left: -8px !important;
border-left-color: transparent !important;
border-radius: 0 $border-radius $border-radius 0 !important;
background-clip: padding-box; /* Avoid background under border */
background-clip: padding-box;
/* Avoid background under border */
background-color: $color-main-background !important;
opacity: 1;
width: 34px;
@@ -227,6 +235,7 @@ input {
background-image: url('../img/actions/confirm-fade.svg?v=2') !important;
}
}

/* only show confirm borders if input is not focused */
&:not(:active):not(:hover):not(:focus){
+ .icon-confirm {
@@ -244,14 +253,19 @@ input {
&:active,
&:hover,
&:focus {
&:invalid {
+ .icon-confirm {
border-color: $color-error;
}
}
+ .icon-confirm {
border-color: $color-primary-element !important;
border-left-color: transparent !important;
z-index: 2; /* above previous input */
/* above previous input */
z-index: 2;
}
}
}

}


@@ -606,6 +620,169 @@ input {
}
}


/* Vue multiselect */
.multiselect.multiselect-vue {
margin: 1px 2px;
padding: 0 !important;
display: inline-block;
min-width: 160px;
width: 160px;
position: relative;
background-color: $color-main-background;
&.multiselect--active {
/* Opened: force display the input */
input.multiselect__input {
opacity: 1 !important;
}
}
&.multiselect--disabled {
background-color: nc-darken($color-main-background, 8%);
}
.multiselect__tags {
display: flex;
flex-wrap: nowrap;
overflow: hidden;
border: 1px solid nc-darken($color-main-background, 14%);
cursor: pointer;
position: relative;
border-radius: 3px;
height: 38px;
/* tag wrapper */
.multiselect__tags-wrap {
align-items: center;
display: inline-flex;
overflow: hidden;
max-width: 100%;
position: relative;
padding: 3px 5px;
/* no tags or simple select? Show input directly
input is used to display single value */
&:empty ~ input.multiselect__input {
opacity: 1 !important;
/* hide default empty text, show input instead */
+ span:not(.multiselect__single) {
display: none;
}
}
/* selected tag */
.multiselect__tag {
flex: 0 0 auto;
line-height: 20px;
padding: 1px 5px;
background-image: none;
color: nc-lighten($color-main-text, 33%);
border: 1px solid nc-darken($color-main-background, 14%);
display: inline-flex;
align-items: center;
border-radius: 3px;
margin-right: 5px;
}
}
/* Single select default value */
.multiselect__single {
padding: 8px 10px;
flex: 0 0 100%;
z-index: 5;
background-color: $color-main-background;
cursor: pointer;
}
/* displayed text if tag limit reached */
.multiselect__strong {
flex: 0 0 auto;
line-height: 20px;
color: nc-lighten($color-main-text, 33%);
display: inline-flex;
align-items: center;
opacity: .7;
}
/* default multiselect input for search and placeholder */
input.multiselect__input {
width: 100% !important;
position: absolute !important;
margin: 0;
opacity: 0;
/* let's leave it on top of tags but hide it */
height: 100%;
border: none;
/* override hide to force show the placeholder */
display: block !important;
}
}
/* results wrapper */
.multiselect__content-wrapper {
position: absolute;
width: 100%;
margin-top: -1px;
border: 1px solid nc-darken($color-main-background, 14%);
background: $color-main-background;
z-index: 50;
.multiselect__content {
width: 100%;
padding: 5px 0;
}
li {
padding: 5px;
position: relative;
display: flex;
align-items: center;
background-color: transparent;
&,
span {
cursor: pointer;
}
> span {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
height: 20px;
margin: 0;
min-height: 1em;
-webkit-touch-callout: none;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
display: inline-flex;
align-items: center;
background-color: transparent !important;
color: nc-lighten($color-main-text, 33%);
width: 100%;
/* selected checkmark icon */
&::before {
content: ' ';
background-image: url('../img/actions/checkmark.svg?v=1');
background-repeat: no-repeat;
background-position: center;
min-width: 16px;
min-height: 16px;
display: block;
opacity: 0.5;
margin-right: 5px;
visibility: hidden;
}
/* add the prop tag-placeholder="create" to add the +
* icon on top of an unknown-and-ready-to-be-created entry
*/
&[data-select='create'] {
&::before {
background-image: url('../img/actions/add.svg?v=1');
visibility: visible;
}
}
&.multiselect__option--highlight {
color: $color-main-text;
}
&.multiselect__option--selected {
&::before {
visibility: visible;
}
}
}
}
}
}

/* Progressbar */
progress {
display: block;

+ 6
- 0
settings/.babelrc View File

@@ -0,0 +1,6 @@
{
"presets": [
["env", { "modules": false }],
"stage-3"
]
}

+ 9
- 0
settings/.editorconfig View File

@@ -0,0 +1,9 @@
root = true

[*]
charset = utf-8
indent_style = space
indent_size = 2
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

+ 12
- 0
settings/.gitignore View File

@@ -0,0 +1,12 @@
.DS_Store
node_modules/
dist/
npm-debug.log
yarn-error.log

# Editor directories and files
.idea
*.suo
*.ntvs*
*.njsproj
*.sln

+ 0
- 1018
settings/Controller/UsersController.php
File diff suppressed because it is too large
View File


+ 18
- 0
settings/README.md View File

@@ -0,0 +1,18 @@
# settings

> A Vue.js project

## Build Setup

``` bash
# install dependencies
npm install

# serve with hot reload at localhost:8080
npm run dev

# build for production with minification
npm run build
```

For detailed explanation on how things work, consult the [docs for vue-loader](http://vuejs.github.io/vue-loader).

+ 171
- 95
settings/css/settings.scss View File

@@ -675,101 +675,6 @@ span.usersLastLoginTooltip {
}
}

tr:hover > td {
&.password > span, &.displayName > span, &.mailAddress > span {
margin: 0;
cursor: pointer;
}
&.password > img, &.displayName > img, &.mailAddress > img {
visibility: visible;
cursor: pointer;
}
}

td.userActions {
.toggleUserActions {
width: 44px;
height: 44px;
position: relative;
.action {
display: block;
padding: 14px;
opacity: 0.5;
.icon-more {
display: inline-block;
}
&:hover,
&:focus {
opacity: 1;
}
}
}
}

div.recoveryPassword {
left: 50em;
display: block;
position: absolute;
top: -1px;
}

input#recoveryPassword {
width: 15em;
}

#controls select.quota {
margin: 3px;
margin-right: 10px;
height: 37px;
}

#userlist td.quota {
position: relative;
width: 10em;
progress.quota-user-progress {
position: absolute;
width: calc(10em + 0px);
margin-top: -7px;
z-index: 0;
margin-left: 1px;
height: 3px;
}
}

select {
&.quota-user {
width: 10em;
height: 34px;
z-index: 50;
position: relative;
}
+ progress.quota-user-progress {
position: absolute;
width: calc(10em + 0px);
margin-top: -7px;
z-index: 0;
margin-left: 1px;
height: 3px;
}
}

input.userFilter {
width: 200px;
}

#newusergroups + input[type='submit'] {
position: relative;
top: -1px;
}

#headerGroups, #headerSubAdmins, #headerQuota {
padding-left: 18px;
}

#headerAvatar {
width: 32px;
}

/* used to highlight a user row in red */

#userlist tr.row-warning {
@@ -1350,3 +1255,174 @@ doesnotexist:-o-prefocus, .strengthify-wrapper {
margin-top: 22px;
}
}


/* USERS LIST -------------------------------------------------------------- */
#body-settings {
#app-navigation {
/* Hack to override the javascript orderBy */
#usergrouplist > li {
order: 4;
&#_everyone {
order:1;
}
&#admin {
order:2;
}
&#_disabled {
order:3;
}
}
}
$grid-row-height: 46px;
#app-content.user-list-grid {
display: grid;
grid-auto-columns: 1fr;
grid-auto-rows: $grid-row-height;
grid-column-gap: 20px;
.row {
display: grid;
grid-row-start: span 1;
align-items: center;
/* let's define the column until storage path,
what follows will be manually defined */
grid-template-columns: 44px;
grid-auto-columns: min-content;
border-top: $color-border 1px solid;
.name,
.displayName,
.password {
width: 150px;
}
.mailAddress{
width: 200px;
}
.groups,
.subadmins,
.quota {
width: 170px;
}
.storageLocation {
width: 250px;
}
.userBackend,
.lastLogin,
.userActions {
width: 100px;
}
&#grid-header,
&#new-user {
position: sticky;
align-self: normal;
background-color: $color-main-background;
z-index: 55; /* above multiselect */
top: 0;
&.sticky {
box-shadow: 0 -2px 10px 1px $color-box-shadow;
}
}
&#grid-header {
color: nc-lighten($color-main-text, 60%);
z-index: 60; /* above new-user */
}
&#new-user {
top: $grid-row-height;
}
&:hover {
input:not([type='submit']):not(:focus):not(:active) {
border-color: nc-darken($color-main-background, 14%) !important;
}
}
> div,
> form {
grid-row: 1;
display: inline-flex;
align-items: center;
color: nc-lighten($color-main-text, 33%);
position: relative;
> input:not(:focus):not(:active) {
border-color: transparent;
cursor: pointer;
}
> input:focus, >input:active {
+ .icon-confirm {
display: block !important;
}
}
&:not(.userActions) > input:not([type='submit']) {
width: 100%;
min-width: 0;
}
&.quota {
.multiselect--active + progress {
display: none;
}
progress {
position: absolute;
width: 160px;
left: 2px;
bottom: 2px;
height: 3px;
}
}
.icon-confirm {
width: 32px;
height: 32px;
flex: 0 0 32px;
cursor: pointer;
&:not(:active) {
display: none;
}
}
&.avatar {
height: 32px;
width: 32px;
margin: 6px;
img {
display: block;
}
}
.toggleUserActions {
position: relative;
.icon-more {
width: 44px;
height: 44px;
opacity: .5;
cursor: pointer;
:hover {
opacity: .7;
}
}
}
.v-select {
&.open .selected-tag-wrap {
display: none;
}
.dropdown-toggle .selected-tag {
padding-right: 5px;
.close {
/* no delete on tags*/
display: none;
}
}
.dropdown-menu li a .icon-add {
position: absolute;
width: 16px;
height: 16px;
opacity: .5;
left: 7px;
}
}
}
}
.infinite-loading-container {
display: flex;
align-items: center;
justify-content: center;
}
.users-list-end {
opacity: .5;
user-select: none;
}
}
}

+ 11
- 0
settings/index.html View File

@@ -0,0 +1,11 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>settings</title>
</head>
<body>
<div id="app"></div>
<script src="/dist/build.js"></script>
</body>
</html>

+ 568
- 0
settings/js/main.js
File diff suppressed because it is too large
View File


+ 1
- 0
settings/js/main.js.map
File diff suppressed because it is too large
View File


+ 0
- 213
settings/js/users/deleteHandler.js View File

@@ -1,213 +0,0 @@
/**
* Copyright (c) 2014, Arthur Schiwon <blizzz@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or later.
* See the COPYING-README file.
*/

/**
* takes care of deleting things represented by an ID
*
* @class
* @param {string} endpoint the corresponding ajax PHP script. Currently limited
* to settings - ajax path.
* @param {string} paramID the by the script expected parameter name holding the
* ID of the object to delete
* @param {markCallback} markCallback function to be called after successfully
* marking the object for deletion.
* @param {removeCallback} removeCallback the function to be called after
* successful delete.
*/

/* globals escapeHTML */

function DeleteHandler(endpoint, paramID, markCallback, removeCallback) {
this.oidToDelete = false;
this.canceled = false;

this.ajaxEndpoint = endpoint;
this.ajaxParamID = paramID;

this.markCallback = markCallback;
this.removeCallback = removeCallback;
this.undoCallback = false;

this.notifier = false;
this.notificationDataID = false;
this.notificationMessage = false;
this.notificationPlaceholder = '%oid';
}

/**
* Number of milliseconds after which the operation is performed.
*/
DeleteHandler.TIMEOUT_MS = 7000;

/**
* Timer after which the action will be performed anyway.
*/
DeleteHandler.prototype._timeout = null;

/**
* The function to be called after successfully marking the object for deletion
* @callback markCallback
* @param {string} oid the ID of the specific user or group
*/

/**
* The function to be called after successful delete. The id of the object will
* be passed as argument. Unsuccessful operations will display an error using
* OC.dialogs, no callback is fired.
* @callback removeCallback
* @param {string} oid the ID of the specific user or group
*/

/**
* This callback is fired after "undo" was clicked so the consumer can update
* the web interface
* @callback undoCallback
* @param {string} oid the ID of the specific user or group
*/

/**
* enabled the notification system. Required for undo UI.
*
* @param {object} notifier Usually OC.Notification
* @param {string} dataID an identifier for the notifier, e.g. 'deleteuser'
* @param {string} message the message that should be shown upon delete. %oid
* will be replaced with the affected id of the item to be deleted
* @param {undoCallback} undoCallback called after "undo" was clicked
*/
DeleteHandler.prototype.setNotification = function(notifier, dataID, message, undoCallback) {
this.notifier = notifier;
this.notificationDataID = dataID;
this.notificationMessage = message;
this.undoCallback = undoCallback;

var dh = this;

$('#notification')
.off('click.deleteHandler_' + dataID)
.on('click.deleteHandler_' + dataID, '.undo', function () {
if ($('#notification').data(dh.notificationDataID)) {
var oid = dh.oidToDelete;
dh.cancel();
if(typeof dh.undoCallback !== 'undefined') {
dh.undoCallback(oid);
}
}
dh.notifier.hide();
});
};

/**
* shows the Undo Notification (if configured)
*/
DeleteHandler.prototype.showNotification = function() {
if(this.notifier !== false) {
if(!this.notifier.isHidden()) {
this.hideNotification();
}
$('#notification').data(this.notificationDataID, true);
var msg = this.notificationMessage.replace(
this.notificationPlaceholder, escapeHTML(this.oidToDelete));
this.notifier.showHtml(msg);
}
};

/**
* hides the Undo Notification
*/
DeleteHandler.prototype.hideNotification = function() {
if(this.notifier !== false) {
$('#notification').removeData(this.notificationDataID);
this.notifier.hide();
}
};

/**
* initializes the delete operation for a given object id
*
* @param {string} oid the object id
*/
DeleteHandler.prototype.mark = function(oid) {
if(this.oidToDelete !== false) {
// passing true to avoid hiding the notification
// twice and causing the second notification
// to disappear immediately
this.deleteEntry(true);
}
this.oidToDelete = oid;
this.canceled = false;
this.markCallback(oid);
this.showNotification();
if (this._timeout) {
clearTimeout(this._timeout);
this._timeout = null;
}
if (DeleteHandler.TIMEOUT_MS > 0) {
this._timeout = window.setTimeout(
_.bind(this.deleteEntry, this),
DeleteHandler.TIMEOUT_MS
);
}
};

/**
* cancels a delete operation
*/
DeleteHandler.prototype.cancel = function() {
if (this._timeout) {
clearTimeout(this._timeout);
this._timeout = null;
}

this.canceled = true;
this.oidToDelete = false;
};

/**
* executes a delete operation. Requires that the operation has been
* initialized by mark(). On error, it will show a message via
* OC.dialogs.alert. On success, a callback is fired so that the client can
* update the web interface accordingly.
*
* @param {boolean} [keepNotification] true to keep the notification, false to hide
* it, defaults to false
*/
DeleteHandler.prototype.deleteEntry = function(keepNotification) {
var deferred = $.Deferred();
if(this.canceled || this.oidToDelete === false) {
return deferred.resolve().promise();
}

var dh = this;
if(!keepNotification && $('#notification').data(this.notificationDataID) === true) {
dh.hideNotification();
}

if (this._timeout) {
clearTimeout(this._timeout);
this._timeout = null;
}

var payload = {};
payload[dh.ajaxParamID] = dh.oidToDelete;
return $.ajax({
type: 'DELETE',
url: OC.generateUrl(dh.ajaxEndpoint+'/{oid}',{oid: this.oidToDelete}),
// FIXME: do not use synchronous ajax calls as they block the browser !
async: false,
success: function (result) {
// Remove undo option, & remove user from table

//TODO: following line
dh.removeCallback(dh.oidToDelete);
dh.canceled = true;
},
error: function (jqXHR) {
OC.dialogs.alert(jqXHR.responseJSON.data.message, t('settings', 'Unable to delete {objName}', {objName: dh.oidToDelete}));
dh.undoCallback(dh.oidToDelete);

}
});
};

+ 0
- 78
settings/js/users/filter.js View File

@@ -1,78 +0,0 @@
/**
* Copyright (c) 2014, Arthur Schiwon <blizzz@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or later.
* See the COPYING-README file.
*/

/**
* @brief this object takes care of the filter functionality on the user
* management page
* @param {UserList} userList the UserList object
* @param {GroupList} groupList the GroupList object
*/
function UserManagementFilter (userList, groupList) {
this.userList = userList;
this.groupList = groupList;
this.oldFilter = '';

this.init();
}

/**
* @brief sets up when the filter action shall be triggered
*/
UserManagementFilter.prototype.init = function () {
OC.Plugins.register('OCA.Search', this);
};

/**
* @brief the filter action needs to be done, here the accurate steps are being
* taken care of
*/
UserManagementFilter.prototype.run = _.debounce(function (filter) {
if (filter === this.oldFilter) {
return;
}
this.oldFilter = filter;
this.userList.filter = filter;
this.userList.empty();
this.userList.update(GroupList.getCurrentGID());
if (this.groupList.filterGroups) {
// user counts are being updated nevertheless
this.groupList.empty();
}
this.groupList.update();
},
300
);

/**
* @brief returns the filter String
* @returns string
*/
UserManagementFilter.prototype.getPattern = function () {
var input = this.filterInput.val(),
html = $('html'),
isIE8or9 = html.hasClass('lte9');
// FIXME - TODO - once support for IE8 and IE9 is dropped
if (isIE8or9 && input == this.filterInput.attr('placeholder')) {
input = '';
}
return input;
};

/**
* @brief adds reset functionality to an HTML element
* @param jQuery the jQuery representation of that element
*/
UserManagementFilter.prototype.addResetButton = function (button) {
var umf = this;
button.click(function () {
umf.filterInput.val('');
umf.run();
});
};

UserManagementFilter.prototype.attach = function (search) {
search.setFilter('settings', this.run.bind(this));
};

+ 0
- 385
settings/js/users/groups.js View File

@@ -1,385 +0,0 @@
/**
* Copyright (c) 2014, Raghu Nayyar <beingminimal@gmail.com>
* Copyright (c) 2014, Arthur Schiwon <blizzz@owncloud.com>
* This file is licensed under the Affero General Public License version 3 or later.
* See the COPYING-README file.
*/

/* globals escapeHTML, UserList, DeleteHandler */

var $userGroupList,
$sortGroupBy;

var GroupList;
GroupList = {
activeGID: '',
everyoneGID: '_everyone',
filter: '',
filterGroups: false,

addGroup: function (gid, displayName, usercount) {
if (_.isUndefined(displayName)) {
displayName = gid;
}
var $li = $userGroupList.find('.isgroup:last-child').clone();
$li
.data('gid', gid)
.find('.groupname').text(displayName);
GroupList.setUserCount($li, usercount);

$li.appendTo($userGroupList);

GroupList.sortGroups();

return $li;
},

setUserCount: function (groupLiElement, usercount) {
if ($sortGroupBy !== 1) {
// If we don't sort by group count we don't display them either
return;
}

var $groupLiElement = $(groupLiElement);
if (usercount === undefined || usercount === 0 || usercount < 0) {
usercount = '';
$groupLiElement.data('usercount', 0);
} else {
$groupLiElement.data('usercount', usercount);
}
$groupLiElement.find('.usercount').text(usercount);
},

getUserCount: function ($groupLiElement) {
var count = parseInt($groupLiElement.data('usercount'), 10);
return isNaN(count) ? 0 : count;
},

modGroupCount: function(gid, diff) {
var $li = GroupList.getGroupLI(gid);
var count = GroupList.getUserCount($li) + diff;
GroupList.setUserCount($li, count);
},

incEveryoneCount: function() {
GroupList.modGroupCount(GroupList.everyoneGID, 1);
},

decEveryoneCount: function() {
GroupList.modGroupCount(GroupList.everyoneGID, -1);
},

incGroupCount: function(gid) {
GroupList.modGroupCount(gid, 1);
},

decGroupCount: function(gid) {
GroupList.modGroupCount(gid, -1);
},

getCurrentGID: function () {
return GroupList.activeGID;
},

sortGroups: function () {
var lis = $userGroupList.find('.isgroup').get();

lis.sort(function (a, b) {
// "Everyone" always at the top
if ($(a).data('gid') === '_everyone') {
return -1;
} else if ($(b).data('gid') === '_everyone') {
return 1;
}

// "admin" always as second
if ($(a).data('gid') === 'admin') {
return -1;
} else if ($(b).data('gid') === 'admin') {
return 1;
}

if ($sortGroupBy === 1) {
// Sort by user count first
var $usersGroupA = $(a).data('usercount'),
$usersGroupB = $(b).data('usercount');
if ($usersGroupA > 0 && $usersGroupA > $usersGroupB) {
return -1;
}
if ($usersGroupB > 0 && $usersGroupB > $usersGroupA) {
return 1;
}
}

// Fallback or sort by group name
return UserList.alphanum(
$(a).find('a span').text(),
$(b).find('a span').text()
);
});

var items = [];
$.each(lis, function (index, li) {
items.push(li);
if (items.length === 100) {
$userGroupList.append(items);
items = [];
}
});
if (items.length > 0) {
$userGroupList.append(items);
}
},

createGroup: function (groupid) {
if (OC.PasswordConfirmation.requiresPasswordConfirmation()) {
OC.PasswordConfirmation.requirePasswordConfirmation(_.bind(this.createGroup, this, groupid));
return;
}

$.post(
OC.generateUrl('/settings/users/groups'),
{
id: groupid
},
function (result) {
if (result.groupname) {
var addedGroup = result.groupname;
UserList.availableGroups[groupid] = {displayName: result.groupname};
GroupList.addGroup(groupid, result.groupname);
}
GroupList.toggleAddGroup();
}).fail(function(result) {
OC.Notification.showTemporary(t('settings', 'Error creating group: {message}', {message: result.responseJSON.message}));
});
},

update: function () {
if (GroupList.updating) {
return;
}
GroupList.updating = true;
$.get(
OC.generateUrl('/settings/users/groups'),
{
pattern: this.filter,
filterGroups: this.filterGroups ? 1 : 0,
sortGroups: $sortGroupBy
},
function (result) {

var lis = [];
if (result.status === 'success') {
$.each(result.data, function (i, subset) {
$.each(subset, function (index, group) {
if (GroupList.getGroupLI(group.name).length > 0) {
GroupList.setUserCount(GroupList.getGroupLI(group.name).first(), group.usercount);
}
else {
var $li = GroupList.addGroup(group.id, group.name, group.usercount);

$li.addClass('appear transparent');
lis.push($li);
}
});
});
if (result.data.length > 0) {
GroupList.doSort();
}
else {
GroupList.noMoreEntries = true;
}
_.defer(function () {
$(lis).each(function () {
this.removeClass('transparent');
});
});
}
GroupList.updating = false;

}
);
},

elementBelongsToAddGroup: function (el) {
return !(el !== $('#newgroup-form').get(0) &&
$('#newgroup-form').find($(el)).length === 0);
},

hasAddGroupNameText: function () {
var name = $('#newgroupname').val();
return $.trim(name) !== '';

},

showDisabledUsers: function () {
UserList.empty();
UserList.update('_disabledUsers');
$userGroupList.find('li').removeClass('active');
GroupList.getGroupLI('_disabledUsers').addClass('active');
},

showGroup: function (gid) {
GroupList.activeGID = gid;
UserList.empty();
UserList.update(gid === '_everyone' ? '' : gid);
$userGroupList.find('li').removeClass('active');
if (gid !== undefined) {
GroupList.getGroupLI(gid).addClass('active');
}
},

isAddGroupButtonVisible: function () {
return !$('#newgroup-entry').hasClass('editing');
},

toggleAddGroup: function (event) {
if (GroupList.isAddGroupButtonVisible()) {
if (event) {
event.stopPropagation();
}
$('#newgroup-entry').addClass('editing');
$('#newgroupname').select();
GroupList.handleAddGroupInput('');
}
else {
$('#newgroup-entry').removeClass('editing');
$('#newgroupname').val('');
}
},

handleAddGroupInput: function (input) {
if(input.length) {
$('#newgroup-form input[type="submit"]').attr('disabled', null);
} else {
$('#newgroup-form input[type="submit"]').attr('disabled', 'disabled');
}
},

isGroupNameValid: function (groupname) {
if ($.trim(groupname) === '') {
OC.Notification.showTemporary(t('settings', 'Error creating group: {message}', {
message: t('settings', 'A valid group name must be provided')
}));
return false;
}
return true;
},

hide: function (gid) {
GroupList.getGroupLI(gid).hide();
},
show: function (gid) {
GroupList.getGroupLI(gid).show();
},
remove: function (gid) {
GroupList.getGroupLI(gid).remove();
},
empty: function () {
$userGroupList.find('.isgroup').filter(function(index, item){
return $(item).data('gid') !== '';
}).remove();
},
initDeleteHandling: function () {
//set up handler
var GroupDeleteHandler = new DeleteHandler('/settings/users/groups', 'groupname',
GroupList.hide, GroupList.remove);

//configure undo
OC.Notification.hide();
var msg = escapeHTML(t('settings', 'deleted {groupName}', {groupName: '%oid'})) + '<span class="undo">' +
escapeHTML(t('settings', 'undo')) + '</span>';
GroupDeleteHandler.setNotification(OC.Notification, 'deletegroup', msg,
GroupList.show);

//when to mark user for delete
var deleteAction = function () {
if (OC.PasswordConfirmation.requiresPasswordConfirmation()) {
OC.PasswordConfirmation.requirePasswordConfirmation(_.bind(deleteAction, this));
return;
}

// Call function for handling delete/undo
GroupDeleteHandler.mark(GroupList.getElementGID($(this).parent()));
};
$userGroupList.on('click', '.delete', deleteAction);

//delete a marked user when leaving the page
$(window).on('beforeunload', function () {
GroupDeleteHandler.deleteEntry();
});
},

getGroupLI: function (gid) {
return $userGroupList.find('li.isgroup').filter(function () {
return GroupList.getElementGID(this) === gid;
});
},

getElementGID: function (element) {
return ($(element).closest('li').data('gid') || '').toString();
},
getEveryoneCount: function () {
$.ajax({
type: "GET",
dataType: "json",
url: OC.generateUrl('/settings/users/stats')
}).success(function (data) {
$('#everyonegroup').data('usercount', data.totalUsers);
$('#everyonecount').text(data.totalUsers);
});
}
};

$(document).ready( function () {
$userGroupList = $('#usergrouplist');
GroupList.initDeleteHandling();
$sortGroupBy = $userGroupList.data('sort-groups');
if ($sortGroupBy === 1) {
// Disabled due to performance issues, when we don't need it for sorting
GroupList.getEveryoneCount();
}

// Display or hide of Create Group List Element
$('#newgroup-init').on('click', function (e) {
GroupList.toggleAddGroup(e);
});

$(document).on('click keydown keyup', function(event) {
if(!GroupList.isAddGroupButtonVisible() &&
!GroupList.elementBelongsToAddGroup(event.target) &&
!GroupList.hasAddGroupNameText()) {
GroupList.toggleAddGroup();
}
// Escape
if(!GroupList.isAddGroupButtonVisible() && event.keyCode && event.keyCode === 27) {
GroupList.toggleAddGroup();
}
});


// Responsible for Creating Groups.
$('#newgroup-form form').submit(function (event) {
event.preventDefault();
if(GroupList.isGroupNameValid($('#newgroupname').val())) {
GroupList.createGroup($('#newgroupname').val());
}
});

// click on group name
$userGroupList.on('click', '.isgroup', function () {
GroupList.showGroup(GroupList.getElementGID(this));
});

// show disabled users
$userGroupList.on('click', '.disabledusers', function () {
GroupList.showDisabledUsers();
});

$('#newgroupname').on('input', function(){
GroupList.handleAddGroupInput(this.value);
});

// highlight `everyone` group at DOMReady by default
GroupList.showGroup('_everyone');
});

+ 0
- 1189
settings/js/users/users.js
File diff suppressed because it is too large
View File


+ 24
- 0
settings/main.php View File

@@ -0,0 +1,24 @@
<?php
/**
* @copyright Copyright (c) 2018 John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
* @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
*
*
* @license AGPL-3.0
*
* This code is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License, version 3,
* as published by the Free Software Foundation.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/

$tmpl = new OC_Template("settings", "settings", "user");
$tmpl->printPage();

+ 42
- 0
settings/package.json View File

@@ -0,0 +1,42 @@
{
"name": "settings",
"description": "Nextcloud settings",
"version": "1.0.0",
"author": "John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>",
"license": "AGPL3",
"private": true,
"scripts": {
"dev": "cross-env NODE_ENV=development webpack",
"watch": "cross-env NODE_ENV=development webpack --progress --watch",
"build": "cross-env NODE_ENV=production webpack --progress --hide-modules"
},
"dependencies": {
"axios": "^0.18.0",
"vue": "^2.5.11",
"vue-click-outside": "^1.0.7",
"vue-infinite-loading": "^2.2.3",
"vue-localstorage": "^0.6.2",
"vue-multiselect": "^2.1",
"vue-router": "^3.0.1",
"vuex": "^3.0.1",
"vuex-router-sync": "^5.0.0"
},
"browserslist": [
"last 2 versions",
"ie >= 11"
],
"devDependencies": {
"babel-core": "^6.26.0",
"babel-loader": "^7.1.2",
"babel-preset-env": "^1.6.0",
"babel-preset-stage-3": "^6.24.1",
"cross-env": "^5.0.5",
"css-loader": "^0.28.7",
"file-loader": "^1.1.4",
"node-sass": "^4.5.3",
"sass-loader": "^6.0.6",
"vue-loader": "^13.0.5",
"vue-template-compiler": "^2.4.4",
"webpack": "^3.6.0"
}
}

+ 1
- 1
settings/routes.php View File

@@ -50,7 +50,7 @@ $application->registerRoutes($this, [
['name' => 'AppSettings#listCategories', 'url' => '/settings/apps/categories', 'verb' => 'GET'],
['name' => 'AppSettings#viewApps', 'url' => '/settings/apps', 'verb' => 'GET'],
['name' => 'AppSettings#listApps', 'url' => '/settings/apps/list', 'verb' => 'GET'],
['name' => 'Users#setDisplayName', 'url' => '/settings/users/{username}/displayName', 'verb' => 'POST'],
['name' => 'Users#setDisplayName', 'url' => '/settings/users/{id}/displayName', 'verb' => 'PUT'],
['name' => 'Users#setEMailAddress', 'url' => '/settings/users/{id}/mailAddress', 'verb' => 'PUT'],
['name' => 'Users#setUserSettings', 'url' => '/settings/users/{username}/settings', 'verb' => 'PUT'],
['name' => 'Users#getVerificationCode', 'url' => '/settings/users/{account}/verify', 'verb' => 'GET'],

+ 3
- 0
settings/src/.jshintrc View File

@@ -0,0 +1,3 @@
{
"esversion": 6
}

+ 16
- 0
settings/src/App.vue View File

@@ -0,0 +1,16 @@
<template>
<router-view></router-view>
</template>

<script>
export default {
name: 'App',
beforeMount: function () {
// importing server data into the store
const serverDataElmt = document.getElementById('serverData');
if (serverDataElmt !== null) {
this.$store.commit('setServerData', JSON.parse(document.getElementById('serverData').dataset.server));
}
}
}
</script>

+ 32
- 0
settings/src/components/appNavigation.vue View File

@@ -0,0 +1,32 @@
<template>
<div id="app-navigation">
<div class="app-navigation-new" v-if="menu.new">
<button type="button" :id="menu.new.id" :class="menu.new.icon" @click="menu.new.action">{{menu.new.text}}</button>
</div>
<ul :id="menu.id">
<navigation-item v-for="(item, key) in menu.items" :item="item" :key="key" />
</ul>
<div id="app-settings">
<div id="app-settings-header">
<button class="settings-button"
data-apps-slide-toggle="#app-settings-content"
>{{t('settings', 'Settings')}}</button>
</div>
<div id="app-settings-content">
<slot name="settings-content"></slot>
</div>
</div>
</div>
</template>

<script>
import navigationItem from './appNavigation/navigationItem';

export default {
name: 'appNavigation',
props: ['menu'],
components: {
navigationItem
}
}
</script>

+ 108
- 0
settings/src/components/appNavigation/navigationItem.vue View File

@@ -0,0 +1,108 @@
<template>
<li :id="item.id" :class="[{'icon-loading-small': item.loading, 'open': item.opened, 'collapsible': item.collapsible&&item.children&&item.children.length>0 }, item.classes]">

<!-- Bullet -->
<div v-if="item.bullet" class="app-navigation-entry-bullet" :style="{ backgroundColor: item.bullet }"></div>

<!-- Main link -->
<a :href="(item.href) ? item.href : '#' " @click="toggleCollapse" :class="item.icon" >{{item.text}}</a>

<!-- Popover, counter and button(s) -->
<div v-if="item.utils" class="app-navigation-entry-utils">
<ul>
<!-- counter -->
<li v-if="Number.isInteger(item.utils.counter)"
class="app-navigation-entry-utils-counter">{{item.utils.counter}}</li>

<!-- first action if only one action and counter -->
<li v-if="item.utils.actions && item.utils.actions.length === 1 && Number.isInteger(item.utils.counter)"
class="app-navigation-entry-utils-menu-button">
<button :class="item.utils.actions[0].icon"></button>
</li>

<!-- second action only two actions and no counter -->
<li v-else-if="item.utils.actions && item.utils.actions.length === 2 && !Number.isInteger(item.utils.counter)"
v-for="action in item.utils.actions" :key="action.action"
class="app-navigation-entry-utils-menu-button">
<button :class="action.icon"></button>
</li>

<!-- menu if only at least one action and counter OR two actions and no counter-->
<li v-else-if="item.utils.actions && item.utils.actions.length > 1 && (Number.isInteger(item.utils.counter) || item.utils.actions.length > 2)"
class="app-navigation-entry-utils-menu-button">
<button v-click-outside="hideMenu" @click="showMenu" ></button>
</li>
</ul>
</div>

<!-- if more than 2 actions or more than 1 actions with counter -->
<div v-if="item.utils && item.utils.actions && item.utils.actions.length > 1 && (Number.isInteger(item.utils.counter) || item.utils.actions.length > 2)"
class="app-navigation-entry-menu" :class="{ 'open': openedMenu }">
<popover-menu :menu="item.utils.actions"/>
</div>

<!-- undo entry -->
<div class="app-navigation-entry-deleted" v-if="item.undo">
<div class="app-navigation-entry-deleted-description">{{item.undo.text}}</div>
<button class="app-navigation-entry-deleted-button icon-history" :title="t('settings', 'Undo')"></button>
</div>

<!-- edit entry -->
<div class="app-navigation-entry-edit" v-if="item.edit">
<form>
<input type="text" v-model="item.text">
<input type="submit" value="" class="icon-confirm">
<input type="submit" value="" class="icon-close" @click.stop.prevent="cancelEdit">
</form>
</div>

<!-- if the item has children, inject the component with proper data -->
<ul v-if="item.children">
<navigation-item v-for="(item, key) in item.children" :item="item" :key="key" />
</ul>
</li>
</template>

<script>
import popoverMenu from '../popoverMenu';
import ClickOutside from 'vue-click-outside';
import Vue from 'vue';

export default {
name: 'navigationItem',
props: ['item'],
components: {
popoverMenu
},
directives: {
ClickOutside
},
data () {
return {
openedMenu: false
}
},
methods: {
showMenu () {
this.openedMenu = true;
},
hideMenu () {
this.openedMenu = false;
},
toggleCollapse () {
// if item.opened isn't set, Vue won't trigger view updates https://vuejs.org/v2/api/#Vue-set
// ternary is here to detect the undefined state of item.opened
Vue.set(this.item, 'opened', this.item.opened ? !this.item.opened : true);
},
cancelEdit () {
// remove the editing class
if (Array.isArray(this.item.classes))
this.item.classes = this.item.classes.filter(item => item !== 'editing');
}
},
mounted () {
// prevent click outside event with popupItem.
this.popupItem = this.$el;
},
}
</script>

+ 18
- 0
settings/src/components/popoverMenu.vue View File

@@ -0,0 +1,18 @@
<template>
<ul>
<popover-item v-for="(item, key) in menu" :item="item" :key="key" />
</ul>
</template>


<script>
import popoverItem from './popoverMenu/popoverItem';

export default {
name: 'popoverMenu',
props: ['menu'],
components: {
popoverItem
}
}
</script>

+ 23
- 0
settings/src/components/popoverMenu/popoverItem.vue View File

@@ -0,0 +1,23 @@
<template>
<li>
<a @click="dispatchToStore" v-if="item.href" :href="(item.href) ? item.href : '#' ">
<span :class="item.icon"></span>
<span>{{item.text}}</span>
</a>
<button @click="dispatchToStore(item.action)" v-else>
<span :class="item.icon"></span>
<span>{{item.text}}</span>
</button>
</li>
</template>

<script>
export default {
props: ['item'],
methods: {
dispatchToStore () {
this.$store.dispatch(this.item.action, this.item.data);
}
}
}
</script>

+ 205
- 0
settings/src/components/userList.vue View File

@@ -0,0 +1,205 @@
<template>
<div id="app-content" class="user-list-grid" v-on:scroll.passive="onScroll">
<div class="row" id="grid-header" :class="{'sticky': scrolled && !showConfig.showNewUserForm}">
<div id="headerAvatar" class="avatar"></div>
<div id="headerName" class="name">{{ t('settings', 'Username') }}</div>
<div id="headerDisplayName" class="displayName">{{ t('settings', 'Full name') }}</div>
<div id="headerPassword" class="password">{{ t('settings', 'Password') }}</div>
<div id="headerAddress" class="mailAddress">{{ t('settings', 'Email') }}</div>
<div id="headerGroups" class="groups">{{ t('settings', 'Groups') }}</div>
<div id="headerSubAdmins" class="subadmins"
v-if="subAdminsGroups.length>0">{{ t('settings', 'Group admin for') }}</div>
<div id="headerQuota" class="quota">{{ t('settings', 'Quota') }}</div>
<div class="headerStorageLocation storageLocation"
v-if="showConfig.showStoragePath">{{ t('settings', 'Storage location') }}</div>
<div class="headerUserBackend userBackend"
v-if="showConfig.showUserBackend">{{ t('settings', 'User backend') }}</div>
<div class="headerLastLogin lastLogin"
v-if="showConfig.showLastLogin">{{ t('settings', 'Last login') }}</div>
<div class="userActions"></div>
</div>

<form class="row" id="new-user" v-show="showConfig.showNewUserForm"
v-on:submit.prevent="createUser" :disabled="loading"
:class="{'sticky': scrolled && showConfig.showNewUserForm}">
<div :class="loading?'icon-loading-small':'icon-add'"></div>
<div class="name">
<input id="newusername" type="text" required v-model="newUser.id"
:placeholder="t('settings', 'User name')" name="username"
autocomplete="off" autocapitalize="none" autocorrect="off"
pattern="[a-zA-Z0-9 _\.@\-']+">
</div>
<div class="displayName">
<input id="newdisplayname" type="text" v-model="newUser.displayName"
:placeholder="t('settings', 'Display name')" name="displayname"
autocomplete="off" autocapitalize="none" autocorrect="off">
</div>
<div class="password">
<input id="newuserpassword" type="password" v-model="newUser.password"
:required="newUser.mailAddress===''"
:placeholder="t('settings', 'Password')" name="password"
autocomplete="new-password" autocapitalize="none" autocorrect="off"
:minlength="minPasswordLength">
</div>
<div class="mailAddress">
<input id="newemail" type="email" v-model="newUser.mailAddress"
:required="newUser.password===''"
:placeholder="t('settings', 'Mail address')" name="email"
autocomplete="off" autocapitalize="none" autocorrect="off">
</div>
<div class="groups">
<multiselect :options="groups" v-model="newUser.groups"
:placeholder="t('settings', 'Add user in group')"
label="name" track-by="id" class="multiselect-vue"
:multiple="true" :close-on-select="false">
<span slot="noResult">{{t('settings','No result')}}</span>
</multiselect>
</div>
<div class="subadmins" v-if="subAdminsGroups.length>0">
<multiselect :options="subAdminsGroups" v-model="newUser.subAdminsGroups"
:placeholder="t('settings', 'Set user as admin for')"
label="name" track-by="id" class="multiselect-vue"
:multiple="true" :close-on-select="false">
<span slot="noResult">{{t('settings','No result')}}</span>
</multiselect>
</div>
<div class="quota">
<multiselect :options="quotaOptions" v-model="newUser.quota"
:placeholder="t('settings', 'Select user quota')"
label="label" track-by="id" class="multiselect-vue"
:allowEmpty="false" :taggable="true"
@tag="validateQuota" >
</multiselect>
</div>
<div class="storageLocation" v-if="showConfig.showStoragePath"></div>
<div class="userBackend" v-if="showConfig.showUserBackend"></div>
<div class="lastLogin" v-if="showConfig.showLastLogin"></div>
<div class="userActions">
<input type="submit" id="newsubmit" class="button primary icon-checkmark-white has-tooltip"
value="" :title="t('settings', 'Add a new user')">
<input type="reset" id="newreset" class="button icon-close has-tooltip" @click="resetForm"
value="" :title="t('settings', 'Cancel and reset the form')">
</div>
</form>

<user-row v-for="(user, key) in users" :user="user" :key="key" :settings="settings" :showConfig="showConfig"
:groups="groups" :subAdminsGroups="subAdminsGroups" :quotaOptions="quotaOptions" />
<infinite-loading @infinite="infiniteHandler">
<span slot="spinner"><div class="users-icon-loading"></div></span>
<span slot="no-more"><div class="users-list-end">— {{t('settings', 'no more results')}} —</div></span>
</infinite-loading>
</div>
</template>

<script>
import userRow from './userList/userRow';
import Multiselect from 'vue-multiselect';
import InfiniteLoading from 'vue-infinite-loading';

export default {
name: 'userList',
props: ['users', 'showConfig'],
components: {
userRow,
Multiselect,
InfiniteLoading
},
data() {
let unlimitedQuota = {id:'none', label:t('settings', 'Unlimited')},
defaultQuota = {id:'default', label:t('settings', 'Default quota')};
return {
unlimitedQuota: unlimitedQuota,
defaultQuota: defaultQuota,
loading: false,
scrolled: false,
newUser: {
id:'',
displayName:'',
password:'',
mailAddress:'',
groups: [],
subAdminsGroups: [],
quota: defaultQuota
}
};
},
mounted() {
if (!this.settings.canChangePassword) {
OC.Notification.showTemporary(t('settings','Password change is disabled because the master key is disabled'));
}
},
computed: {
settings() {
return this.$store.getters.getServerData;
},
groups() {
// data provided php side + remove the disabled group
return this.$store.getters.getGroups.filter(group => group.id !== '_disabled');
},
subAdminsGroups() {
// data provided php side
return this.$store.getters.getServerData.subadmingroups;
},
quotaOptions() {
// convert the preset array into objects
let quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({id:cur, label:cur}), []);
// add default presets
quotaPreset.unshift(this.unlimitedQuota);
quotaPreset.unshift(this.defaultQuota);
return quotaPreset;
},
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength;
},
usersOffset() {
return this.$store.getters.getUsersOffset;
},
usersLimit() {
return this.$store.getters.getUsersLimit;
},
},
methods: {
onScroll(event) {
this.scrolled = event.target.scrollTop>0;
},

/**
* Validate quota string to make sure it's a valid human file size
*
* @param {string} quota Quota in readable format '5 GB'
* @returns {Object}
*/
validateQuota(quota) {
// only used for new presets sent through @Tag
let validQuota = OC.Util.computerFileSize(quota);
if (validQuota !== null && validQuota > 0) {
// unify format output
quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota));
return this.newUser.quota = {id: quota, label: quota};
}
// Default is unlimited
return this.newUser.quota = this.quotaOptions[0];
},

infiniteHandler($state) {
this.$store.dispatch('getUsers', {offset:this.usersOffset, limit:this.usersLimit})
.then((response) => {response?$state.loaded():$state.complete()});
},

resetForm () {
// revert form to original state
Object.assign(this.newUser, this.$options.data.call(this).newUser);
this.loading = false;
},
createUser() {
this.loading = true;
this.$store.dispatch('addUser', {
userid: this.newUser.id,
password: this.newUser.password,
email: this.newUser.mailAddress,
groups: this.newUser.groups.map(group => group.id)
}).then(() =>this.resetForm());
}
}
}
</script>

+ 370
- 0
settings/src/components/userList/userRow.vue View File

@@ -0,0 +1,370 @@
<template>
<div class="row">
<div class="avatar"><img alt="" width="32" height="32" :src="generateAvatar(user.id, 32)" :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"></div>
<div class="name">{{user.id}}</div>
<form class="displayName" :class="{'icon-loading-small': loading.displayName}" v-on:submit.prevent="updateDisplayName">
<input :id="'displayName'+user.id+rand" type="text"
:disabled="loading.displayName||loading.all"
:value="user.displayname" ref="displayName"
autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" />
<input type="submit" class="icon-confirm" value="" />
</form>
<form class="password" v-if="settings.canChangePassword" :class="{'icon-loading-small': loading.password}"
v-on:submit.prevent="updatePassword">
<input :id="'password'+user.id+rand" type="password" required
:disabled="loading.password||loading.all" :minlength="minPasswordLength"
value="" :placeholder="t('settings', 'New password')" ref="password"
autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" />
<input type="submit" class="icon-confirm" value="" />
</form>
<div v-else></div>
<form class="mailAddress" :class="{'icon-loading-small': loading.mailAddress}" v-on:submit.prevent="updateEmail">
<input :id="'mailAddress'+user.id+rand" type="email"
:disabled="loading.mailAddress||loading.all"
:value="user.email" ref="mailAddress"
autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" />
<input type="submit" class="icon-confirm" value="" />
</form>
<div class="groups" :class="{'icon-loading-small': loading.groups}">
<multiselect :value="userGroups" :options="groups" :disabled="loading.groups||loading.all"
tag-placeholder="create" :placeholder="t('settings', 'Add user in group')"
label="name" track-by="id" class="multiselect-vue"
:limit="2" :limitText="limitGroups"
:multiple="true" :taggable="true" :closeOnSelect="false"
@tag="createGroup" @select="addUserGroup" @remove="removeUserGroup">
</multiselect>
</div>
<div class="subadmins" v-if="subAdminsGroups.length>0" :class="{'icon-loading-small': loading.subadmins}">
<multiselect :value="userSubAdminsGroups" :options="subAdminsGroups" :disabled="loading.subadmins||loading.all"
:placeholder="t('settings', 'Set user as admin for')"
label="name" track-by="id" class="multiselect-vue"
:limit="2" :limitText="limitGroups"
:multiple="true" :closeOnSelect="false"
@select="addUserSubAdmin" @remove="removeUserSubAdmin">
<span slot="noResult">{{t('settings','No result')}}</span>
</multiselect>
</div>
<div class="quota" :class="{'icon-loading-small': loading.quota}">
<multiselect :value="userQuota" :options="quotaOptions" :disabled="loading.quota||loading.all"
tag-placeholder="create" :placeholder="t('settings', 'Select user quota')"
label="label" track-by="id" class="multiselect-vue"
:allowEmpty="false" :taggable="true"
@tag="validateQuota" @input="setUserQuota">
</multiselect>
<progress class="quota-user-progress" :class="{'warn':usedQuota>80}" :value="usedQuota" max="100"></progress>
</div>
<div class="storageLocation" v-if="showConfig.showStoragePath">{{user.storageLocation}}</div>
<div class="userBackend" v-if="showConfig.showUserBackend">{{user.backend}}</div>
<div class="lastLogin" v-if="showConfig.showLastLogin" :title="user.lastLogin>0 ? OC.Util.formatDate(user.lastLogin) : ''">
{{user.lastLogin>0 ? OC.Util.relativeModifiedDate(user.lastLogin) : t('settings','Never')}}
</div>
<div class="userActions">
<div class="toggleUserActions" v-if="OC.currentUser !== user.id && user.id !== 'admin'">
<div class="icon-more" v-click-outside="hideMenu" @click="showMenu"></div>
<div class="popovermenu" :class="{ 'open': openedMenu }">
<popover-menu :menu="userActions" />
</div>
</div>
</div>
</div>
</template>

<script>
import popoverMenu from '../popoverMenu';
import ClickOutside from 'vue-click-outside';
import Multiselect from 'vue-multiselect';
//import Multiselect from '../../../node_modules/vue-multiselect/src/index';

export default {
name: 'userRow',
props: ['user', 'settings', 'groups', 'subAdminsGroups', 'quotaOptions', 'showConfig'],
components: {
popoverMenu,
Multiselect
},
directives: {
ClickOutside
},
mounted() {
// prevent click outside event with popupItem.
this.popupItem = this.$el;
},
data() {
return {
rand: parseInt(Math.random() * 1000),
openedMenu: false,
loading: {
all: false,
displayName: false,
password: false,
mailAddress: false,
groups: false,
subadmins: false,
quota: false
}
}
},
computed: {
/* USER POPOVERMENU ACTIONS */
userActions() {
return [{
icon: 'icon-delete',
text: t('settings','Delete user'),
action: 'deleteUser',
data: this.user.id
},{
'icon': this.user.enabled ? 'icon-close' : 'icon-add',
'text': this.user.enabled ? t('settings','Disable user') : t('settings','Enable user'),
'action': 'enableDisableUser',
data: {userid: this.user.id, enabled: !this.user.enabled}
}]
},

/* GROUPS MANAGEMENT */
userGroups() {
let userGroups = this.groups.filter(group => this.user.groups.includes(group.id));
return userGroups;
},
userSubAdminsGroups() {
let userSubAdminsGroups = this.subAdminsGroups.filter(group => this.user.subadmin.includes(group.id));
return userSubAdminsGroups;
},

/* QUOTA MANAGEMENT */
usedQuota() {
let quota = this.user.quota.quota;
if (quota > 0) {
quota = Math.min(100, Math.round(this.user.quota.used / quota * 100));
} else {
var usedInGB = this.user.quota.used / (10 * Math.pow(2, 30));
//asymptotic curve approaching 50% at 10GB to visualize used stace with infinite quota
quota = 95 * (1 - (1 / (usedInGB + 1)));
}
return isNaN(quota) ? 0 : quota;
},
// Mapping saved values to objects
userQuota() {
if (this.user.quota.quota > 0) {
// if value is valid, let's map the quotaOptions or return custom quota
let humanQuota = OC.Util.humanFileSize(this.user.quota.quota);
let userQuota = this.quotaOptions.find(quota => quota.id === humanQuota);
return userQuota ? userQuota : {id:humanQuota, label:humanQuota};
} else if (this.user.quota.quota === 0 || this.user.quota.quota === 'default') {
// default quota is replaced by the proper value on load
return this.quotaOptions[0];
}
return this.quotaOptions[1]; // unlimited
},

/* PASSWORD POLICY? */
minPasswordLength() {
return this.$store.getters.getPasswordPolicyMinLength;
}
},
methods: {
/* MENU HANDLING */
showMenu () {
this.openedMenu = true;
},
hideMenu () {
this.openedMenu = false;
},

/**
* Generate avatar url
*
* @param {string} user The user name
* @param {int} size Size integer, default 32
* @returns {string}
*/
generateAvatar(user, size=32) {
return OC.generateUrl(
'/avatar/{user}/{size}?v={version}',
{
user: user,
size: size,
version: oc_userconfig.avatar.version
}
);
},


/**
* Format the limit text in the selected options
*
* @param {int} count elements left
* @returns {string}
*/
limitGroups(count) {
return '+'+count;
},

/**
* Set user displayName
*
* @param {string} displayName The display name
* @returns {Promise}
*/
updateDisplayName() {
let displayName = this.$refs.displayName.value;
this.loading.displayName = true;
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'displayname',
value: displayName
}).then(() => {
this.loading.displayName = false;
this.$refs.displayName.value = displayName;
});
},

/**
* Set user password
*
* @param {string} password The email adress
* @returns {Promise}
*/
updatePassword() {
let password = this.$refs.password.value;
this.loading.password = true;
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'password',
value: password
}).then(() => {
this.loading.password = false;
this.$refs.password.value = ''; // empty & show placeholder
});
},

/**
* Set user mailAddress
*
* @param {string} mailAddress The email adress
* @returns {Promise}
*/
updateEmail() {
let mailAddress = this.$refs.mailAddress.value;
this.loading.mailAddress = true;
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'email',
value: mailAddress
}).then(() => {
this.loading.mailAddress = false;
this.$refs.mailAddress.value = mailAddress;
});
},

/**
* Create a new group
*
* @param {string} groups Group id
* @returns {Promise}
*/
createGroup(gid) {
this.loading = {groups:true, subadmins:true}
this.$store.dispatch('addGroup', gid).then(() => {
this.loading = {groups:false, subadmins:false};
let userid = this.user.id;
this.$store.dispatch('addUserGroup', {userid, gid});
});
return this.$store.getters.getGroups[this.groups.length];
},

/**
* Add user to group
*
* @param {object} group Group object
* @returns {Promise}
*/
addUserGroup(group) {
this.loading.groups = true;
let userid = this.user.id;
let gid = group.id;
return this.$store.dispatch('addUserGroup', {userid, gid})
.then(() => this.loading.groups = false);
},

/**
* Remove user from group
*
* @param {object} group Group object
* @returns {Promise}
*/
removeUserGroup(group) {
this.loading.groups = true;
let userid = this.user.id;
let gid = group.id;
return this.$store.dispatch('removeUserGroup', {userid, gid})
.then(() => this.loading.groups = false);
},

/**
* Add user to group
*
* @param {object} group Group object
* @returns {Promise}
*/
addUserSubAdmin(group) {
this.loading.subadmins = true;
let userid = this.user.id;
let gid = group.id;
return this.$store.dispatch('addUserSubAdmin', {userid, gid})
.then(() => this.loading.subadmins = false);
},

/**
* Remove user from group
*
* @param {object} group Group object
* @returns {Promise}
*/
removeUserSubAdmin(group) {
this.loading.subadmins = true;
let userid = this.user.id;
let gid = group.id;
return this.$store.dispatch('removeUserSubAdmin', {userid, gid})
.then(() => this.loading.subadmins = false);
},


/**
* Validate quota string to make sure it's a valid human file size
*
* @param {string|Object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
* @returns {string}
*/
setUserQuota(quota = 'none') {
this.loading.quota = true;
// ensure we only send the preset id
quota = quota.id ? quota.id : quota;
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'quota',
value: quota
}).then(() => this.loading.quota = false);
return quota;
},

/**
* Validate quota string to make sure it's a valid human file size
*
* @param {string} quota Quota in readable format '5 GB'
* @returns {Promise|boolean}
*/
validateQuota(quota) {
// only used for new presets sent through @Tag
let validQuota = OC.Util.computerFileSize(quota);
if (validQuota === 0) {
return this.setUserQuota('none');
} else if (validQuota !== null) {
// unify format output
return this.setUserQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota)));
}
// if no valid doo not change
return false;
}
}
}
</script>

+ 20
- 0
settings/src/main.js View File

@@ -0,0 +1,20 @@
import Vue from 'vue';
import { sync } from 'vuex-router-sync';
import App from './App.vue';
import router from './router';
import store from './store';

sync(store, router);

// bind to window
Vue.prototype.t = t;
Vue.prototype.OC = OC;
Vue.prototype.oc_userconfig = oc_userconfig;

const app = new Vue({
router,
store,
render: h => h(App)
}).$mount('#content');

export { app, router, store };

+ 23
- 0
settings/src/router.js View File

@@ -0,0 +1,23 @@
import Vue from 'vue';
import Router from 'vue-router';
import Users from './views/Users';

Vue.use(Router);

/*
* This is the list of routes where the vuejs app will
* take over php to provide data
* You need to forward the php routing (routes.php) to
* /settings/main.php, where the vue-router will ensure
* the proper route.
* ⚠️ Routes needs to match the php routes.
*/

export default new Router({
mode: 'history',
base: window.location.pathName,
routes: [{
path: '/settings/users',
component: Users
}]
});

+ 50
- 0
settings/src/store/api.js View File

@@ -0,0 +1,50 @@
import axios from 'axios';

const requestToken = document.getElementsByTagName('head')[0].getAttribute('data-requesttoken');
const tokenHeaders = { headers: { requesttoken: requestToken } };

const sanitize = function(url) {
return url.replace(/\/$/, ''); // Remove last slash of url
}

export default {
requireAdmin() {
return new Promise(function(resolve, reject) {
setTimeout(reject, 5000); // automatically reject 5s if not ok
function waitForpassword() {
if (OC.PasswordConfirmation.requiresPasswordConfirmation()) {
setTimeout(waitForpassword, 500);
return;
}
resolve();
}
waitForpassword();
OC.PasswordConfirmation.requirePasswordConfirmation();
}).catch((error) => console.log('Required password not entered'));
},
get(url) {
return axios.get(sanitize(url), tokenHeaders)
.then((response) => Promise.resolve(response))
.catch((error) => Promise.reject(error));
},
post(url, data) {
return axios.post(sanitize(url), data, tokenHeaders)
.then((response) => Promise.resolve(response))
.catch((error) => Promise.reject(error));
},
patch(url, data) {
return axios.patch(sanitize(url), { data: data, headers: tokenHeaders.headers })
.then((response) => Promise.resolve(response))
.catch((error) => Promise.reject(error));
},
put(url, data) {
return axios.put(sanitize(url), data, tokenHeaders)
.then((response) => Promise.resolve(response))
.catch((error) => Promise.reject(error));
},
delete(url, data) {
return axios.delete(sanitize(url), { data: data, headers: tokenHeaders.headers })
.then((response) => Promise.resolve(response))
.catch((error) => Promise.reject(error));
}
};

+ 24
- 0
settings/src/store/index.js View File

@@ -0,0 +1,24 @@
import Vue from 'vue'
import Vuex from 'vuex'
import users from './users'
import settings from './settings'

Vue.use(Vuex)

const debug = process.env.NODE_ENV !== 'production'

const mutations = {
API_FAILURE(state, error) {
console.log(state, error);
}
}

export default new Vuex.Store({
modules: {
users,
settings
},
strict: debug,

mutations
})

+ 18
- 0
settings/src/store/settings.js View File

@@ -0,0 +1,18 @@
import api from './api';

const state = {
serverData: {}
};
const mutations = {
setServerData(state, data) {
state.serverData = data;
}
};
const getters = {
getServerData(state) {
return state.serverData;
}
}
const actions = {}

export default {state, mutations, getters, actions};

+ 380
- 0
settings/src/store/users.js View File

@@ -0,0 +1,380 @@
import api from './api';

const orderGroups = function(groups, orderBy) {
/* const SORT_USERCOUNT = 1;
* const SORT_GROUPNAME = 2;
* https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34
*/
if (orderBy === 1) {
return groups.sort((a, b) => a.usercount < b.usercount);
} else {
return groups.sort((a, b) => a.name.localeCompare(b.name));
}
}

const state = {
users: [],
groups: [],
orderBy: 1,
minPasswordLength: 0,
usersOffset: 0,
usersLimit: 25,
};

const mutations = {
appendUsers(state, usersObj) {
// convert obj to array
let users = state.users.concat(Object.keys(usersObj).map(userid => usersObj[userid]));
state.usersOffset += state.usersLimit;
state.users = users;
},
setPasswordPolicyMinLength(state, length) {
state.minPasswordLength = length!=='' ? length : 0;
},
initGroups(state, {groups, orderBy}) {
state.groups = groups;
state.orderBy = orderBy;
state.groups = orderGroups(state.groups, state.orderBy);
},
addGroup(state, groupid) {
try {
state.groups.push({
id: groupid,
name: groupid,
usercount: 0 // user will be added after the creation
});
state.groups = orderGroups(state.groups, state.orderBy);
} catch (e) {
console.log('Can\'t create group', e);
}
},
addUserGroup(state, { userid, gid }) {
// this should not be needed as it would means the user contains a group
// the server database doesn't have.
let group = state.groups.find(groupSearch => groupSearch.id == gid);
if (group) {
group.usercount++; // increase count
}
let groups = state.users.find(user => user.id == userid).groups;
groups.push(gid);
state.groups = orderGroups(state.groups, state.orderBy);
},
removeUserGroup(state, { userid, gid }) {
// this should not be needed as it would means the user contains a group
// the server database doesn't have.
let group = state.groups.find(groupSearch => groupSearch.id == gid);
if (group) {
group.usercount--; // lower count
}
let groups = state.users.find(user => user.id == userid).groups;
groups.splice(groups.indexOf(gid),1);
state.groups = orderGroups(state.groups, state.orderBy);
},
addUserSubAdmin(state, { userid, gid }) {
let groups = state.users.find(user => user.id == userid).subadmin;
groups.push(gid);
},
removeUserSubAdmin(state, { userid, gid }) {
let groups = state.users.find(user => user.id == userid).subadmin;
groups.splice(groups.indexOf(gid),1);
},
deleteUser(state, userid) {
let userIndex = state.users.findIndex(user => user.id == userid);
state.users.splice(userIndex, 1);
},
addUserData(state, response) {
state.users.push(response.data.ocs.data);
},
enableDisableUser(state, { userid, enabled }) {
state.users.find(user => user.id == userid).enabled = enabled;
state.groups.find(group => group.id == '_disabled').usercount += enabled ? -1 : 1;
},
setUserData(state, { userid, key, value }) {
if (key === 'quota') {
let humanValue = OC.Util.computerFileSize(value);
state.users.find(user => user.id == userid)[key][key] = humanValue?humanValue:value;
} else {
state.users.find(user => user.id == userid)[key] = value;
}
},
};

const getters = {
getUsers(state) {
return state.users;
},
getGroups(state) {
return state.groups;
},
getPasswordPolicyMinLength(state) {
return state.minPasswordLength;
},
getUsersOffset(state) {
return state.usersOffset;
},
getUsersLimit(state) {
return state.usersLimit;
}
};

const actions = {
/**
* Get all users with full details
*
* @param {Object} context
* @param {Object} options
* @param {int} options.offset List offset to request
* @param {int} options.limit List number to return from offset
* @returns {Promise}
*/
getUsers(context, { offset, limit, search }) {
search = typeof search === 'string' ? search : '';
return api.get(OC.linkToOCS(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2))
.then((response) => {
if (Object.keys(response.data.ocs.data.users).length > 0) {
context.commit('appendUsers', response.data.ocs.data.users);
return true;
}
return false;
})
.catch((error) => context.commit('API_FAILURE', error));
},

/**
* Get all users with full details
*
* @param {Object} context
* @param {Object} options
* @param {int} options.offset List offset to request
* @param {int} options.limit List number to return from offset
* @returns {Promise}
*/
getUsersFromList(context, { offset, limit, search }) {
search = typeof search === 'string' ? search : '';
return api.get(OC.linkToOCS(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2))
.then((response) => {
if (Object.keys(response.data.ocs.data.users).length > 0) {
context.commit('appendUsers', response.data.ocs.data.users);
return true;
}
return false;
})
.catch((error) => context.commit('API_FAILURE', error));
},

/**
* Get all users with full details from a groupid
*
* @param {Object} context
* @param {Object} options
* @param {int} options.offset List offset to request
* @param {int} options.limit List number to return from offset
* @returns {Promise}
*/
getUsersFromGroup(context, { groupid, offset, limit }) {
return api.get(OC.linkToOCS(`cloud/users/${groupid}/details?offset=${offset}&limit=${limit}`, 2))
.then((response) => context.commit('getUsersFromList', response.data.ocs.data.users))
.catch((error) => context.commit('API_FAILURE', error));
},

getPasswordPolicyMinLength(context) {
return api.get(OC.linkToOCS('apps/provisioning_api/api/v1/config/apps/password_policy/minLength', 2))
.then((response) => context.commit('setPasswordPolicyMinLength', response.data.ocs.data.data))
.catch((error) => context.commit('API_FAILURE', error));
},

/**
* Add group
*
* @param {Object} context
* @param {string} gid Group id
* @returns {Promise}
*/
addGroup(context, gid) {
return api.requireAdmin().then((response) => {
return api.post(OC.linkToOCS(`cloud/groups`, 2), {groupid: gid})
.then((response) => context.commit('addGroup', gid))
.catch((error) => context.commit('API_FAILURE', error));
});
},

/**
* Add group
*
* @param {Object} context
* @param {string} gid Group id
* @returns {Promise}
*/
removeGroup(context, gid) {
return api.requireAdmin().then((response) => {
return api.post(OC.linkToOCS(`cloud/groups`, 2), {groupid: gid})
.then((response) => context.commit('removeGroup', gid))
.catch((error) => context.commit('API_FAILURE', error));
});
},

/**
* Add user to group
*
* @param {Object} context
* @param {Object} options
* @param {string} options.userid User id
* @param {string} options.gid Group id
* @returns {Promise}
*/
addUserGroup(context, { userid, gid }) {
return api.requireAdmin().then((response) => {
return api.post(OC.linkToOCS(`cloud/users/${userid}/groups`, 2), { groupid: gid })
.then((response) => context.commit('addUserGroup', { userid, gid }))
.catch((error) => context.commit('API_FAILURE', error));
});
},

/**
* Remove user from group
*
* @param {Object} context
* @param {Object} options
* @param {string} options.userid User id
* @param {string} options.gid Group id
* @returns {Promise}
*/
removeUserGroup(context, { userid, gid }) {
return api.requireAdmin().then((response) => {
return api.delete(OC.linkToOCS(`cloud/users/${userid}/groups`, 2), { groupid: gid })
.then((response) => context.commit('removeUserGroup', { userid, gid }))
.catch((error) => context.commit('API_FAILURE', { userid, error }));
});
},

/**
* Add user to group admin
*
* @param {Object} context
* @param {Object} options
* @param {string} options.userid User id
* @param {string} options.gid Group id
* @returns {Promise}
*/
addUserSubAdmin(context, { userid, gid }) {
return api.requireAdmin().then((response) => {
return api.post(OC.linkToOCS(`cloud/users/${userid}/subadmins`, 2), { groupid: gid })
.then((response) => context.commit('addUserSubAdmin', { userid, gid }))
.catch((error) => context.commit('API_FAILURE', error));
});
},

/**
* Remove user from group admin
*
* @param {Object} context
* @param {Object} options
* @param {string} options.userid User id
* @param {string} options.gid Group id
* @returns {Promise}
*/
removeUserSubAdmin(context, { userid, gid }) {
return api.requireAdmin().then((response) => {
return api.delete(OC.linkToOCS(`cloud/users/${userid}/subadmins`, 2), { groupid: gid })
.then((response) => context.commit('removeUserSubAdmin', { userid, gid }))
.catch((error) => context.commit('API_FAILURE', { userid, error }));
});
},

/**
* Delete a user
*
* @param {Object} context
* @param {string} userid User id
* @returns {Promise}
*/
deleteUser(context, userid) {
return api.requireAdmin().then((response) => {
return api.delete(OC.linkToOCS(`cloud/users/${userid}`, 2))
.then((response) => context.commit('deleteUser', userid))
.catch((error) => context.commit('API_FAILURE', { userid, error }));
});
},

/**
* Add a user
*
* @param {Object} context
* @param {Object} options
* @param {string} options.userid User id
* @param {string} options.password User password
* @param {string} options.email User email
* @returns {Promise}
*/
addUser({context, dispatch}, {userid, password, email, groups}) {
return api.requireAdmin().then((response) => {
return api.post(OC.linkToOCS(`cloud/users`, 2), {userid, password, email, groups})
.then((response) => dispatch('addUserData', userid))
.catch((error) => context.commit('API_FAILURE', { userid, error }));
});
},

/**
* Get user data and commit addition
*
* @param {Object} context
* @param {string} userid User id
* @returns {Promise}
*/
addUserData(context, userid) {
return api.requireAdmin().then((response) => {
return api.get(OC.linkToOCS(`cloud/users/${userid}`, 2))
.then((response) => context.commit('addUserData', response))
.catch((error) => context.commit('API_FAILURE', { userid, error }));
});
},

/** Enable or disable user
*
* @param {Object} context
* @param {Object} options
* @param {string} options.userid User id
* @param {boolean} options.enabled User enablement status
* @returns {Promise}
*/
enableDisableUser(context, { userid, enabled = true }) {
let userStatus = enabled ? 'enable' : 'disable';
return api.requireAdmin().then((response) => {
return api.put(OC.linkToOCS(`cloud/users/${userid}/${userStatus}`, 2))
.then((response) => context.commit('enableDisableUser', { userid, enabled }))
.catch((error) => context.commit('API_FAILURE', { userid, error }));
});
},

/**
* Edit user data
*
* @param {Object} context
* @param {Object} options
* @param {string} options.userid User id
* @param {string} options.key User field to edit
* @param {string} options.value Value of the change
* @returns {Promise}
*/
setUserData(context, { userid, key, value }) {
if (['email', 'quota', 'displayname', 'password'].indexOf(key) !== -1) {
// We allow empty email or displayname
if (typeof value === 'string' &&
(
(['quota', 'password'].indexOf(key) !== -1 && value.length > 0) ||
['email', 'displayname'].indexOf(key) !== -1
)
) {
return api.requireAdmin().then((response) => {
return api.put(OC.linkToOCS(`cloud/users/${userid}`, 2), { key: key, value: value })
.then((response) => context.commit('setUserData', { userid, key, value }))
.catch((error) => context.commit('API_FAILURE', { userid, error }));
});
}
}
return Promise.reject(new Error('Invalid request data'));
}
};

export default { state, mutations, getters, actions };

+ 152
- 0
settings/src/views/Users.vue View File

@@ -0,0 +1,152 @@
<template>
<div id="app">
<app-navigation :menu="menu">
<template slot="settings-content">
<div>
<input type="checkbox" id="showLastLogin" class="checkbox"
:checked="showLastLogin" v-model="showLastLogin">
<label for="showLastLogin">{{t('settings', 'Show last login')}}</label>
</div>
<div>
<input type="checkbox" id="showUserBackend" class="checkbox"
:checked="showUserBackend" v-model="showUserBackend">
<label for="showUserBackend">{{t('settings', 'Show user backend')}}</label>
</div>
<div>
<input type="checkbox" id="showStoragePath" class="checkbox"
:checked="showStoragePath" v-model="showStoragePath">
<label for="showStoragePath">{{t('settings', 'Show storage path')}}</label>
</div>
</template>
</app-navigation>
<user-list :users="users" :showConfig="showConfig" />
</div>
</template>

<script>
import appNavigation from '../components/appNavigation';
import userList from '../components/userList';
import Vue from 'vue';
import VueLocalStorage from 'vue-localstorage'
Vue.use(VueLocalStorage)

export default {
name: 'Users',
components: {
appNavigation,
userList
},
beforeMount() {
this.$store.commit('initGroups', {
groups: this.$store.getters.getServerData.groups,
orderBy: this.$store.getters.getServerData.sortGroups
});
this.$store.dispatch('getPasswordPolicyMinLength');
},
data() {
return {
showConfig: {
showStoragePath: false,
showUserBackend: false,
showLastLogin: false,
showNewUserForm: false
}
}
},
methods: {
getLocalstorage(key) {
// force initialization
this.showConfig[key] = this.$localStorage.get(key) === 'true';
return this.showConfig[key];
},
setLocalStorage(key, status) {
this.showConfig[key] = status;
this.$localStorage.set(key, status);
return status;
}
},
computed: {
users() {
return this.$store.getters.getUsers;
},
loading() {
return Object.keys(this.users).length === 0;
},
usersOffset() {
return this.$store.getters.getUsersOffset;
},
usersLimit() {
return this.$store.getters.getUsersLimit;
},
showLastLogin: {
get: function() {return this.getLocalstorage('showLastLogin')},
set: function(status) {
this.setLocalStorage('showLastLogin', status);
}
},
showUserBackend: {
get: function() {return this.getLocalstorage('showUserBackend')},
set: function(status) {
this.setLocalStorage('showUserBackend', status);
}
},
showStoragePath: {
get: function() {return this.getLocalstorage('showStoragePath')},
set: function(status) {
this.setLocalStorage('showStoragePath', status);
}
},
menu() {
let self = this;
// Data provided php side
let groups = this.$store.getters.getGroups;
groups = Array.isArray(groups) ? groups : [];

// Map groups
groups = groups.map(group => {
let item = {};
item.id = group.id.replace(' ', '_');
item.classes = [];
item.href = '#group'+group.id.replace(' ', '_');
item.text = group.name;
item.utils = {counter: group.usercount};
return item;
});

// Adjust data
if (groups[0].id === 'admin') {
groups[0].text = t('settings', 'Admins');} // rename admin group
if (groups[1].id === '_disabled') {
groups[1].text = t('settings', 'Disabled users'); // rename disabled group
if (groups[1].utils.counter === 0) {
groups.splice(1, 1); // remove disabled if empty
}
}

// Add everyone group
groups.unshift({
id: '_everyone',
classes: ['active'],
href:'#group_everyone',
text: t('settings', 'Everyone'),
utils: {counter: this.users.length}
});

// Return
return {
id: 'usergrouplist',
new: {
id:'new-user-button',
text: t('settings','New user'),
icon: 'icon-add',
action: function(){self.showConfig.showNewUserForm=!self.showConfig.showNewUserForm}
},
items: groups
}
}
}
}
</script>

<style lang="scss">
</style>

+ 26
- 8
settings/templates/settings.php View File

@@ -1,9 +1,27 @@
<?php /**
* Copyright (c) 2011, Robin Appelman <icewind1991@gmail.com>
* This file is licensed under the Affero General Public License version 3 or later.
* See the COPYING-README file.
*/?>
<?php
/**
* @copyright Copyright (c) 2018, John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
* @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This is the default empty template to load Vue!
* Do your cbackend computations into a php files
* then serve this file as template and include your data into
* the $serverData template variable
*
* $tmpl = new OC_Template('settings', 'settings', 'user');
* $tmpl->assign('serverData', $serverData);
* $tmpl->printPage();

<?php foreach($_['forms'] as $form) {
print_unescaped($form);
}
*/

script('settings', 'main');
style('settings', 'settings');

// Did we have some data to inject ?
if(is_array($_['serverData'])) {
$serverData = json_encode($_['serverData']);
?>
<span id="serverData" data-server="<?php p($serverData);?>"></span>
<?php } ?>

+ 0
- 80
settings/templates/users/main.php View File

@@ -1,80 +0,0 @@
<?php
/**
* Copyright (c) 2011, Robin Appelman <icewind1991@gmail.com>
* Copyright (c) 2017, John Molakvoæ <skjnldsv@protonmail.com>
* This file is licensed under the Affero General Public License version 3 or later.
* See the COPYING-README file.
*/

script('settings', [
'users/deleteHandler',
'users/filter',
'users/users',
'users/groups'
]);
script('core', [
'multiselect',
'singleselect'
]);
style('settings', 'settings');

$userlistParams = array();
$allGroups=array();
foreach($_["adminGroup"] as $group) {
$allGroups[$group['id']] = array('displayName' => $group['name']);
}
foreach($_["groups"] as $group) {
$allGroups[$group['id']] = array('displayName' => $group['name']);
}
$userlistParams['subadmingroups'] = $allGroups;
$userlistParams['allGroups'] = json_encode($allGroups);
$items = array_flip($userlistParams['subadmingroups']);
unset($items['admin']);
$userlistParams['subadmingroups'] = array_flip($items);

translation('settings');
?>

<div id="app-navigation">
<?php print_unescaped($this->inc('users/part.createuser')); ?>
<?php print_unescaped($this->inc('users/part.grouplist')); ?>
<div id="app-settings">
<div id="app-settings-header">
<button class="settings-button" tabindex="0" data-apps-slide-toggle="#app-settings-content"><?php p($l->t('Settings'));?></button>
</div>
<div id="app-settings-content">
<?php print_unescaped($this->inc('users/part.setquota')); ?>

<div id="userlistoptions">
<p>
<input type="checkbox" name="StorageLocation" value="StorageLocation" id="CheckboxStorageLocation"
class="checkbox" <?php if ($_['show_storage_location'] === 'true') print_unescaped('checked="checked"'); ?> />
<label for="CheckboxStorageLocation">
<?php p($l->t('Show storage location')) ?>
</label>
</p>
<p>
<input type="checkbox" name="UserBackend" value="UserBackend" id="CheckboxUserBackend"
class="checkbox" <?php if ($_['show_backend'] === 'true') print_unescaped('checked="checked"'); ?> />
<label for="CheckboxUserBackend">
<?php p($l->t('Show user backend')) ?>
</label>
</p>
<p>
<input type="checkbox" name="LastLogin" value="LastLogin" id="CheckboxLastLogin"
class="checkbox" <?php if ($_['show_last_login'] === 'true') print_unescaped('checked="checked"'); ?> />
<label for="CheckboxLastLogin">
<?php p($l->t('Show last login')) ?>
</label>
</p>
<p class="info-text">
<?php p($l->t('When the password of a new user is left empty, an activation email with a link to set the password is sent.')) ?>
</p>
</div>
</div>
</div>
</div>

<div id="app-content">
<?php print_unescaped($this->inc('users/part.userlist', $userlistParams)); ?>
</div>

+ 0
- 3
settings/templates/users/part.createuser.php View File

@@ -1,3 +0,0 @@
<div class="app-navigation-new">
<button type="button" id="new-user-button" class="icon-add"><?php p($l->t('Add user'))?></button>
</div>

+ 0
- 69
settings/templates/users/part.grouplist.php View File

@@ -1,69 +0,0 @@
<ul id="usergrouplist" data-sort-groups="<?php p($_['sortGroups']); ?>">
<!-- Add new group -->
<?php if ($_['isAdmin']) { ?>
<li id="newgroup-entry">
<a href="#" class="icon-add" id="newgroup-init"><?php p($l->t('Add group'))?></a>
<div class="app-navigation-entry-edit" id="newgroup-form">
<form>
<input type="text" id="newgroupname" placeholder="<?php p($l->t('Add group'))?>">
<input type="submit" value="" class="icon-checkmark">
</form>
</div>
</li>
<?php } ?>
<!-- Everyone -->
<li id="everyonegroup" data-gid="_everyone" data-usercount="" class="isgroup">
<a href="#">
<span class="groupname">
<?php p($l->t('Everyone')); ?>
</span>
</a>
<div class="app-navigation-entry-utils">
<ul>
<li class="usercount app-navigation-entry-utils-counter" id="everyonecount"></li>
</ul>
</div>
</li>

<!-- The Admin Group -->
<?php foreach($_["adminGroup"] as $adminGroup): ?>
<li data-gid="admin" data-usercount="<?php if($adminGroup['usercount'] > 0) { p($adminGroup['usercount']); } ?>" class="isgroup">
<a href="#"><span class="groupname"><?php p($l->t('Admins')); ?></span></a>
<div class="app-navigation-entry-utils">
<ul>
<li class="app-navigation-entry-utils-counter"><?php if($adminGroup['usercount'] > 0) { p($adminGroup['usercount']); } ?></li>
</ul>
</div>
</li>
<?php endforeach; ?>

<!-- Disabled Users -->
<?php $disabledUsersGroup = $_["disabledUsersGroup"] ?>
<li data-gid="_disabledUsers" data-usercount="<?php if($disabledUsersGroup['usercount'] > 0) { p($disabledUsersGroup['usercount']); } ?>" class="isgroup">
<a href="#"><span class="groupname"><?php p($l->t('Disabled')); ?></span></a>
<div class="app-navigation-entry-utils">
<ul>
<li class="app-navigation-entry-utils-counter"><?php if($disabledUsersGroup['usercount'] > 0) { p($disabledUsersGroup['usercount']); } ?></li>
</ul>
</div>
</li>

<!--List of Groups-->
<?php foreach($_["groups"] as $group): ?>
<li data-gid="<?php p($group['id']) ?>" data-usercount="<?php p($group['usercount']) ?>" class="isgroup">
<a href="#" class="dorename">
<span class="groupname"><?php p($group['name']); ?></span>
</a>
<div class="app-navigation-entry-utils">
<ul>
<li class="app-navigation-entry-utils-counter"><?php if($group['usercount'] > 0) { p($group['usercount']); } ?></li>
<?php if($_['isAdmin']): ?>
<li class="app-navigation-entry-utils-menu-button delete">
<button class="icon-delete"></button>
</li>
<?php endif; ?>
</ul>
</div>
</li>
<?php endforeach; ?>
</ul>

+ 0
- 35
settings/templates/users/part.setquota.php View File

@@ -1,35 +0,0 @@
<div class="quota">
<!-- Default storage -->
<span><?php p($l->t('Default quota'));?></span>
<?php if((bool) $_['isAdmin']): ?>
<select id='default_quota' data-inputtitle="<?php p($l->t('Please enter storage quota (ex: "512 MB" or "12 GB")')) ?>" data-tipsy-gravity="s">
<option <?php if($_['default_quota'] === 'none') print_unescaped('selected="selected"');?> value='none'>
<?php p($l->t('Unlimited'));?>
</option>
<?php foreach($_['quota_preset'] as $preset):?>
<?php if($preset !== 'default'):?>
<option <?php if($_['default_quota']==$preset) print_unescaped('selected="selected"');?> value='<?php p($preset);?>'>
<?php p($preset);?>
</option>
<?php endif;?>
<?php endforeach;?>
<?php if($_['defaultQuotaIsUserDefined']):?>
<option selected="selected" value='<?php p($_['default_quota']);?>'>
<?php p($_['default_quota']);?>
</option>
<?php endif;?>
<option data-new value='other'>
<?php p($l->t('Other'));?>
...
</option>
</select>
<?php endif; ?>
<?php if((bool) !$_['isAdmin']): ?>
:
<?php if( $_['default_quota'] === 'none'): ?>
<?php p($l->t('Unlimited'));?>
<?php else: ?>
<?php p($_['default_quota']);?>
<?php endif; ?>
<?php endif; ?>
</div>

+ 0
- 149
settings/templates/users/part.userlist.php View File

@@ -1,149 +0,0 @@
<form class="newUserMenu" id="newuser" autocomplete="off">
<table id="userlist" class="grid" data-groups="<?php p($_['allGroups']);?>">
<thead>
<tr>
<th id="headerAvatar" scope="col"></th>
<th id="headerName" scope="col"><?php p($l->t('Username'))?></th>
<th id="headerDisplayName" scope="col"><?php p($l->t( 'Full name' )); ?></th>
<th id="headerPassword" scope="col"><?php p($l->t( 'Password' )); ?></th>
<th class="mailAddress" scope="col"><?php p($l->t( 'Email' )); ?></th>
<th id="headerGroups" scope="col"><?php p($l->t( 'Groups' )); ?></th>
<?php if(is_array($_['subadmins']) || $_['subadmins']): ?>
<th id="headerSubAdmins" scope="col"><?php p($l->t('Group admin for')); ?></th>
<?php endif;?>
<?php if((bool)$_['recoveryAdminEnabled']): ?>
<th id="recoveryPassword" scope="col"><?php p($l->t('Recovery password')); ?></th>
<?php endif; ?>
<th id="headerQuota" scope="col"><?php p($l->t('Quota')); ?></th>
<th class="storageLocation" scope="col"><?php p($l->t('Storage location')); ?></th>
<th class="userBackend" scope="col"><?php p($l->t('User backend')); ?></th>
<th class="lastLogin" scope="col"><?php p($l->t('Last login')); ?></th>
<th class="userActions"></th>
</tr>
<tr id="newuserHeader" style="display:none">
<td class="icon-add"></td>
<td class="name">
<input id="newusername" type="text" required
placeholder="<?php p($l->t('Username'))?>" name="username"
autocomplete="off" autocapitalize="none" autocorrect="off" />
</td>
<td class="displayName">
<input id="newdisplayname" type="text"
placeholder="<?php p($l->t('Full name'))?>" name="displayname"
autocomplete="off" autocapitalize="none" autocorrect="off" />
</td>
<td class="password">
<input id="newuserpassword" type="password"
placeholder="<?php p($l->t('Password'))?>" name="password"
autocomplete="new-password" autocapitalize="none" autocorrect="off" />
</td>
<td class="mailAddress">
<input id="newemail" type="email"
placeholder="<?php p($l->t('E-Mail'))?>" name="email"
autocomplete="off" autocapitalize="none" autocorrect="off" />
</td>
<td class="groups">
<div class="groupsListContainer multiselect button" data-placeholder="<?php p($l->t('Groups'))?>">
<span class="title groupsList"></span>
<span class="icon-triangle-s"></span>
</div>
</td>
<?php if(is_array($_['subadmins']) || $_['subadmins']): ?>
<td></td>
<?php endif;?>
<?php if((bool)$_['recoveryAdminEnabled']): ?>
<td class="recoveryPassword">
<input id="recoveryPassword"
type="password"
placeholder="<?php p($l->t('Admin Recovery Password'))?>"
title="<?php p($l->t('Enter the recovery password in order to recover the users files during password change'))?>"
alt="<?php p($l->t('Enter the recovery password in order to recover the users files during password change'))?>"/>
</td>
<?php endif; ?>
<td class="quota"></td>
<td class="storageLocation" scope="col"></td>
<td class="userBackend" scope="col"></td>
<td class="lastLogin" scope="col"></td>
<td class="userActions">
<input type="submit" id="newsubmit" class="button primary icon-checkmark-white has-tooltip" value="" title="<?php p($l->t('Add user'))?>" />
<input type="reset" id="newreset" class="button icon-close has-tooltip" value="" title="<?php p($l->t('Cancel'))?>" />
</td>
</tr>
</thead>
<tbody>
<!-- the following <tr> is used as a template for the JS part -->
<tr style="display:none">
<td class="avatar"><div class="avatardiv"></div></td>
<td class="name" scope="row"></td>
<td class="displayName"><span></span> <img class="action"
src="<?php p(image_path('core', 'actions/rename.svg'))?>"
alt="<?php p($l->t('change full name'))?>" title="<?php p($l->t('change full name'))?>"/>
</td>
<td class="password"><span>●●●●●●●</span> <img class="action"
src="<?php print_unescaped(image_path('core', 'actions/rename.svg'))?>"
alt="<?php p($l->t('set new password'))?>" title="<?php p($l->t('set new password'))?>"/>
</td>
<td class="mailAddress"><span></span><div class="loading-small hidden"></div> <img class="action"
src="<?php p(image_path('core', 'actions/rename.svg'))?>"
alt="<?php p($l->t('change email address'))?>" title="<?php p($l->t('change email address'))?>"/>
</td>
<td class="groups"><div class="groupsListContainer multiselect button"
><span class="title groupsList"></span><span class="icon-triangle-s"></span></div>
</td>
<?php if(is_array($_['subadmins']) || $_['subadmins']): ?>
<td class="subadmins"><div class="groupsListContainer multiselect button"
><span class="title groupsList"></span><span class="icon-triangle-s"></span></div>
</td>
<?php endif;?>
<?php if((bool)$_['recoveryAdminEnabled']): ?>
<td></td>
<?php endif; ?>
<td class="quota">
<select class="quota-user" data-inputtitle="<?php p($l->t('Please enter storage quota (ex: "512 MB" or "12 GB")')) ?>">
<option value='default'>
<?php p($l->t('Default'));?>
</option>
<option value='none'>
<?php p($l->t('Unlimited'));?>
</option>
<?php foreach($_['quota_preset'] as $preset):?>
<option value='<?php p($preset);?>'>
<?php p($preset);?>
</option>
<?php endforeach;?>
<option value='other' data-new>
<?php p($l->t('Other'));?> ...
</option>
</select>
<progress class="quota-user-progress" value="" max="100"></progress>
</td>
<td class="storageLocation"></td>
<td class="userBackend"></td>
<td class="lastLogin"></td>
<td class="userActions">
<div class="toggleUserActions">
<a class="action"><span class="icon-more"></span></a>
<div class="popovermenu">
<ul class="userActionsMenu">
<li>
<a href="#" class="menuitem action-togglestate permanent" data-action="togglestate"></a>
</li>
<li>
<a href="#" class="menuitem action-remove permanent" data-action="remove">
<span class="icon icon-delete"></span>
<span><?php p($l->t('Delete')); ?></span>
</a>
</li>
</ul>
</div>
</div>
</td>
</tr>
</tbody>
</table>
</form>

<div class="emptycontent" style="display:none">
<div class="icon-search"></div>
<h2></h2>
</div>

+ 0
- 220
settings/tests/js/users/deleteHandlerSpec.js View File

@@ -1,220 +0,0 @@
/**
* ownCloud
*
* @author Vincent Petry
* @copyright 2014 Vincent Petry <pvince81@owncloud.com>
*
* This library is free software; you can redistribute it and/or
* modify it under the terms of the GNU AFFERO GENERAL PUBLIC LICENSE
* License as published by the Free Software Foundation; either
* version 3 of the License, or any later version.
*
* This library is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU AFFERO GENERAL PUBLIC LICENSE for more details.
*
* You should have received a copy of the GNU Affero General Public
* License along with this library. If not, see <http://www.gnu.org/licenses/>.
*
*/

describe('DeleteHandler tests', function() {
var showNotificationSpy;
var hideNotificationSpy;
var clock;
var removeCallback;
var markCallback;
var undoCallback;

function init(markCallback, removeCallback, undoCallback) {
var handler = new DeleteHandler('dummyendpoint.php', 'paramid', markCallback, removeCallback);
handler.setNotification(OC.Notification, 'dataid', 'removed %oid entry', undoCallback);
return handler;
}

beforeEach(function() {
showNotificationSpy = sinon.spy(OC.Notification, 'showHtml');
hideNotificationSpy = sinon.spy(OC.Notification, 'hide');
clock = sinon.useFakeTimers();
removeCallback = sinon.stub();
markCallback = sinon.stub();
undoCallback = sinon.stub();

$('#testArea').append('<div id="notification"></div>');
});
afterEach(function() {
showNotificationSpy.restore();
hideNotificationSpy.restore();
clock.restore();
});
it('shows a notification when marking for delete', function() {
var handler = init(markCallback, removeCallback, undoCallback);
handler.mark('some_uid');

expect(showNotificationSpy.calledOnce).toEqual(true);
expect(showNotificationSpy.getCall(0).args[0]).toEqual('removed some_uid entry');

expect(markCallback.calledOnce).toEqual(true);
expect(markCallback.getCall(0).args[0]).toEqual('some_uid');
expect(removeCallback.notCalled).toEqual(true);
expect(undoCallback.notCalled).toEqual(true);

expect(fakeServer.requests.length).toEqual(0);
});
it('deletes first entry and reshows notification on second delete', function() {
fakeServer.respondWith(/\/index\.php\/dummyendpoint.php\/some_uid/, [
204,
{ 'Content-Type': 'application/json' },
JSON.stringify({status: 'success'})
]);
fakeServer.respondWith(/\/index\.php\/dummyendpoint.php\/some_other_uid/, [
204,
{ 'Content-Type': 'application/json' },
JSON.stringify({status: 'success'})
]);

var handler = init(markCallback, removeCallback, undoCallback);
handler.mark('some_uid');

expect(showNotificationSpy.calledOnce).toEqual(true);
expect(showNotificationSpy.getCall(0).args[0]).toEqual('removed some_uid entry');
showNotificationSpy.resetHistory();

handler.mark('some_other_uid');

expect(hideNotificationSpy.calledOnce).toEqual(true);
expect(showNotificationSpy.calledOnce).toEqual(true);
expect(showNotificationSpy.getCall(0).args[0]).toEqual('removed some_other_uid entry');

expect(markCallback.calledTwice).toEqual(true);
expect(markCallback.getCall(0).args[0]).toEqual('some_uid');
expect(markCallback.getCall(1).args[0]).toEqual('some_other_uid');
// called only once, because it is called once the second user is deleted
expect(removeCallback.calledOnce).toEqual(true);
expect(undoCallback.notCalled).toEqual(true);

// previous one was delete
expect(fakeServer.requests.length).toEqual(1);
var request = fakeServer.requests[0];
expect(request.url).toEqual(OC.webroot + '/index.php/dummyendpoint.php/some_uid');
});
it('automatically deletes after timeout', function() {
fakeServer.respondWith(/\/index\.php\/dummyendpoint.php\/some_uid/, [
204,
{ 'Content-Type': 'application/json' },
JSON.stringify({status: 'success'})
]);

var handler = init(markCallback, removeCallback, undoCallback);
handler.mark('some_uid');

clock.tick(5000);
// nothing happens yet
expect(fakeServer.requests.length).toEqual(0);

clock.tick(3000);
expect(fakeServer.requests.length).toEqual(1);
var request = fakeServer.requests[0];
expect(request.url).toEqual(OC.webroot + '/index.php/dummyendpoint.php/some_uid');
});
it('deletes when deleteEntry is called', function() {
fakeServer.respondWith(/\/index\.php\/dummyendpoint.php\/some_uid/, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({status: 'success'})
]);
var handler = init(markCallback, removeCallback, undoCallback);
handler.mark('some_uid');

handler.deleteEntry();
expect(fakeServer.requests.length).toEqual(1);
var request = fakeServer.requests[0];
expect(request.url).toEqual(OC.webroot + '/index.php/dummyendpoint.php/some_uid');
});
it('deletes when deleteEntry is called and escapes', function() {
fakeServer.respondWith(/\/index\.php\/dummyendpoint.php\/some_uid/, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({status: 'success'})
]);
var handler = init(markCallback, removeCallback, undoCallback);
handler.mark('some_uid<>/"..\\');

handler.deleteEntry();
expect(fakeServer.requests.length).toEqual(1);
var request = fakeServer.requests[0];
expect(request.url).toEqual(OC.webroot + '/index.php/dummyendpoint.php/some_uid%3C%3E%2F%22..%5C');
});
it('cancels deletion when undo is clicked', function() {
var handler = init(markCallback, removeCallback, undoCallback);
handler.setNotification(OC.Notification, 'dataid', 'removed %oid entry <span class="undo">Undo</span>', undoCallback);
handler.mark('some_uid');
$('#notification .undo').click();

expect(undoCallback.calledOnce).toEqual(true);

// timer was cancelled
clock.tick(10000);
expect(fakeServer.requests.length).toEqual(0);
});
it('cancels deletion when cancel method is called', function() {
var handler = init(markCallback, removeCallback, undoCallback);
handler.setNotification(OC.Notification, 'dataid', 'removed %oid entry <span class="undo">Undo</span>', undoCallback);
handler.mark('some_uid');
handler.cancel();

// not sure why, seems to be by design
expect(undoCallback.notCalled).toEqual(true);

// timer was cancelled
clock.tick(10000);
expect(fakeServer.requests.length).toEqual(0);
});
it('calls removeCallback after successful server side deletion', function() {
fakeServer.respondWith(/\/index\.php\/dummyendpoint.php\/some_uid/, [
200,
{ 'Content-Type': 'application/json' },
JSON.stringify({status: 'success'})
]);

var handler = init(markCallback, removeCallback, undoCallback);
handler.mark('some_uid');
handler.deleteEntry();

expect(fakeServer.requests.length).toEqual(1);
var request = fakeServer.requests[0];
var query = OC.parseQueryString(request.requestBody);

expect(removeCallback.calledOnce).toEqual(true);
expect(undoCallback.notCalled).toEqual(true);
expect(removeCallback.getCall(0).args[0]).toEqual('some_uid');
});
it('calls undoCallback and shows alert after failed server side deletion', function() {
// stub t to avoid extra calls
var tStub = sinon.stub(window, 't').returns('text');
fakeServer.respondWith(/\/index\.php\/dummyendpoint.php\/some_uid/, [
403,
{ 'Content-Type': 'application/json' },
JSON.stringify({status: 'error', data: {message: 'test error'}})
]);

var alertDialogStub = sinon.stub(OC.dialogs, 'alert');
var handler = init(markCallback, removeCallback, undoCallback);
handler.mark('some_uid');
handler.deleteEntry();

expect(fakeServer.requests.length).toEqual(1);
var request = fakeServer.requests[0];
var query = OC.parseQueryString(request.requestBody);

expect(removeCallback.notCalled).toEqual(true);
expect(undoCallback.calledOnce).toEqual(true);
expect(undoCallback.getCall(0).args[0]).toEqual('some_uid');

expect(alertDialogStub.calledOnce);

alertDialogStub.restore();
tStub.restore();
});
});

+ 36
- 42
settings/users.php View File

@@ -17,6 +17,7 @@
* @author Stephan Peijnik <speijnik@anexia-it.com>
* @author Thomas Müller <thomas.mueller@tmit.eu>
* @author Thomas Pulzer <t.pulzer@kniel.de>
* @author John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
*
* @license AGPL-3.0
*
@@ -30,7 +31,7 @@
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License, version 3,
* along with this program. If not, see <http://www.gnu.org/licenses/>
* along with this program. If not, see <http://www.gnu.org/licenses/>
*
*/

@@ -41,12 +42,10 @@ OC_Util::checkSubAdminUser();
$userManager = \OC::$server->getUserManager();
$groupManager = \OC::$server->getGroupManager();
$appManager = \OC::$server->getAppManager();

// Set the sort option: SORT_USERCOUNT or SORT_GROUPNAME
$sortGroupsBy = \OC\Group\MetaData::SORT_USERCOUNT;

$config = \OC::$server->getConfig();

/* SORT OPTION: SORT_USERCOUNT or SORT_GROUPNAME */
$sortGroupsBy = \OC\Group\MetaData::SORT_USERCOUNT;
if ($config->getSystemValue('sort_groups_by_name', false)) {
$sortGroupsBy = \OC\Group\MetaData::SORT_GROUPNAME;
} else {
@@ -62,14 +61,16 @@ if ($config->getSystemValue('sort_groups_by_name', false)) {
}
}

$uid = \OC_User::getUser();
$isAdmin = OC_User::isAdminUser($uid);
/* ENCRYPTION CONFIG */
$isEncryptionEnabled = \OC::$server->getEncryptionManager()->isEnabled();
$useMasterKey = $config->getAppValue('encryption', 'useMasterKey', true);
// If masterKey enabled, then you can change password. This is to avoid data loss!
$canChangePassword = ($isEncryptionEnabled && $useMasterKey) || $useMasterKey;

$isDisabled = true;
$user = $userManager->get($uid);
if ($user) {
$isDisabled = !$user->isEnabled();
}

/* GROUPS */
$uid = \OC_User::getUser();
$isAdmin = \OC_User::isAdminUser($uid);

$groupsInfo = new \OC\Group\MetaData(
$uid,
@@ -81,10 +82,7 @@ $groupsInfo = new \OC\Group\MetaData(
$groupsInfo->setSorting($sortGroupsBy);
list($adminGroup, $groups) = $groupsInfo->get();

$recoveryAdminEnabled = $appManager->isEnabledForUser('encryption') &&
$config->getAppValue( 'encryption', 'recoveryAdminEnabled', '0');

if($isAdmin) {
if ($isAdmin) {
$subAdmins = \OC::$server->getGroupManager()->getSubAdmin()->getAllSubAdmins();
// New class returns IUser[] so convert back
$result = [];
@@ -95,7 +93,7 @@ if($isAdmin) {
];
}
$subAdmins = $result;
}else{
} else {
/* Retrieve group IDs from $groups array, so we can pass that information into OC_Group::displayNamesInGroups() */
$gids = array();
foreach($groups as $group) {
@@ -108,42 +106,38 @@ if($isAdmin) {

$disabledUsers = $isLDAPUsed ? 0 : $userManager->countDisabledUsers();
$disabledUsersGroup = [
'id' => '_disabledUsers',
'name' => '_disabledUsers',
'id' => '_disabled',
'name' => 'Disabled users',
'usercount' => $disabledUsers
];
$allGroups = array_merge_recursive($adminGroup, $groups);

// load preset quotas
/* QUOTAS PRESETS */
$quotaPreset=$config->getAppValue('files', 'quota_preset', '1 GB, 5 GB, 10 GB');
$quotaPreset=explode(',', $quotaPreset);
foreach($quotaPreset as &$preset) {
$preset=trim($preset);
}
$quotaPreset=array_diff($quotaPreset, array('default', 'none'));

$defaultQuota=$config->getAppValue('files', 'default_quota', 'none');
$defaultQuotaIsUserDefined=array_search($defaultQuota, $quotaPreset)===false
&& array_search($defaultQuota, array('none', 'default'))===false;

\OC::$server->getEventDispatcher()->dispatch('OC\Settings\Users::loadAdditionalScripts');

$tmpl = new OC_Template("settings", "users/main", "user");
$tmpl->assign('groups', $groups);
$tmpl->assign('sortGroups', $sortGroupsBy);
$tmpl->assign('adminGroup', $adminGroup);
$tmpl->assign('disabledUsersGroup', $disabledUsersGroup);
$tmpl->assign('isAdmin', (int)$isAdmin);
$tmpl->assign('subadmins', $subAdmins);
$tmpl->assign('numofgroups', count($groups) + count($adminGroup));
$tmpl->assign('quota_preset', $quotaPreset);
$tmpl->assign('default_quota', $defaultQuota);
$tmpl->assign('defaultQuotaIsUserDefined', $defaultQuotaIsUserDefined);
$tmpl->assign('recoveryAdminEnabled', $recoveryAdminEnabled);

$tmpl->assign('show_storage_location', $config->getAppValue('core', 'umgmt_show_storage_location', 'false'));
$tmpl->assign('show_last_login', $config->getAppValue('core', 'umgmt_show_last_login', 'false'));
$tmpl->assign('show_email', $config->getAppValue('core', 'umgmt_show_email', 'false'));
$tmpl->assign('show_backend', $config->getAppValue('core', 'umgmt_show_backend', 'false'));
$tmpl->assign('send_email', $config->getAppValue('core', 'umgmt_send_email', 'false'));

/* FINAL DATA */
$serverData = array();
// groups
$serverData['groups'] = array_merge_recursive($adminGroup, [$disabledUsersGroup], $groups);
$serverData['subadmingroups'] = $groups;
// Various data
$serverData['subadmins'] = $subAdmins;
$serverData['sortGroups'] = $sortGroupsBy;
$serverData['quotaPreset'] = $quotaPreset;
$serverData['userCount'] = $userManager->countUsers();
// Settings
$serverData['defaultQuota'] = $defaultQuota;
$serverData['canChangePassword'] = $canChangePassword;

// print template + vue + serve data
$tmpl = new OC_Template('settings', 'settings', 'user');
$tmpl->assign('serverData', $serverData);
$tmpl->printPage();

+ 108
- 0
settings/webpack.config.js View File

@@ -0,0 +1,108 @@
var path = require('path')
var webpack = require('webpack')

module.exports = {
entry: './src/main.js',
output: {
path: path.resolve(__dirname, './js'),
publicPath: '/dist/',
filename: 'main.js'
},
module: {
rules: [
{
test: /\.css$/,
use: [
'vue-style-loader',
'css-loader'
],
},
{
test: /\.scss$/,
use: [
'vue-style-loader',
'css-loader',
'sass-loader'
],
},
{
test: /\.sass$/,
use: [
'vue-style-loader',
'css-loader',
'sass-loader?indentedSyntax'
],
},
{
test: /\.vue$/,
loader: 'vue-loader',
options: {
loaders: {
// Since sass-loader (weirdly) has SCSS as its default parse mode, we map
// the "scss" and "sass" values for the lang attribute to the right configs here.
// other preprocessors should work out of the box, no loader config like this necessary.
'scss': [
'vue-style-loader',
'css-loader',
'sass-loader'
],
'sass': [
'vue-style-loader',
'css-loader',
'sass-loader?indentedSyntax'
]
}
// other vue-loader options go here
}
},
{
test: /\.js$/,
loader: 'babel-loader',
exclude: /node_modules/
},
{
test: /\.(png|jpg|gif|svg)$/,
loader: 'file-loader',
options: {
name: '[name].[ext]?[hash]'
}
}
]
},
resolve: {
alias: {
'vue$': 'vue/dist/vue.esm.js'
},
extensions: ['*', '.js', '.vue', '.json']
},
devServer: {
historyApiFallback: true,
noInfo: true,
overlay: true
},
performance: {
hints: false
},
devtool: '#eval-source-map'
}

if (process.env.NODE_ENV === 'production') {
module.exports.devtool = '#source-map'
// http://vue-loader.vuejs.org/en/workflow/production.html
module.exports.plugins = (module.exports.plugins || []).concat([
new webpack.DefinePlugin({
'process.env': {
NODE_ENV: '"production"'
}
}),
new webpack.optimize.UglifyJsPlugin({
sourceMap: true,
compress: {
warnings: false
}
}),
new webpack.LoaderOptionsPlugin({
minimize: true
})
])
}

+ 0
- 3022
tests/Settings/Controller/UsersControllerTest.php
File diff suppressed because it is too large
View File


Loading…
Cancel
Save