Browse Source

Merge pull request #8824 from nextcloud/settings-vue

Vue migration: settings
tags/v14.0.0beta1
Morris Jobke 6 years ago
parent
commit
a2c518ee5a
No account linked to committer's email address
66 changed files with 13675 additions and 6820 deletions
  1. 9
    0
      .drone.yml
  2. 217
    3
      core/css/inputs.scss
  3. 3
    2
      core/css/multiselect.scss
  4. 119
    110
      core/css/tooltip.scss
  5. 0
    1
      lib/composer/composer/autoload_classmap.php
  6. 0
    1
      lib/composer/composer/autoload_static.php
  7. 78
    3
      lib/private/L10N/Factory.php
  8. 1
    1
      lib/private/NavigationManager.php
  9. 16
    56
      lib/private/Settings/Personal/PersonalInfo.php
  10. 15
    0
      settings/.babelrc
  11. 12
    0
      settings/.editorconfig
  12. 12
    0
      settings/.gitignore
  13. 0
    157
      settings/Controller/GroupsController.php
  14. 189
    735
      settings/Controller/UsersController.php
  15. 26
    0
      settings/Makefile
  16. 19
    0
      settings/README.md
  17. 0
    77
      settings/ajax/setquota.php
  18. 0
    55
      settings/ajax/togglesubadmins.php
  19. 175
    95
      settings/css/settings.scss
  20. 56
    0
      settings/js/main.js
  21. 1
    0
      settings/js/main.js.map
  22. 0
    213
      settings/js/users/deleteHandler.js
  23. 0
    78
      settings/js/users/filter.js
  24. 0
    385
      settings/js/users/groups.js
  25. 0
    1189
      settings/js/users/users.js
  26. 10119
    0
      settings/package-lock.json
  27. 45
    0
      settings/package.json
  28. 3
    25
      settings/routes.php
  29. 3
    0
      settings/src/.jshintrc
  30. 16
    0
      settings/src/App.vue
  31. 32
    0
      settings/src/components/appNavigation.vue
  32. 117
    0
      settings/src/components/appNavigation/navigationItem.vue
  33. 18
    0
      settings/src/components/popoverMenu.vue
  34. 28
    0
      settings/src/components/popoverMenu/popoverItem.vue
  35. 298
    0
      settings/src/components/userList.vue
  36. 449
    0
      settings/src/components/userList/userRow.vue
  37. 22
    0
      settings/src/main.js
  38. 36
    0
      settings/src/router.js
  39. 99
    0
      settings/src/store/api.js
  40. 28
    0
      settings/src/store/index.js
  41. 25
    0
      settings/src/store/oc.js
  42. 18
    0
      settings/src/store/settings.js
  43. 420
    0
      settings/src/store/users.js
  44. 301
    0
      settings/src/views/Users.vue
  45. 23
    8
      settings/templates/settings.php
  46. 0
    80
      settings/templates/users/main.php
  47. 0
    3
      settings/templates/users/part.createuser.php
  48. 0
    69
      settings/templates/users/part.grouplist.php
  49. 0
    35
      settings/templates/users/part.setquota.php
  50. 0
    149
      settings/templates/users/part.userlist.php
  51. 0
    220
      settings/tests/js/users/deleteHandlerSpec.js
  52. 0
    149
      settings/users.php
  53. 77
    0
      settings/webpack.common.js
  54. 12
    0
      settings/webpack.dev.js
  55. 7
    0
      settings/webpack.prod.js
  56. 0
    1
      tests/Settings/ApplicationTest.php
  57. 0
    381
      tests/Settings/Controller/GroupsControllerTest.php
  58. 9
    2515
      tests/Settings/Controller/UsersControllerTest.php
  59. 2
    0
      tests/acceptance/config/behat.yml
  60. 56
    8
      tests/acceptance/features/bootstrap/AppNavigationContext.php
  61. 102
    0
      tests/acceptance/features/bootstrap/AppSettingsContext.php
  62. 68
    0
      tests/acceptance/features/bootstrap/DialogContext.php
  63. 141
    15
      tests/acceptance/features/bootstrap/UsersSettingsContext.php
  64. 38
    0
      tests/acceptance/features/core/ElementWrapper.php
  65. 0
    1
      tests/acceptance/features/login.feature
  66. 115
    0
      tests/acceptance/features/users.feature

+ 9
- 0
.drone.yml View File

@@ -594,6 +594,13 @@ pipeline:
when:
matrix:
TESTS-ACCEPTANCE: login
acceptance-users:
image: nextcloudci/acceptance-php7.1:acceptance-php7.1-2
commands:
- tests/acceptance/run-local.sh --timeout-multiplier 10 --nextcloud-server-domain acceptance-users --selenium-server selenium:4444 allow-git-repository-modifications features/users.feature
when:
matrix:
TESTS-ACCEPTANCE: users
nodb-codecov:
image: nextcloudci/php7.0:php7.0-19
commands:
@@ -761,6 +768,8 @@ matrix:
TESTS-ACCEPTANCE: header
- TESTS: acceptance
TESTS-ACCEPTANCE: login
- TESTS: acceptance
TESTS-ACCEPTANCE: users
- TESTS: jsunit
- TESTS: syntax-php7.0
- TESTS: syntax-php7.1

+ 217
- 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,206 @@ 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;
cursor: text !important;
}
}
&.multiselect--disabled,
&.multiselect--disabled .multiselect__single {
background-color: nc-darken($color-main-background, 8%) !important;
}
.multiselect__tags {
/* space between tags and limit tag */
$space-between: 5px;

display: flex;
flex-wrap: nowrap;
overflow: hidden;
border: 1px solid nc-darken($color-main-background, 14%);
cursor: pointer;
position: relative;
border-radius: 3px;
height: 34px;
/* tag wrapper */
.multiselect__tags-wrap {
align-items: center;
display: inline-flex;
overflow: hidden;
max-width: 100%;
position: relative;
padding: 3px $space-between;
flex-grow: 1;
/* 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: 1 0 0;
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;
/* require to override the default width
and force the tag to shring properly */
min-width: 0;
max-width: 50%;
max-width: fit-content;
max-width: -moz-fit-content;
/* css hack, detect if more than two tags
if so, flex-basis is set to half */
&:only-child {
flex: 0 1 auto;
}
&:not(:last-child) {
margin-right: $space-between;
}
/* ellipsis the groups to be sure
we display at least two of them */
> span {
white-space: nowrap;
text-overflow: ellipsis;
overflow: hidden;
}
}
}
/* Single select default value */
.multiselect__single {
padding: 8px 10px;
flex: 0 0 100%;
z-index: 1; /* above input */
background-color: $color-main-background;
cursor: pointer;
}
/* displayed text if tag limit reached */
.multiselect__strong,
.multiselect__limit {
flex: 0 0 auto;
line-height: 20px;
color: nc-lighten($color-main-text, 33%);
display: inline-flex;
align-items: center;
opacity: .7;
margin-right: $space-between;
/* above the input */
z-index: 5;
}
/* 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;
/* only when not active */
cursor: pointer;
}
}
/* 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;
max-height: 250px;
overflow-y: auto;
.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 */
&:not(.multiselect__option--disabled)::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;
}
&.multiselect__option--disabled {
background-color: nc-darken($color-main-background, 8%);
}
/* 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;

+ 3
- 2
core/css/multiselect.scss View File

@@ -75,8 +75,9 @@ ul.multiselectoptions {
}
}

div.multiselect,
select.multiselect {
/* TODO drop old legacy multiselect! */
div.multiselect:not(.multiselect-vue),
select.multiselect:not(.multiselect-vue) {
display: inline-block;
max-width: 200px;
min-width: 150px !important;

+ 119
- 110
core/css/tooltip.scss View File

@@ -11,119 +11,128 @@
*/

.tooltip {
position: absolute;
display: block;
font-family: 'Open Sans', Frutiger, Calibri, 'Myriad Pro', Myriad, sans-serif;
font-style: normal;
font-weight: 400;
letter-spacing: normal;
line-break: auto;
line-height: 1.6;
text-align: left;
text-align: start;
text-decoration: none;
text-shadow: none;
text-transform: none;
white-space: normal;
word-break: normal;
word-spacing: normal;
word-wrap: normal;
font-size: 12px;
opacity: 0;
z-index: 100000;
filter: drop-shadow(0 1px 10px $color-box-shadow);
&.in {
opacity: 1;
}

&.top {
margin-top: -3px;
padding: 10px 0;
}
&.bottom {
margin-top: 3px;
padding: 10px 0;
}

&.right {
margin-left: 3px;
padding: 0 10px;
.tooltip-arrow {
top: 50%;
left: 0;
margin-top: -10px;
border-width: 10px 10px 10px 0;
border-right-color: $color-main-background;
}
}
&.left {
margin-left: -3px;
padding: 0 5px;
.tooltip-arrow {
top: 50%;
right: 0;
margin-top: -10px;
border-width: 10px 0 10px 10px;
border-left-color: $color-main-background;
}
}

/* TOP */
&.top .tooltip-arrow,
&.top-left .tooltip-arrow,
&.top-right .tooltip-arrow {
bottom: 0;
border-width: 10px 10px 0;
border-top-color: $color-main-background;
}
&.top .tooltip-arrow {
left: 50%;
margin-left: -10px;
}
&.top-left .tooltip-arrow {
right: 10px;
margin-bottom: -10px;
}
&.top-right .tooltip-arrow {
left: 10px;
margin-bottom: -10px;
}

/* BOTTOM */
&.bottom .tooltip-arrow,
&.bottom-left .tooltip-arrow,
&.bottom-right .tooltip-arrow {
top: 0;
border-width: 0 10px 10px;
border-bottom-color: $color-main-background;
}
&.bottom .tooltip-arrow {
left: 50%;
margin-left: -10px;
}
&.bottom-left .tooltip-arrow {
right: 10px;
margin-top: -10px;
}
&.bottom-right .tooltip-arrow {
left: 10px;
margin-top: -10px;
}
position: absolute;
display: block;
font-family: 'Open Sans', Frutiger, Calibri, 'Myriad Pro', Myriad, sans-serif;
font-style: normal;
font-weight: 400;
letter-spacing: normal;
line-break: auto;
line-height: 1.6;
text-align: left;
text-align: start;
text-decoration: none;
text-shadow: none;
text-transform: none;
white-space: normal;
word-break: normal;
word-spacing: normal;
word-wrap: normal;
font-size: 12px;
opacity: 0;
z-index: 100000;
/* default to top */
margin-top: -3px;
padding: 10px 0;
filter: drop-shadow(0 1px 10px $color-box-shadow);
&.in,
&.tooltip[aria-hidden='false'] {
visibility: visible;
opacity: 1;
transition: opacity .15s;
}
&.top .tooltip-arrow,
&[x-placement^='top'] {
left: 50%;
margin-left: -10px;
}
&.bottom,
&[x-placement^='bottom'] {
margin-top: 3px;
padding: 10px 0;
}
&.right,
&[x-placement^='right'] {
margin-left: 3px;
padding: 0 10px;
.tooltip-arrow {
top: 50%;
left: 0;
margin-top: -10px;
border-width: 10px 10px 10px 0;
border-right-color: $color-main-background;
}
}
&.left,
&[x-placement^='left'] {
margin-left: -3px;
padding: 0 5px;
.tooltip-arrow {
top: 50%;
right: 0;
margin-top: -10px;
border-width: 10px 0 10px 10px;
border-left-color: $color-main-background;
}
}
/* TOP */
&.top,
&.top-left,
&[x-placement^='top'],
&.top-right {
.tooltip-arrow {
bottom: 0;
border-width: 10px 10px 0;
border-top-color: $color-main-background;
}
}
&.top-left .tooltip-arrow {
right: 10px;
margin-bottom: -10px;
}
&.top-right .tooltip-arrow {
left: 10px;
margin-bottom: -10px;
}
/* BOTTOM */
&.bottom,
&[x-placement^='bottom'],
&.bottom-left,
&.bottom-right {
.tooltip-arrow {
top: 0;
border-width: 0 10px 10px;
border-bottom-color: $color-main-background;
}
}
&[x-placement^='bottom'] .tooltip-arrow,
&.bottom .tooltip-arrow {
left: 50%;
margin-left: -10px;
}
&.bottom-left .tooltip-arrow {
right: 10px;
margin-top: -10px;
}
&.bottom-right .tooltip-arrow {
left: 10px;
margin-top: -10px;
}
}

.tooltip-inner {
max-width: 350px;
padding: 5px 8px;
background-color: $color-main-background;
color: $color-main-text;
text-align: center;
border-radius: $border-radius;
max-width: 350px;
padding: 5px 8px;
background-color: $color-main-background;
color: $color-main-text;
text-align: center;
border-radius: $border-radius;
}

.tooltip-arrow {
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}
position: absolute;
width: 0;
height: 0;
border-color: transparent;
border-style: solid;
}

+ 0
- 1
lib/composer/composer/autoload_classmap.php View File

@@ -913,7 +913,6 @@ return array(
'OC\\Settings\\Controller\\ChangePasswordController' => $baseDir . '/settings/Controller/ChangePasswordController.php',
'OC\\Settings\\Controller\\CheckSetupController' => $baseDir . '/settings/Controller/CheckSetupController.php',
'OC\\Settings\\Controller\\CommonSettingsTrait' => $baseDir . '/settings/Controller/CommonSettingsTrait.php',
'OC\\Settings\\Controller\\GroupsController' => $baseDir . '/settings/Controller/GroupsController.php',
'OC\\Settings\\Controller\\LogSettingsController' => $baseDir . '/settings/Controller/LogSettingsController.php',
'OC\\Settings\\Controller\\MailSettingsController' => $baseDir . '/settings/Controller/MailSettingsController.php',
'OC\\Settings\\Controller\\PersonalSettingsController' => $baseDir . '/settings/Controller/PersonalSettingsController.php',

+ 0
- 1
lib/composer/composer/autoload_static.php View File

@@ -943,7 +943,6 @@ class ComposerStaticInit53792487c5a8370acc0b06b1a864ff4c
'OC\\Settings\\Controller\\ChangePasswordController' => __DIR__ . '/../../..' . '/settings/Controller/ChangePasswordController.php',
'OC\\Settings\\Controller\\CheckSetupController' => __DIR__ . '/../../..' . '/settings/Controller/CheckSetupController.php',
'OC\\Settings\\Controller\\CommonSettingsTrait' => __DIR__ . '/../../..' . '/settings/Controller/CommonSettingsTrait.php',
'OC\\Settings\\Controller\\GroupsController' => __DIR__ . '/../../..' . '/settings/Controller/GroupsController.php',
'OC\\Settings\\Controller\\LogSettingsController' => __DIR__ . '/../../..' . '/settings/Controller/LogSettingsController.php',
'OC\\Settings\\Controller\\MailSettingsController' => __DIR__ . '/../../..' . '/settings/Controller/MailSettingsController.php',
'OC\\Settings\\Controller\\PersonalSettingsController' => __DIR__ . '/../../..' . '/settings/Controller/PersonalSettingsController.php',

+ 78
- 3
lib/private/L10N/Factory.php View File

@@ -59,6 +59,11 @@ class Factory implements IFactory {
*/
protected $pluralFunctions = [];

const COMMON_LANGUAGE_CODES = [
'en', 'es', 'fr', 'de', 'de_DE', 'ja', 'ar', 'ru', 'nl', 'it',
'pt_BR', 'pt_PT', 'da', 'fi_FI', 'nb_NO', 'sv', 'tr', 'zh_CN', 'ko'
];

/** @var IConfig */
protected $config;

@@ -137,9 +142,9 @@ class Factory implements IFactory {
*
* @link https://github.com/owncloud/core/issues/21955
*/
if($this->config->getSystemValue('installed', false)) {
if ($this->config->getSystemValue('installed', false)) {
$userId = !is_null($this->userSession->getUser()) ? $this->userSession->getUser()->getUID() : null;
if(!is_null($userId)) {
if (!is_null($userId)) {
$userLang = $this->config->getUserValue($userId, 'core', 'lang', null);
} else {
$userLang = null;
@@ -310,7 +315,7 @@ class Factory implements IFactory {
*/
private function isSubDirectory($sub, $parent) {
// Check whether $sub contains no ".."
if(strpos($sub, '..') !== false) {
if (strpos($sub, '..') !== false) {
return false;
}

@@ -441,4 +446,74 @@ class Factory implements IFactory {
return $function;
}
}

/**
* returns the common language and other languages in an
* associative array
*
* @return array
*/
public function getLanguages() {
$forceLanguage = $this->config->getSystemValue('force_language', false);
if ($forceLanguage !== false) {
return [];
}

$languageCodes = $this->findAvailableLanguages();

$commonLanguages = [];
$languages = [];

foreach($languageCodes as $lang) {
$l = $this->get('lib', $lang);
// TRANSLATORS this is the language name for the language switcher in the personal settings and should be the localized version
$potentialName = (string) $l->t('__language_name__');
if ($l->getLanguageCode() === $lang && $potentialName[0] !== '_') {//first check if the language name is in the translation file
$ln = array(
'code' => $lang,
'name' => $potentialName
);
} else if ($lang === 'en') {
$ln = array(
'code' => $lang,
'name' => 'English (US)'
);
} else {//fallback to language code
$ln = array(
'code' => $lang,
'name' => $lang
);
}

// put appropriate languages into appropriate arrays, to print them sorted
// common languages -> divider -> other languages
if (in_array($lang, self::COMMON_LANGUAGE_CODES)) {
$commonLanguages[array_search($lang, self::COMMON_LANGUAGE_CODES)] = $ln;
} else {
$languages[] = $ln;
}
}

ksort($commonLanguages);

// sort now by displayed language not the iso-code
usort( $languages, function ($a, $b) {
if ($a['code'] === $a['name'] && $b['code'] !== $b['name']) {
// If a doesn't have a name, but b does, list b before a
return 1;
}
if ($a['code'] !== $a['name'] && $b['code'] === $b['name']) {
// If a does have a name, but b doesn't, list a before b
return -1;
}
// Otherwise compare the names
return strcmp($a['name'], $b['name']);
});

return [
// reset indexes
'commonlanguages' => array_values($commonLanguages),
'languages' => $languages
];
}
}

+ 1
- 1
lib/private/NavigationManager.php View File

@@ -247,7 +247,7 @@ class NavigationManager implements INavigationManager {
'type' => 'settings',
'id' => 'core_users',
'order' => 4,
'href' => $this->urlGenerator->linkToRoute('settings_users'),
'href' => $this->urlGenerator->linkToRoute('settings.Users.usersList'),
'name' => $l->t('Users'),
'icon' => $this->urlGenerator->imagePath('settings', 'users.svg'),
]);

+ 16
- 56
lib/private/Settings/Personal/PersonalInfo.php View File

@@ -39,6 +39,7 @@ use OCP\L10N\IFactory;
use OCP\Settings\ISettings;

class PersonalInfo implements ISettings {

/** @var IConfig */
private $config;
/** @var IUserManager */
@@ -51,12 +52,6 @@ class PersonalInfo implements ISettings {
private $appManager;
/** @var IFactory */
private $l10nFactory;

const COMMON_LANGUAGE_CODES = [
'en', 'es', 'fr', 'de', 'de_DE', 'ja', 'ar', 'ru', 'nl', 'it',
'pt_BR', 'pt_PT', 'da', 'fi_FI', 'nb_NO', 'sv', 'tr', 'zh_CN', 'ko'
];

/** @var IL10N */
private $l;

@@ -198,64 +193,29 @@ class PersonalInfo implements ISettings {

$uid = $user->getUID();

$userLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage());
$languageCodes = $this->l10nFactory->findAvailableLanguages();

$commonLanguages = [];
$languages = [];
$userConfLang = $this->config->getUserValue($uid, 'core', 'lang', $this->l10nFactory->findLanguage());
$languages = $this->l10nFactory->getLanguages();

foreach($languageCodes as $lang) {
$l = \OC::$server->getL10N('lib', $lang);
// TRANSLATORS this is the language name for the language switcher in the personal settings and should be the localized version
$potentialName = (string) $l->t('__language_name__');
if($l->getLanguageCode() === $lang && $potentialName[0] !== '_') {//first check if the language name is in the translation file
$ln = array('code' => $lang, 'name' => $potentialName);
} elseif ($lang === 'en') {
$ln = ['code' => $lang, 'name' => 'English (US)'];
}else{//fallback to language code
$ln=array('code'=>$lang, 'name'=>$lang);
}

// put appropriate languages into appropriate arrays, to print them sorted
// used language -> common languages -> divider -> other languages
if ($lang === $userLang) {
$userLang = $ln;
} elseif (in_array($lang, self::COMMON_LANGUAGE_CODES)) {
$commonLanguages[array_search($lang, self::COMMON_LANGUAGE_CODES)]=$ln;
} else {
$languages[]=$ln;
}
// associate the user language with the proper array
$userLangIndex = array_search($userConfLang, array_column($languages['commonlanguages'], 'code'));
$userLang = $languages['commonlanguages'][$userLangIndex];
// search in the other languages
if ($userLangIndex === false) {
$userLangIndex = array_search($userConfLang, array_column($languages['languages'], 'code'));
$userLang = $languages['languages'][$userLangIndex];
}

// if user language is not available but set somehow: show the actual code as name
if (!is_array($userLang)) {
$userLang = [
'code' => $userLang,
'name' => $userLang,
'code' => $userConfLang,
'name' => $userConfLang,
];
}

ksort($commonLanguages);

// sort now by displayed language not the iso-code
usort( $languages, function ($a, $b) {
if ($a['code'] === $a['name'] && $b['code'] !== $b['name']) {
// If a doesn't have a name, but b does, list b before a
return 1;
}
if ($a['code'] !== $a['name'] && $b['code'] === $b['name']) {
// If a does have a name, but b doesn't, list a before b
return -1;
}
// Otherwise compare the names
return strcmp($a['name'], $b['name']);
});

return [
'activelanguage' => $userLang,
'commonlanguages' => $commonLanguages,
'languages' => $languages
];
return array_merge(
array('activelanguage' => $userLang),
$languages
);
}

/**

+ 15
- 0
settings/.babelrc View File

@@ -0,0 +1,15 @@
{
"presets": [
[
"env",
{
"targets": {
"browsers": ["last 2 versions", "ie >= 11"]
},
"modules": false,
"blacklist": ["useStrict"],
"useBuiltIns": true
}
]
]
}

+ 12
- 0
settings/.editorconfig View File

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

[*]
charset = utf-8
indent_style = tab
end_of_line = lf
insert_final_newline = true
trim_trailing_whitespace = true

[{package.json,.travis.yml,webpack.config.js}]
indent_style = space
indent_size = 2

+ 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
- 157
settings/Controller/GroupsController.php View File

@@ -1,157 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Joas Schilling <coding@schilljs.com>
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Morris Jobke <hey@morrisjobke.de>
*
* @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/>
*
*/

namespace OC\Settings\Controller;

use OC\AppFramework\Http;
use OC\Group\MetaData;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http\DataResponse;
use OCP\IGroup;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserSession;

/**
* @package OC\Settings\Controller
*/
class GroupsController extends Controller {
/** @var IGroupManager */
private $groupManager;
/** @var IL10N */
private $l10n;
/** @var IUserSession */
private $userSession;
/** @var bool */
private $isAdmin;

/**
* @param string $appName
* @param IRequest $request
* @param IGroupManager $groupManager
* @param IUserSession $userSession
* @param bool $isAdmin
* @param IL10N $l10n
*/
public function __construct($appName,
IRequest $request,
IGroupManager $groupManager,
IUserSession $userSession,
$isAdmin,
IL10N $l10n) {
parent::__construct($appName, $request);
$this->groupManager = $groupManager;
$this->userSession = $userSession;
$this->isAdmin = $isAdmin;
$this->l10n = $l10n;
}

/**
* @NoAdminRequired
*
* @param string $pattern
* @param bool $filterGroups
* @param int $sortGroups
* @return DataResponse
*/
public function index($pattern = '', $filterGroups = false, $sortGroups = MetaData::SORT_USERCOUNT) {
$groupPattern = $filterGroups ? $pattern : '';

$groupsInfo = new MetaData(
$this->userSession->getUser()->getUID(),
$this->isAdmin,
$this->groupManager,
$this->userSession
);
$groupsInfo->setSorting($sortGroups);
list($adminGroups, $groups) = $groupsInfo->get($groupPattern, $pattern);

return new DataResponse(
array(
'data' => array('adminGroups' => $adminGroups, 'groups' => $groups)
)
);
}

/**
* @PasswordConfirmationRequired
* @param string $id
* @return DataResponse
*/
public function create($id) {
if($this->groupManager->groupExists($id)) {
return new DataResponse(
array(
'message' => (string)$this->l10n->t('Group already exists.')
),
Http::STATUS_CONFLICT
);
}
$group = $this->groupManager->createGroup($id);
if($group instanceof IGroup) {
return new DataResponse(['groupname' => $group->getDisplayName()], Http::STATUS_CREATED);
}

return new DataResponse(
array(
'status' => 'error',
'data' => array(
'message' => (string)$this->l10n->t('Unable to add group.')
)
),
Http::STATUS_FORBIDDEN
);
}

/**
* @PasswordConfirmationRequired
* @param string $id
* @return DataResponse
*/
public function destroy($id) {
$group = $this->groupManager->get($id);
if ($group) {
if ($group->delete()) {
return new DataResponse(
array(
'status' => 'success',
'data' => ['groupname' => $group->getDisplayName()]
),
Http::STATUS_NO_CONTENT
);
}
}
return new DataResponse(
array(
'status' => 'error',
'data' => array(
'message' => (string)$this->l10n->t('Unable to delete group.')
),
),
Http::STATUS_FORBIDDEN
);
}

}

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


+ 26
- 0
settings/Makefile View File

@@ -0,0 +1,26 @@
all: dev-setup build-js-production

dev-setup: clean clean-dev npm-init

npm-init:
npm install

npm-update:
npm update

build-js:
npm run dev

build-js-production:
npm run build

watch-js:
npm run watch

clean:
rm -f js/main.js
rm -f js/main.js.map

clean-dev:
rm -rf node_modules


+ 19
- 0
settings/README.md View File

@@ -0,0 +1,19 @@
# Settings section

> Nextcloud settings with Vue

## Build Setup

``` bash
# install dependencies
make dev-setup

# build for development
make build-js

# build for development and watch edits
make watch-js

# build for production with minification
make build-js-production
```

+ 0
- 77
settings/ajax/setquota.php View File

@@ -1,77 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
* @author Bart Visscher <bartv@thisnet.nl>
* @author Björn Schießle <bjoern@schiessle.org>
* @author Christopher Schäpers <kondou@ts.unde.re>
* @author Felix Moeller <mail@felixmoeller.de>
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Joas Schilling <coding@schilljs.com>
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Robin Appelman <robin@icewind.nl>
* @author Thomas Müller <thomas.mueller@tmit.eu>
*
* @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/>
*
*/

OC_JSON::checkSubAdminUser();
\OC_JSON::callCheck();

$lastConfirm = (int) \OC::$server->getSession()->get('last-password-confirm');
if ($lastConfirm < (time() - 30 * 60 + 15)) { // allow 15 seconds delay
$l = \OC::$server->getL10N('core');
OC_JSON::error(array( 'data' => array( 'message' => $l->t('Password confirmation is required'))));
exit();
}

$username = isset($_POST["username"]) ? (string)$_POST["username"] : '';

$isUserAccessible = false;
$currentUserObject = \OC::$server->getUserSession()->getUser();
$targetUserObject = \OC::$server->getUserManager()->get($username);
if($targetUserObject !== null && $currentUserObject !== null) {
$isUserAccessible = \OC::$server->getGroupManager()->getSubAdmin()->isUserAccessible($currentUserObject, $targetUserObject);
}

if(($username === '' && !OC_User::isAdminUser(OC_User::getUser()))
|| (!OC_User::isAdminUser(OC_User::getUser())
&& !$isUserAccessible)) {
$l = \OC::$server->getL10N('core');
OC_JSON::error(array( 'data' => array( 'message' => $l->t('Authentication error') )));
exit();
}

//make sure the quota is in the expected format
$quota= (string)$_POST["quota"];
if($quota !== 'none' and $quota !== 'default') {
$quota= OC_Helper::computerFileSize($quota);
$quota=OC_Helper::humanFileSize($quota);
}

// Return Success story
if($username) {
$targetUserObject->setQuota($quota);
}else{//set the default quota when no username is specified
if($quota === 'default') {//'default' as default quota makes no sense
$quota='none';
}
\OC::$server->getConfig()->setAppValue('files', 'default_quota', $quota);
}
OC_JSON::success(array("data" => array( "username" => $username , 'quota' => $quota)));


+ 0
- 55
settings/ajax/togglesubadmins.php View File

@@ -1,55 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Bart Visscher <bartv@thisnet.nl>
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Joas Schilling <coding@schilljs.com>
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Roeland Jago Douma <roeland@famdouma.nl>
*
* @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/>
*
*/
OC_JSON::checkAdminUser();
\OC_JSON::callCheck();

$lastConfirm = (int) \OC::$server->getSession()->get('last-password-confirm');
if ($lastConfirm < (time() - 30 * 60 + 15)) { // allow 15 seconds delay
$l = \OC::$server->getL10N('core');
OC_JSON::error(array( 'data' => array( 'message' => $l->t('Password confirmation is required'))));
exit();
}

$username = (string)$_POST['username'];
$group = (string)$_POST['group'];

$subAdminManager = \OC::$server->getGroupManager()->getSubAdmin();
$targetUserObject = \OC::$server->getUserManager()->get($username);
$targetGroupObject = \OC::$server->getGroupManager()->get($group);

$isSubAdminOfGroup = false;
if($targetUserObject !== null && $targetGroupObject !== null) {
$isSubAdminOfGroup = $subAdminManager->isSubAdminOfGroup($targetUserObject, $targetGroupObject);
}

// Toggle group
if($isSubAdminOfGroup) {
$subAdminManager->deleteSubAdmin($targetUserObject, $targetGroupObject);
} else {
$subAdminManager->createSubAdmin($targetUserObject, $targetGroupObject);
}

OC_JSON::success();

+ 175
- 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,178 @@ 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 {
// TODO replace with css4 subgrid when available
display: grid;
grid-row-start: span 1;
grid-gap: 3px;
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;
&.disabled {
opacity: .5;
}
.name,
.displayName,
.password {
width: 150px;
}
.mailAddress,
.groups,
.subadmins {
width: 200px;
}
.quota {
width: 150px;
}
.languages {
width: 200px;
}
.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;
}
/* fake input for groups validation */
input#newgroups {
position: absolute;
opacity: 0;
width: 80% !important;
margin: 0 10%;
z-index: 0;
}
}
// separate prop to set initial value to top:0
&#new-user {
top: $grid-row-height;
}
&#grid-header {
color: nc-lighten($color-main-text, 60%);
z-index: 60; /* above new-user */
}
&: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: calc(100% - 4px); /* minus left and right */
left: 2px;
bottom: 2px;
height: 3px;
z-index: 5; /* above multiselect */
}
}
.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;
}
}
}
/* Fill the grid cell */
.multiselect.multiselect-vue {
width: 100%;
}
}
}
.infinite-loading-container {
display: flex;
align-items: center;
justify-content: center;
grid-row-start: span 4;
}
.users-list-end {
opacity: .5;
user-select: none;
}
}
}

+ 56
- 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


+ 10119
- 0
settings/package-lock.json
File diff suppressed because it is too large
View File


+ 45
- 0
settings/package.json View File

@@ -0,0 +1,45 @@
{
"name": "settings",
"description": "Nextcloud settings",
"version": "1.0.0",
"author": "John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>",
"license": "AGPL3",
"private": true,
"scripts": {
"dev": "webpack --config webpack.dev.js",
"watch": "webpack --progress --watch --config webpack.dev.js",
"build": "webpack --progress --hide-modules --config webpack.prod.js"
},
"dependencies": {
"axios": "^0.18.0",
"babel-polyfill": "^6.26.0",
"v-tooltip": "^2.0.0-rc.32",
"vue": "^2.5.16",
"vue-click-outside": "^1.0.7",
"vue-infinite-loading": "^2.3.1",
"vue-localstorage": "^0.6.2",
"vue-multiselect": "^2.1.0",
"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.3",
"babel-loader": "^7.1.4",
"babel-preset-env": "^1.7.0",
"babel-preset-stage-3": "^6.24.1",
"css-loader": "^0.28.11",
"file-loader": "^1.1.11",
"node-sass": "^4.9.0",
"sass-loader": "^7.0.1",
"vue-loader": "^14.2.2",
"vue-template-compiler": "^2.5.16",
"webpack": "^4.8.3",
"webpack-cli": "^2.1.3",
"webpack-merge": "^4.1.2"
}
}

+ 3
- 25
settings/routes.php View File

@@ -39,7 +39,6 @@ namespace OC\Settings;
$application = new Application();
$application->registerRoutes($this, [
'resources' => [
'users' => ['url' => '/settings/users/users'],
'AuthSettings' => ['url' => '/settings/personal/authtokens'],
],
'routes' => [
@@ -50,12 +49,10 @@ $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#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'],
['name' => 'Users#setEnabled', 'url' => '/settings/users/{id}/setEnabled', 'verb' => 'POST'],
['name' => 'Users#stats', 'url' => '/settings/users/stats', 'verb' => 'GET'],
['name' => 'Users#usersList', 'url' => '/settings/users', 'verb' => 'GET'],
['name' => 'Users#usersListByGroup', 'url' => '/settings/users/{group}', 'verb' => 'GET'],
['name' => 'LogSettings#setLogLevel', 'url' => '/settings/admin/log/level', 'verb' => 'POST'],
['name' => 'LogSettings#getEntries', 'url' => '/settings/admin/log/entries', 'verb' => 'GET'],
['name' => 'LogSettings#download', 'url' => '/settings/admin/log/download', 'verb' => 'GET'],
@@ -70,12 +67,7 @@ $application->registerRoutes($this, [
['name' => 'AdminSettings#index', 'url' => '/settings/admin/{section}', 'verb' => 'GET', 'defaults' => ['section' => 'server']],
['name' => 'AdminSettings#form', 'url' => '/settings/admin/{section}', 'verb' => 'GET'],
['name' => 'ChangePassword#changePersonalPassword', 'url' => '/settings/personal/changepassword', 'verb' => 'POST'],
['name' => 'ChangePassword#changeUserPassword', 'url' => '/settings/users/changepassword', 'verb' => 'POST'],
['name' => 'Groups#index', 'url' => '/settings/users/groups', 'verb' => 'GET'],
['name' => 'Groups#show', 'url' => '/settings/users/groups/{id}', 'requirements' => ['id' => '[^?]*'], 'verb' => 'GET'],
['name' => 'Groups#create', 'url' => '/settings/users/groups', 'verb' => 'POST'],
['name' => 'Groups#update', 'url' => '/settings/users/groups/{id}', 'requirements' => ['id' => '[^?]*'], 'verb' => 'PUT'],
['name' => 'Groups#destroy', 'url' => '/settings/users/groups/{id}', 'requirements' => ['id' => '[^?]*'], 'verb' => 'DELETE'],
['name' => 'ChangePassword#changeUserPassword', 'url' => '/settings/users/changepassword', 'verb' => 'POST']
]
]);

@@ -84,18 +76,7 @@ $application->registerRoutes($this, [
// Settings pages
$this->create('settings_help', '/settings/help')
->actionInclude('settings/help.php');
$this->create('settings_users', '/settings/users')
->actionInclude('settings/users.php');
// Settings ajax actions
// users
$this->create('settings_ajax_setquota', '/settings/ajax/setquota.php')
->actionInclude('settings/ajax/setquota.php');
$this->create('settings_ajax_togglegroups', '/settings/ajax/togglegroups.php')
->actionInclude('settings/ajax/togglegroups.php');
$this->create('settings_ajax_togglesubadmins', '/settings/ajax/togglesubadmins.php')
->actionInclude('settings/ajax/togglesubadmins.php');
$this->create('settings_ajax_changegorupname', '/settings/ajax/changegroupname.php')
->actionInclude('settings/ajax/changegroupname.php');
// apps
$this->create('settings_ajax_enableapp', '/settings/ajax/enableapp.php')
->actionInclude('settings/ajax/enableapp.php');
@@ -105,6 +86,3 @@ $this->create('settings_ajax_updateapp', '/settings/ajax/updateapp.php')
->actionInclude('settings/ajax/updateapp.php');
$this->create('settings_ajax_uninstallapp', '/settings/ajax/uninstallapp.php')
->actionInclude('settings/ajax/uninstallapp.php');
// admin
$this->create('settings_ajax_excludegroups', '/settings/ajax/excludegroups.php')
->actionInclude('settings/ajax/excludegroups.php');

+ 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" :class="{'icon-loading': menu.loading}">
<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>

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

@@ -0,0 +1,117 @@
<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 v-if="item.href" :href="(item.href) ? item.href : '#' " @click="toggleCollapse" :class="item.icon" >
<img v-if="item.iconUrl" :alt="item.text" :src="item.iconUrl">
{{item.text}}
</a>

<!-- Router link if specified. href OR router -->
<router-link :to="item.router" v-else-if="item.router" :class="item.icon" >
<img v-if="item.iconUrl" :alt="item.text" :src="item.iconUrl">
{{item.text}}
</router-link>

<!-- 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 @click="item.utils.actions[0].action" :class="item.utils.actions[0].icon" :title="item.utils.actions[0].text"></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 @click="action.action" :class="action.icon" :title="action.text"></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>

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

@@ -0,0 +1,28 @@
<template>
<li>
<!-- If item.href is set, a link will be directly used -->
<a @click="item.action" v-if="item.href" :href="(item.href) ? item.href : '#' ">
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>
</a>
<!-- If item.action is set instead, a button will be used -->
<button @click="item.action" v-else-if="item.action">
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>
</button>
<!-- If item.longtext is set AND the item does not have an action -->
<span v-else>
<span :class="item.icon"></span>
<span v-if="item.text">{{item.text}}</span>
<p v-else-if="item.longtext">{{item.longtext}}</p>
</span>
</li>
</template>

<script>
export default {
props: ['item']
}
</script>

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

@@ -0,0 +1,298 @@
<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 && settings.isAdmin">{{ t('settings', 'Group admin for') }}</div>
<div id="headerQuota" class="quota">{{ t('settings', 'Quota') }}</div>
<div id="headerLanguages" class="languages"
v-if="showConfig.showLanguages">{{ t('settings', 'Languages') }}</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">
<!-- hidden input trick for vanilla html5 form validation -->
<input type="text" :value="newUser.groups" v-if="!settings.isAdmin"
tabindex="-1" id="newgroups" :required="!settings.isAdmin" />
<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"
:allowEmpty="settings.isAdmin">
<!-- If user is not admin, he is a subadmin.
Subadmins can't create users outside their groups
Therefore, empty select is forbidden -->
<span slot="noResult">{{t('settings', 'No results')}}</span>
</multiselect>
</div>
<div class="subadmins" v-if="subAdminsGroups.length>0 && settings.isAdmin">
<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 results')}}</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="languages" v-if="showConfig.showLanguages">
<multiselect :options="languages" v-model="newUser.language"
:placeholder="t('settings', 'Default language')"
label="name" track-by="code" class="multiselect-vue"
:allowEmpty="false" group-values="languages" group-label="label">
</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 filteredUsers" :user="user" :key="key" :settings="settings" :showConfig="showConfig"
:groups="groups" :subAdminsGroups="subAdminsGroups" :quotaOptions="quotaOptions" :languages="languages" />
<infinite-loading @infinite="infiniteHandler" ref="infiniteLoading">
<div slot="spinner"><div class="users-icon-loading icon-loading"></div></div>
<div slot="no-more"><div class="users-list-end">— {{t('settings', 'no more results')}} —</div></div>
<div slot="no-results">
<div id="emptycontent">
<div class="icon-contacts-dark"></div>
<h2>{{t('settings', 'No users in here')}}</h2>
</div>
</div>
</infinite-loading>
</div>
</template>

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

export default {
name: 'userList',
props: ['users', 'showConfig', 'selectedGroup'],
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,
language: {code: 'en', name: t('settings', 'Default language')}
}
};
},
mounted() {
if (!this.settings.canChangePassword) {
OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled'));
}

/**
* Init default language from server data. The use of this.settings
* requires a computed variable, which break the v-model binding of the form,
* this is a much easier solution than getter and setter on a computed var
*/
Vue.set(this.newUser.language, 'code', this.settings.defaultLanguage);

/**
* In case the user directly loaded the user list within a group
* the watch won't be triggered. We need to initialize it.
*/
this.setNewUserDefaultGroup(this.$route.params.selectedGroup);
},
computed: {
settings() {
return this.$store.getters.getServerData;
},
filteredUsers() {
if (this.selectedGroup === 'disabled') {
let disabledUsers = this.users.filter(user => user.enabled !== true);
if (disabledUsers.length===0 && this.$refs.infiniteLoading && this.$refs.infiniteLoading.isComplete) {
// disabled group is empty, redirection to all users
this.$router.push({name: 'users'});
this.$refs.infiniteLoading.$emit('$InfiniteLoading:reset');
}
return disabledUsers;
}
return this.users.filter(user => user.enabled === true);
},
groups() {
// data provided php side + remove the disabled group
return this.$store.getters.getGroups
.filter(group => group.id !== 'disabled')
.sort((a, b) => a.name.localeCompare(b.name));
},
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;
},

/* LANGUAGES */
languages() {
return Array(
{
label: t('settings', 'Common languages'),
languages: this.settings.languages.commonlanguages
},
{
label: t('settings', 'All languages'),
languages: this.settings.languages.languages
}
);
}
},
watch: {
// watch url change and group select
selectedGroup: function (val, old) {
this.$store.commit('resetUsers');
this.$refs.infiniteLoading.$emit('$InfiniteLoading:reset');
this.setNewUserDefaultGroup(val);
}
},
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,
group: this.selectedGroup !== 'disabled' ? this.selectedGroup : ''
})
.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),
subadmin: this.newUser.subAdminsGroups.map(group => group.id),
quota: this.newUser.quota.id,
language: this.newUser.language.code,
}).then(() => this.resetForm())
.catch(() => this.loading = false);
},
setNewUserDefaultGroup(value) {
if (value && value.length > 0) {
// setting new user default group to the current selected one
let currentGroup = this.groups.find(group => group.id === value);
if (currentGroup) {
this.newUser.groups = [currentGroup];
return;
}
}
// fallback, empty selected group
this.newUser.groups = [];
}
}
}
</script>

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

@@ -0,0 +1,449 @@
<template>
<div class="row" :class="{'disabled': loading.delete || loading.disable}">
<div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable}">
<img alt="" width="32" height="32" :src="generateAvatar(user.id, 32)"
:srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"
v-if="!loading.delete && !loading.disable">
</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"
:multiple="true" :taggable="settings.isAdmin" :closeOnSelect="false"
@tag="createGroup" @select="addUserGroup" @remove="removeUserGroup">
<span slot="limit" class="multiselect__limit" v-tooltip.auto="formatGroupsTitle(userGroups)">+{{userGroups.length-2}}</span>
<span slot="noResult">{{t('settings', 'No results')}}</span>
</multiselect>
</div>
<div class="subadmins" v-if="subAdminsGroups.length>0 && settings.isAdmin" :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"
:multiple="true" :closeOnSelect="false"
@select="addUserSubAdmin" @remove="removeUserSubAdmin">
<span slot="limit" class="multiselect__limit" v-tooltip.auto="formatGroupsTitle(userSubAdminsGroups)">+{{userSubAdminsGroups.length-2}}</span>
<span slot="noResult">{{t('settings', 'No results')}}</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="languages" :class="{'icon-loading-small': loading.languages}"
v-if="showConfig.showLanguages">
<multiselect :value="userLanguage" :options="languages" :disabled="loading.languages||loading.all"
:placeholder="t('settings', 'No language set')"
label="name" track-by="code" class="multiselect-vue"
:allowEmpty="false" group-values="languages" group-label="label"
@input="setUserLanguage">
</multiselect>
</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" v-tooltip.auto="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' && !loading.all">
<div class="icon-more" v-click-outside="hideMenu" @click="toggleMenu"></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 Vue from 'vue'
import VTooltip from 'v-tooltip'

Vue.use(VTooltip)

export default {
name: 'userRow',
props: ['user', 'settings', 'groups', 'subAdminsGroups', 'quotaOptions', 'showConfig', 'languages'],
components: {
popoverMenu,
Multiselect
},
directives: {
ClickOutside
},
mounted() {
// required if popup needs to stay opened after menu click
// since we only have disable/delete actions, let's close it directly
// 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,
delete: false,
disable: false,
languages: false
}
}
},
computed: {
/* USER POPOVERMENU ACTIONS */
userActions() {
return [{
icon: 'icon-delete',
text: t('settings','Delete user'),
action: this.deleteUser
},{
icon: this.user.enabled ? 'icon-close' : 'icon-add',
text: this.user.enabled ? t('settings','Disable user') : t('settings','Enable user'),
action: this.enableDisableUser
}]
},

/* 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;
},

/* LANGUAGE */
userLanguage() {
let availableLanguages = this.languages[0].languages.concat(this.languages[1].languages);
let userLang = availableLanguages.find(lang => lang.code === this.user.language);
if (typeof userLang !== 'object' && this.user.language !== '') {
return {
code: this.user.language,
name: this.user.language
}
} else if(this.user.language === '') {
return false;
}
return userLang;
}
},
methods: {
/* MENU HANDLING */
toggleMenu() {
this.openedMenu = !this.openedMenu;
},
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 array of groups objects to a string for the popup
*
* @param {array} groups The groups
* @returns {string}
*/
formatGroupsTitle(groups) {
let names = groups.map(group => group.name);
return names.slice(2,).join(', ');
},

deleteUser() {
this.loading.delete = true;
this.loading.all = true;
let userid = this.user.id;
return this.$store.dispatch('deleteUser', {userid})
.then(() => {
this.loading.delete = false
this.loading.all = false
});
},

enableDisableUser() {
this.loading.delete = true;
this.loading.all = true;
let userid = this.user.id;
let enabled = !this.user.enabled;
return this.$store.dispatch('enableDisableUser', {userid, enabled})
.then(() => {
this.loading.delete = false
this.loading.all = false
});
},

/**
* 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
// remove user from current list if current list is the removed group
if (this.$route.params.selectedGroup === gid) {
this.$store.commit('deleteUser', userid);
}
});
},

/**
* 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);
},

/**
* Dispatch quota set request
*
* @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 do not change
return false;
},

/**
* Dispatch language set request
*
* @param {Object} lang language object {code:'en', name:'English'}
* @returns {Object}
*/
setUserLanguage(lang) {
this.loading.languages = true;
// ensure we only send the preset id
this.$store.dispatch('setUserData', {
userid: this.user.id,
key: 'language',
value: lang.code
}).then(() => this.loading.languages = false);
return lang;
}
}
}
</script>

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

@@ -0,0 +1,22 @@
import Vue from 'vue';
import { sync } from 'vuex-router-sync';
import App from './App.vue';
import router from './router';
import store from './store';
require("babel-polyfill");


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 };

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

@@ -0,0 +1,36 @@
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',
// if index.php is in the url AND we got this far, then it's working:
// let's keep using index.php in the url
base: OC.generateUrl(''),
routes: [
{
path: '/:index(index.php/)?settings/users',
component: Users,
props: true,
name: 'users',
children: [
{
path: ':selectedGroup',
name: 'group',
component: Users
}
]
}
]
});

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

@@ -0,0 +1,99 @@
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 url slash
};

export default {

/**
* This Promise is used to chain a request that require an admin password confirmation
* Since chaining Promise have a very precise behavior concerning catch and then,
* you'll need to be careful when using it.
* e.g
* // store
* action(context) {
* return api.requireAdmin().then((response) => {
* return api.get('url')
* .then((response) => {API success})
* .catch((error) => {API failure});
* }).catch((error) => {requireAdmin failure});
* }
* // vue
* this.$store.dispatch('action').then(() => {always executed})
*
* Since Promise.then().catch().then() will always execute the last then
* this.$store.dispatch('action').then will always be executed
*
* If you want requireAdmin failure to also catch the API request failure
* you will need to throw a new error in the api.get.catch()
*
* e.g
* api.requireAdmin().then((response) => {
* api.get('url')
* .then((response) => {API success})
* .catch((error) => {throw error;});
* }).catch((error) => {requireAdmin OR API failure});
*
* @returns {Promise}
*/
requireAdmin() {
return new Promise(function(resolve, reject) {
// TODO: migrate the OC.dialog to Vue and avoid this mess
// wait for password confirmation
let passwordTimeout;
let waitForpassword = function() {
if (OC.PasswordConfirmation.requiresPasswordConfirmation()) {
passwordTimeout = setTimeout(waitForpassword, 500);
return;
}
clearTimeout(passwordTimeout);
clearTimeout(promiseTimeout);
resolve();
};

// automatically reject after 5s if not resolved
let promiseTimeout = setTimeout(() => {
clearTimeout(passwordTimeout);
// close dialog
if (document.getElementsByClassName('oc-dialog-close').length>0) {
document.getElementsByClassName('oc-dialog-close')[0].click();
}
OC.Notification.showTemporary(t('settings', 'You did not enter the password in time'));
reject('Password request cancelled');
}, 7000);

// request password
OC.PasswordConfirmation.requirePasswordConfirmation();
waitForpassword();
});
},
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, tokenHeaders)
.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));
}
};

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

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

Vue.use(Vuex)

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

const mutations = {
API_FAILURE(state, error) {
let message = error.error.response.data.ocs.meta.message;
OC.Notification.showHtml(t('settings','An error occured during the request. Unable to proceed.')+'<br>'+message, {timeout: 7});
console.log(state, error);
}
};

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

mutations
});

+ 25
- 0
settings/src/store/oc.js View File

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

const state = {};
const mutations = {};
const getters = {};
const actions = {
/**
* Set application config in database
*
* @param {Object} context
* @param {Object} options
* @param {string} options.app Application name
* @param {boolean} options.key Config key
* @param {boolean} options.value Value to set
* @returns{Promise}
*/
setAppConfig(context, {app, key, value}) {
return api.requireAdmin().then((response) => {
return api.post(OC.linkToOCS(`apps/provisioning_api/api/v1/config/apps/${app}/${key}`, 2), {value: value})
.catch((error) => {throw error;});
}).catch((error) => context.commit('API_FAILURE', { app, key, value, error }));;
}
};

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

+ 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};

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

@@ -0,0 +1,420 @@
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,
userCount: 0
};

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, userCount}) {
state.groups = groups;
state.orderBy = orderBy;
state.userCount = userCount;
state.groups = orderGroups(state.groups, state.orderBy);
},
addGroup(state, gid) {
try {
state.groups.push({
id: gid,
name: gid,
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);
}
},
removeGroup(state, gid) {
let groupIndex = state.groups.findIndex(groupSearch => groupSearch.id == gid);
if (groupIndex >= 0) {
state.groups.splice(groupIndex, 1);
}
},
addUserGroup(state, { userid, gid }) {
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 }) {
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;
// increment or not
state.groups.find(group => group.id == 'disabled').usercount += enabled ? -1 : 1;
state.userCount += enabled ? 1 : -1;
console.log(enabled);
},
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;
}
},

/**
* Reset users list
*/
resetUsers(state) {
state.users = [];
state.usersOffset = 0;
}
};

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;
},
getUserCount(state) {
return state.userCount;
}
};

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
* @param {string} options.search Search amongst users
* @param {string} options.group Get users from group
* @returns {Promise}
*/
getUsers(context, { offset, limit, search, group }) {
search = typeof search === 'string' ? search : '';
group = typeof group === 'string' ? group : '';
if (group !== '') {
return api.get(OC.linkToOCS(`cloud/groups/${group}/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));
}

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) {
if(oc_capabilities.password_policy && oc_capabilities.password_policy.minLength) {
context.commit('setPasswordPolicyMinLength', oc_capabilities.password_policy.minLength);
return oc_capabilities.password_policy.minLength;
}
return false;
},

/**
* 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) => {throw error;});
}).catch((error) => context.commit('API_FAILURE', { userid, error }));
},

/**
* Remove group
*
* @param {Object} context
* @param {string} gid Group id
* @returns {Promise}
*/
removeGroup(context, gid) {
return api.requireAdmin().then((response) => {
return api.delete(OC.linkToOCS(`cloud/groups/${gid}`, 2))
.then((response) => context.commit('removeGroup', gid))
.catch((error) => {throw error;});
}).catch((error) => context.commit('API_FAILURE', { gid, 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) => {throw error;});
}).catch((error) => context.commit('API_FAILURE', { userid, 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) => {throw error;});
}).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) => {throw error;});
}).catch((error) => context.commit('API_FAILURE', { userid, 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) => {throw error;});
}).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) => {throw error;});
}).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
* @param {string} options.groups User groups
* @param {string} options.subadmin User subadmin groups
* @param {string} options.quota User email
* @returns {Promise}
*/
addUser({commit, dispatch}, { userid, password, email, groups, subadmin, quota, language }) {
return api.requireAdmin().then((response) => {
return api.post(OC.linkToOCS(`cloud/users`, 2), { userid, password, email, groups, subadmin, quota, language })
.then((response) => dispatch('addUserData', userid))
.catch((error) => {throw error;});
}).catch((error) => 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) => {throw error;});
}).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) => {throw error;});
}).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 }) {
let allowedEmpty = ['email', 'displayname'];
if (['email', 'language', 'quota', 'displayname', 'password'].indexOf(key) !== -1) {
// We allow empty email or displayname
if (typeof value === 'string' &&
(
(allowedEmpty.indexOf(key) === -1 && value.length > 0) ||
allowedEmpty.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) => {throw error;});
}).catch((error) => context.commit('API_FAILURE', { userid, error }));
}
}
return Promise.reject(new Error('Invalid request data'));
}
};

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

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

@@ -0,0 +1,301 @@
<template>
<div id="app">
<app-navigation :menu="menu">
<template slot="settings-content">
<div>
<p>{{t('settings', 'Default quota :')}}</p>
<multiselect :value="defaultQuota" :options="quotaOptions"
tag-placeholder="create" :placeholder="t('settings', 'Select default quota')"
label="label" track-by="id" class="multiselect-vue"
:allowEmpty="false" :taggable="true"
@tag="validateQuota" @input="setDefaultQuota">
</multiselect>

</div>
<div>
<input type="checkbox" id="showLanguages" class="checkbox" v-model="showLanguages">
<label for="showLanguages">{{t('settings', 'Show Languages')}}</label>
</div>
<div>
<input type="checkbox" id="showLastLogin" class="checkbox" v-model="showLastLogin">
<label for="showLastLogin">{{t('settings', 'Show last login')}}</label>
</div>
<div>
<input type="checkbox" id="showUserBackend" class="checkbox" v-model="showUserBackend">
<label for="showUserBackend">{{t('settings', 'Show user backend')}}</label>
</div>
<div>
<input type="checkbox" id="showStoragePath" class="checkbox" v-model="showStoragePath">
<label for="showStoragePath">{{t('settings', 'Show storage path')}}</label>
</div>
</template>
</app-navigation>
<user-list :users="users" :showConfig="showConfig" :selectedGroup="selectedGroup" />
</div>
</template>

<script>
import appNavigation from '../components/appNavigation';
import userList from '../components/userList';
import Vue from 'vue';
import VueLocalStorage from 'vue-localstorage'
import Multiselect from 'vue-multiselect';
import api from '../store/api';

Vue.use(VueLocalStorage)
Vue.use(VueLocalStorage)

export default {
name: 'Users',
props: ['selectedGroup'],
components: {
appNavigation,
userList,
Multiselect
},
beforeMount() {
this.$store.commit('initGroups', {
groups: this.$store.getters.getServerData.groups,
orderBy: this.$store.getters.getServerData.sortGroups,
userCount: this.$store.getters.getServerData.userCount
});
this.$store.dispatch('getPasswordPolicyMinLength');
},
data() {
return {
// default quota is unlimited
unlimitedQuota: {id:'default', label:t('settings', 'Unlimited')},
// temporary value used for multiselect change
selectedQuota: false,
showConfig: {
showStoragePath: false,
showUserBackend: false,
showLastLogin: false,
showNewUserForm: false,
showLanguages: false
}
}
},
methods: {
toggleNewUserMenu() {
this.showConfig.showNewUserForm = !this.showConfig.showNewUserForm;
if (this.showConfig.showNewUserForm) {
Vue.nextTick(() => {
window.newusername.focus();
});
}
},
getLocalstorage(key) {
// force initialization
let localConfig = this.$localStorage.get(key);
// if localstorage is null, fallback to original values
this.showConfig[key] = localConfig !== null ? localConfig === 'true' : this.showConfig[key];
return this.showConfig[key];
},
setLocalStorage(key, status) {
this.showConfig[key] = status;
this.$localStorage.set(key, status);
return status;
},
removeGroup(groupid) {
let self = this;
// TODO migrate to a vue js confirm dialog component
OC.dialogs.confirm(
t('settings', 'You are about to remove the group {group}. The users will NOT be deleted.', {group: groupid}),
t('settings','Please confirm the group removal '),
function (success) {
if (success) {
self.$store.dispatch('removeGroup', groupid);
}
}
);
},

/**
* Dispatch default quota set request
*
* @param {string|Object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
* @returns {string}
*/
setDefaultQuota(quota = 'none') {
this.$store.dispatch('setAppConfig', {
app: 'files',
key: 'default_quota',
// ensure we only send the preset id
value: quota.id ? quota.id : quota
}).then(() => {
if (typeof quota !== 'object') {
quota = {id: quota, label: quota};
}
this.defaultQuota = 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.setDefaultQuota('none');
} else if (validQuota !== null) {
// unify format output
return this.setDefaultQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota)));
}
// if no valid do not change
return false;
},
},
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;
},

// Local settings
showLanguages: {
get: function() {return this.getLocalstorage('showLanguages')},
set: function(status) {
this.setLocalStorage('showLanguages', status);
}
},
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);
}
},

userCount() {
return this.$store.getters.getUserCount;
},
settings() {
return this.$store.getters.getServerData;
},

// default quota
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);
return quotaPreset;
},
// mapping saved values to objects
defaultQuota: {
get: function() {
if (this.selectedQuota !== false) {
return this.selectedQuota;
}
if (OC.Util.computerFileSize(this.settings.defaultQuota) > 0) {
// if value is valid, let's map the quotaOptions or return custom quota
return {id:this.settings.defaultQuota, label:this.settings.defaultQuota};
}
return this.unlimitedQuota; // unlimited
},
set: function(quota) {
this.selectedQuota = quota;
}
},

// BUILD APP NAVIGATION MENU OBJECT
menu() {
// 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 = []; // empty classes, active will be set later
item.router = { // router link to
name: 'group',
params: {selectedGroup: group.id}
};
item.text = group.name; // group name
item.utils = {counter: group.usercount}; // users count

if (item.id !== 'admin' && item.id !== 'disabled' && this.settings.isAdmin) {
// add delete button on real groups
let self = this;
item.utils.actions = [{
icon: 'icon-delete',
text: t('settings', 'Remove group'),
action: function() {self.removeGroup(group.id)}
}];
};
return item;
});

// Adjust data
let adminGroup = groups.find(group => group.id == 'admin');
let disabledGroupIndex = groups.findIndex(group => group.id == 'disabled');
let disabledGroup = groups[disabledGroupIndex];
if (adminGroup && adminGroup.text) {
adminGroup.text = t('settings', 'Admins'); // rename admin group
}
if (disabledGroup && disabledGroup.text) {
disabledGroup.text = t('settings', 'Disabled users'); // rename disabled group
if (disabledGroup.utils.counter === 0) {
groups.splice(disabledGroupIndex, 1); // remove disabled if empty
}
}

// Add everyone group
groups.unshift({
id: 'everyone',
classes: [],
router: {name:'users'},
text: t('settings', 'Everyone'),
utils: {counter: this.userCount}
});

// Set current group as active
let activeGroup = groups.findIndex(group => group.id === this.selectedGroup);
if (activeGroup >= 0) {
groups[activeGroup].classes.push('active');
} else {
groups[0].classes.push('active');
}

// Return
return {
id: 'usergrouplist',
new: {
id:'new-user-button',
text: t('settings','New user'),
icon: 'icon-add',
action: this.toggleNewUserMenu
},
items: groups
}
},
}
}
</script>

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

@@ -1,9 +1,24 @@
<?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
*
* return new TemplateResponse('settings', 'settings', ['serverData' => $serverData]);
*
*/

<?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'])) {
?>
<span id="serverData" data-server="<?php p(json_encode($_['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();
});
});

+ 0
- 149
settings/users.php View File

@@ -1,149 +0,0 @@
<?php
/**
* @copyright Copyright (c) 2016, ownCloud, Inc.
*
* @author Arthur Schiwon <blizzz@arthur-schiwon.de>
* @author Bart Visscher <bartv@thisnet.nl>
* @author Clark Tomlinson <fallen013@gmail.com>
* @author Daniel Molkentin <daniel@molkentin.de>
* @author Georg Ehrke <oc.list@georgehrke.com>
* @author Jakob Sack <mail@jakobsack.de>
* @author Joas Schilling <coding@schilljs.com>
* @author Jörn Friedrich Dreyer <jfd@butonic.de>
* @author Lukas Reschke <lukas@statuscode.ch>
* @author Morris Jobke <hey@morrisjobke.de>
* @author Robin Appelman <robin@icewind.nl>
* @author Roeland Jago Douma <roeland@famdouma.nl>
* @author Stephan Peijnik <speijnik@anexia-it.com>
* @author Thomas Müller <thomas.mueller@tmit.eu>
* @author Thomas Pulzer <t.pulzer@kniel.de>
*
* @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/>
*
*/

OC_Util::checkSubAdminUser();

\OC::$server->getNavigationManager()->setActiveEntry('core_users');

$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();

if ($config->getSystemValue('sort_groups_by_name', false)) {
$sortGroupsBy = \OC\Group\MetaData::SORT_GROUPNAME;
} else {
$isLDAPUsed = false;
if ($appManager->isEnabledForUser('user_ldap')) {
$isLDAPUsed =
$groupManager->isBackendUsed('\OCA\User_LDAP\Group_LDAP')
|| $groupManager->isBackendUsed('\OCA\User_LDAP\Group_Proxy');
if ($isLDAPUsed) {
// LDAP user count can be slow, so we sort by group name here
$sortGroupsBy = \OC\Group\MetaData::SORT_GROUPNAME;
}
}
}

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

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

$groupsInfo = new \OC\Group\MetaData(
$uid,
$isAdmin,
$groupManager,
\OC::$server->getUserSession()
);

$groupsInfo->setSorting($sortGroupsBy);
list($adminGroup, $groups) = $groupsInfo->get();

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

if($isAdmin) {
$subAdmins = \OC::$server->getGroupManager()->getSubAdmin()->getAllSubAdmins();
// New class returns IUser[] so convert back
$result = [];
foreach ($subAdmins as $subAdmin) {
$result[] = [
'gid' => $subAdmin['group']->getGID(),
'uid' => $subAdmin['user']->getUID(),
];
}
$subAdmins = $result;
}else{
/* Retrieve group IDs from $groups array, so we can pass that information into OC_Group::displayNamesInGroups() */
$gids = array();
foreach($groups as $group) {
if (isset($group['id'])) {
$gids[] = $group['id'];
}
}
$subAdmins = false;
}

$disabledUsers = $isLDAPUsed ? 0 : $userManager->countDisabledUsers();
$disabledUsersGroup = [
'id' => '_disabledUsers',
'name' => '_disabledUsers',
'usercount' => $disabledUsers
];

// load preset quotas
$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'));

$tmpl->printPage();

+ 77
- 0
settings/webpack.common.js View File

@@ -0,0 +1,77 @@
const path = require('path')

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']
}
}

+ 12
- 0
settings/webpack.dev.js View File

@@ -0,0 +1,12 @@
const merge = require('webpack-merge');
const common = require('./webpack.common.js');

module.exports = merge(common, {
mode: 'development',
devServer: {
historyApiFallback: true,
noInfo: true,
overlay: true
},
devtool: '#eval-source-map',
})

+ 7
- 0
settings/webpack.prod.js View File

@@ -0,0 +1,7 @@
const merge = require('webpack-merge')
const common = require('./webpack.common.js')

module.exports = merge(common, {
mode: 'production',
devtool: '#source-map'
})

+ 0
- 1
tests/Settings/ApplicationTest.php View File

@@ -72,7 +72,6 @@ class ApplicationTest extends TestCase {
[AuthSettingsController::class, Controller::class],
// Needs session: [CertificateController::class, Controller::class],
[CheckSetupController::class, Controller::class],
[GroupsController::class, Controller::class],
[LogSettingsController::class, Controller::class],
[MailSettingsController::class, Controller::class],
[UsersController::class, Controller::class],

+ 0
- 381
tests/Settings/Controller/GroupsControllerTest.php View File

@@ -1,381 +0,0 @@
<?php
/**
* @author Lukas Reschke
* @copyright 2014 Lukas Reschke lukas@owncloud.com
*
* This file is licensed under the Affero General Public License version 3 or
* later.
* See the COPYING-README file.
*/

namespace Tests\Settings\Controller;

use OC\Group\Group;
use OC\Group\MetaData;
use OC\Settings\Controller\GroupsController;
use OC\User\User;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\DataResponse;
use OCP\IGroupManager;
use OCP\IL10N;
use OCP\IRequest;
use OCP\IUserSession;

/**
* @package Tests\Settings\Controller
*/
class GroupsControllerTest extends \Test\TestCase {

/** @var IGroupManager|\PHPUnit_Framework_MockObject_MockObject */
private $groupManager;

/** @var IUserSession|\PHPUnit_Framework_MockObject_MockObject */
private $userSession;

/** @var GroupsController */
private $groupsController;

protected function setUp() {
parent::setUp();

$this->groupManager = $this->createMock(IGroupManager::class);
$this->userSession = $this->createMock(IUserSession::class);
$l = $this->createMock(IL10N::class);
$l->method('t')
->will($this->returnCallback(function($text, $parameters = []) {
return vsprintf($text, $parameters);
}));
$this->groupsController = new GroupsController(
'settings',
$this->createMock(IRequest::class),
$this->groupManager,
$this->userSession,
true,
$l
);

}

/**
* TODO: Since GroupManager uses the static OC_Subadmin class it can't be mocked
* to test for subadmins. Thus the test always assumes you have admin permissions...
*/
public function testIndexSortByName() {
$firstGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$firstGroup
->method('getGID')
->will($this->returnValue('firstGroup'));
$firstGroup
->method('getDisplayName')
->will($this->returnValue('First group'));
$firstGroup
->method('count')
->will($this->returnValue(12));
$secondGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$secondGroup
->method('getGID')
->will($this->returnValue('secondGroup'));
$secondGroup
->method('getDisplayName')
->will($this->returnValue('Second group'));
$secondGroup
->method('count')
->will($this->returnValue(25));
$thirdGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$thirdGroup
->method('getGID')
->will($this->returnValue('thirdGroup'));
$thirdGroup
->method('getDisplayName')
->will($this->returnValue('Third group'));
$thirdGroup
->method('count')
->will($this->returnValue(14));
$fourthGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$fourthGroup
->method('getGID')
->will($this->returnValue('admin'));
$fourthGroup
->method('getDisplayName')
->will($this->returnValue('Admin'));
$fourthGroup
->method('count')
->will($this->returnValue(18));
/** @var \OC\Group\Group[] $groups */
$groups = array();
$groups[] = $firstGroup;
$groups[] = $secondGroup;
$groups[] = $thirdGroup;
$groups[] = $fourthGroup;

$user = $this->getMockBuilder(User::class)
->disableOriginalConstructor()->getMock();
$this->userSession
->expects($this->once())
->method('getUser')
->will($this->returnValue($user));
$user
->expects($this->once())
->method('getUID')
->will($this->returnValue('MyAdminUser'));
$this->groupManager->method('search')
->will($this->returnValue($groups));

$expectedResponse = new DataResponse(
array(
'data' => array(
'adminGroups' => array(
0 => array(
'id' => 'admin',
'name' => 'Admin',
'usercount' => 0,//User count disabled 18,
)
),
'groups' =>
array(
0 => array(
'id' => 'firstGroup',
'name' => 'First group',
'usercount' => 0,//User count disabled 12,
),
1 => array(
'id' => 'secondGroup',
'name' => 'Second group',
'usercount' => 0,//User count disabled 25,
),
2 => array(
'id' => 'thirdGroup',
'name' => 'Third group',
'usercount' => 0,//User count disabled 14,
),
)
)
)
);
$response = $this->groupsController->index('', false, MetaData::SORT_GROUPNAME);
$this->assertEquals($expectedResponse, $response);
}

/**
* TODO: Since GroupManager uses the static OC_Subadmin class it can't be mocked
* to test for subadmins. Thus the test always assumes you have admin permissions...
*/
public function testIndexSortbyCount() {
$firstGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$firstGroup
->method('getGID')
->will($this->returnValue('firstGroup'));
$firstGroup
->method('getDisplayName')
->will($this->returnValue('First group'));
$firstGroup
->method('count')
->will($this->returnValue(12));
$secondGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$secondGroup
->method('getGID')
->will($this->returnValue('secondGroup'));
$secondGroup
->method('getDisplayName')
->will($this->returnValue('Second group'));
$secondGroup
->method('count')
->will($this->returnValue(25));
$thirdGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$thirdGroup
->method('getGID')
->will($this->returnValue('thirdGroup'));
$thirdGroup
->method('getDisplayName')
->will($this->returnValue('Third group'));
$thirdGroup
->method('count')
->will($this->returnValue(14));
$fourthGroup = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$fourthGroup
->method('getGID')
->will($this->returnValue('admin'));
$fourthGroup
->method('getDisplayName')
->will($this->returnValue('Admin'));
$fourthGroup
->method('count')
->will($this->returnValue(18));
/** @var \OC\Group\Group[] $groups */
$groups = array();
$groups[] = $firstGroup;
$groups[] = $secondGroup;
$groups[] = $thirdGroup;
$groups[] = $fourthGroup;

$user = $this->getMockBuilder(User::class)
->disableOriginalConstructor()->getMock();
$this->userSession
->expects($this->once())
->method('getUser')
->will($this->returnValue($user));
$user
->expects($this->once())
->method('getUID')
->will($this->returnValue('MyAdminUser'));
$this->groupManager
->method('search')
->will($this->returnValue($groups));

$expectedResponse = new DataResponse(
array(
'data' => array(
'adminGroups' => array(
0 => array(
'id' => 'admin',
'name' => 'Admin',
'usercount' => 18,
)
),
'groups' =>
array(
0 => array(
'id' => 'secondGroup',
'name' => 'Second group',
'usercount' => 25,
),
1 => array(
'id' => 'thirdGroup',
'name' => 'Third group',
'usercount' => 14,
),
2 => array(
'id' => 'firstGroup',
'name' => 'First group',
'usercount' => 12,
),
)
)
)
);
$response = $this->groupsController->index();
$this->assertEquals($expectedResponse, $response);
}

public function testCreateWithExistingGroup() {
$this->groupManager
->expects($this->once())
->method('groupExists')
->with('ExistingGroup')
->will($this->returnValue(true));

$expectedResponse = new DataResponse(
array(
'message' => 'Group already exists.'
),
Http::STATUS_CONFLICT
);
$response = $this->groupsController->create('ExistingGroup');
$this->assertEquals($expectedResponse, $response);
}

public function testCreateSuccessful() {
$group = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$this->groupManager
->expects($this->once())
->method('groupExists')
->with('NewGroup')
->will($this->returnValue(false));
$this->groupManager
->expects($this->once())
->method('createGroup')
->with('NewGroup')
->will($this->returnValue($group));
$group
->expects($this->once())
->method('getDisplayName')
->will($this->returnValue('NewGroup'));

$expectedResponse = new DataResponse(
array(
'groupname' => 'NewGroup'
),
Http::STATUS_CREATED
);
$response = $this->groupsController->create('NewGroup');
$this->assertEquals($expectedResponse, $response);
}

public function testCreateUnsuccessful() {
$this->groupManager
->expects($this->once())
->method('groupExists')
->with('NewGroup')
->will($this->returnValue(false));
$this->groupManager
->expects($this->once())
->method('createGroup')
->with('NewGroup')
->will($this->returnValue(false));

$expectedResponse = new DataResponse(
array(
'status' => 'error',
'data' => array('message' => 'Unable to add group.')
),
Http::STATUS_FORBIDDEN
);
$response = $this->groupsController->create('NewGroup');
$this->assertEquals($expectedResponse, $response);
}

public function testDestroySuccessful() {
$group = $this->getMockBuilder(Group::class)
->disableOriginalConstructor()->getMock();
$this->groupManager
->expects($this->once())
->method('get')
->with('ExistingGroup')
->will($this->returnValue($group));
$group
->expects($this->once())
->method('delete')
->will($this->returnValue(true));
$group
->method('getDisplayName')
->will($this->returnValue('ExistingGroup'));

$expectedResponse = new DataResponse(
array(
'status' => 'success',
'data' => array('groupname' => 'ExistingGroup')
),
Http::STATUS_NO_CONTENT
);
$response = $this->groupsController->destroy('ExistingGroup');
$this->assertEquals($expectedResponse, $response);
}

public function testDestroyUnsuccessful() {
$this->groupManager
->expects($this->once())
->method('get')
->with('ExistingGroup')
->will($this->returnValue(null));

$expectedResponse = new DataResponse(
array(
'status' => 'error',
'data' => array('message' => 'Unable to delete group.')
),
Http::STATUS_FORBIDDEN
);
$response = $this->groupsController->destroy('ExistingGroup');
$this->assertEquals($expectedResponse, $response);
}

}

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


+ 2
- 0
tests/acceptance/config/behat.yml View File

@@ -8,8 +8,10 @@ default:
- NextcloudTestServerContext

- AppNavigationContext
- AppSettingsContext
- CommentsAppContext
- ContactsMenuContext
- DialogContext
- FeatureContext
- FileListContext
- FilesAppContext

+ 56
- 8
tests/acceptance/features/bootstrap/AppNavigationContext.php View File

@@ -1,9 +1,10 @@
<?php

/**
*
*
* @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com)
*
* @copyright Copyright (c) 2018, John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
@@ -32,24 +33,43 @@ class AppNavigationContext implements Context, ActorAwareInterface {
*/
public static function appNavigation() {
return Locator::forThe()->id("app-navigation")->
describedAs("App navigation");
describedAs("App navigation");
}

/**
* @return Locator
*/
public static function appNavigationSectionItemFor($sectionText) {
return Locator::forThe()->xpath("//li[normalize-space() = '$sectionText']")->
descendantOf(self::appNavigation())->
describedAs($sectionText . " section item in App Navigation");
return Locator::forThe()->xpath("//li/a[normalize-space() = '$sectionText']/..")->
descendantOf(self::appNavigation())->
describedAs($sectionText . " section item in App Navigation");
}

/**
* @return Locator
*/
public static function appNavigationCurrentSectionItem() {
return Locator::forThe()->css(".active")->descendantOf(self::appNavigation())->
describedAs("Current section item in App Navigation");
return Locator::forThe()->css(".active")->
descendantOf(self::appNavigation())->
describedAs("Current section item in App Navigation");
}

/**
* @return Locator
*/
public static function buttonForTheSection($class, $section) {
return Locator::forThe()->css("." . $class)->
descendantOf(self::appNavigationSectionItemFor($section))->
describedAs("The $class button on the $section section in App Navigation");
}

/**
* @return Locator
*/
public static function counterForTheSection($section) {
return Locator::forThe()->css(".app-navigation-entry-utils-counter")->
descendantOf(self::appNavigationSectionItemFor($section))->
describedAs("The counter for the $section section in App Navigation");
}

/**
@@ -59,6 +79,13 @@ class AppNavigationContext implements Context, ActorAwareInterface {
$this->actor->find(self::appNavigationSectionItemFor($section), 10)->click();
}

/**
* @Given I click the :class button on the :section section
*/
public function iClickTheButtonInTheSection($class, $section) {
$this->actor->find(self::buttonForTheSection($class, $section), 10)->click();
}

/**
* @Then I see that the current section is :section
*/
@@ -66,4 +93,25 @@ class AppNavigationContext implements Context, ActorAwareInterface {
PHPUnit_Framework_Assert::assertEquals($this->actor->find(self::appNavigationCurrentSectionItem(), 10)->getText(), $section);
}

/**
* @Then I see that the section :section is shown
*/
public function iSeeThatTheSectionIsShown($section) {
WaitFor::elementToBeEventuallyShown($this->actor, self::appNavigationSectionItemFor($section));
}

/**
* @Then I see that the section :section is not shown
*/
public function iSeeThatTheSectionIsNotShown($section) {
WaitFor::elementToBeEventuallyNotShown($this->actor, self::appNavigationSectionItemFor($section));
}

/**
* @Then I see that the section :section has a count of :count
*/
public function iSeeThatTheSectionHasACountOf($section, $count) {
PHPUnit_Framework_Assert::assertEquals($this->actor->find(self::counterForTheSection($section), 10)->getText(), $count);
}

}

+ 102
- 0
tests/acceptance/features/bootstrap/AppSettingsContext.php View File

@@ -0,0 +1,102 @@
<?php

/**
*
* @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com)
* @copyright Copyright (c) 2018, John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

use Behat\Behat\Context\Context;

class AppSettingsContext implements Context, ActorAwareInterface {

use ActorAware;

/**
* @return Locator
*/
public static function appSettings() {
return Locator::forThe()->id("app-settings")->
describedAs("App settings");
}
/**
* @return Locator
*/
public static function appSettingsContent() {
return Locator::forThe()->id("app-settings-content")->
descendantOf(self::appSettings())->
describedAs("App settings");
}

/**
* @return Locator
*/
public static function appSettingsOpenButton() {
return Locator::forThe()->xpath("//div[@id = 'app-settings-header']/button")->
descendantOf(self::appSettings())->
describedAs("The button to open the app settings");
}

/**
* @return Locator
*/
public static function checkboxInTheSettings($id) {
return Locator::forThe()->xpath("//input[@id = '$id']")->
descendantOf(self::appSettingsContent())->
describedAs("The $id checkbox in the settings");
}

/**
* @return Locator
*/
public static function checkboxLabelInTheSettings($id) {
return Locator::forThe()->xpath("//label[@for = '$id']")->
descendantOf(self::appSettingsContent())->
describedAs("The label for the $id checkbox in the settings");
}

/**
* @Given I open the settings
*/
public function iOpenTheSettings() {
$this->actor->find(self::appSettingsOpenButton())->click();
}

/**
* @Given I toggle the :id checkbox in the settings
*/
public function iToggleTheCheckboxInTheSettingsTo($id) {
$locator = self::CheckboxInTheSettings($id);

// If locator is not visible, fallback to label
if (!$this->actor->find(self::CheckboxInTheSettings($id))->isVisible()) {
$locator = self::checkboxLabelInTheSettings($id);
}

$this->actor->find($locator)->click();
}

/**
* @Then I see that the settings are opened
*/
public function iSeeThatTheSettingsAreOpened() {
WaitFor::elementToBeEventuallyShown($this->actor, self::appSettingsContent());
}

}

+ 68
- 0
tests/acceptance/features/bootstrap/DialogContext.php View File

@@ -0,0 +1,68 @@
<?php

/**
*
* @copyright Copyright (c) 2018, John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* 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
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/

use Behat\Behat\Context\Context;

class DialogContext implements Context, ActorAwareInterface {

use ActorAware;

/**
* @return Locator
*/
public static function theDialog() {
return Locator::forThe()->css(".oc-dialog")->
describedAs("The dialog");
}

/**
* @return Locator
*/
public static function theDialogButton($text) {
return Locator::forThe()->xpath("//button[normalize-space() = '$text']")->
descendantOf(self::theDialog())->
describedAs($text . " button of the dialog");
}

/**
* @Given I click the :text button of the confirmation dialog
*/
public function iClickTheDialogButton($text) {
$this->actor->find(self::theDialogButton($text), 10)->click();
}

/**
* @Then I see that the confirmation dialog is shown
*/
public function iSeeThatTheConfirmationDialogIsShown() {
WaitFor::elementToBeEventuallyShown($this->actor, self::theDialog());
}

/**
* @Then I see that the confirmation dialog is not shown
*/
public function iSeeThatTheConfirmationDialogIsNotShown() {
WaitFor::elementToBeEventuallyNotShown($this->actor, self::theDialog());
}

}

+ 141
- 15
tests/acceptance/features/bootstrap/UsersSettingsContext.php View File

@@ -1,8 +1,9 @@
<?php

/**
*
*
* @copyright Copyright (c) 2017, Daniel Calviño Sánchez (danxuliu@gmail.com)
* @copyright Copyright (c) 2018, John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
*
* @license GNU AGPL version 3 or any later version
*
@@ -31,7 +32,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function newUserForm() {
return Locator::forThe()->id("newuserHeader")->
return Locator::forThe()->id("new-user")->
describedAs("New user form in Users Settings");
}

@@ -63,7 +64,7 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function createNewUserButton() {
return Locator::forThe()->xpath("//form[@id = 'newuser']//input[@type = 'submit']")->
return Locator::forThe()->xpath("//form[@id = 'new-user']//input[@type = 'submit']")->
describedAs("Create user button in Users Settings");
}

@@ -71,24 +72,72 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
* @return Locator
*/
public static function rowForUser($user) {
return Locator::forThe()->xpath("//table[@id = 'userlist']//td[normalize-space() = '$user']/..")->
return Locator::forThe()->xpath("//div[@id='app-content']/div/div[normalize-space() = '$user']/..")->
describedAs("Row for user $user in Users Settings");
}

/**
* Warning: you need to watch out for the proper classes order
*
* @return Locator
*/
public static function classCellForUser($class, $user) {
return Locator::forThe()->xpath("//*[@class='$class']")->
descendantOf(self::rowForUser($user))->
describedAs("$class cell for user $user in Users Settings");
}

/**
* @return Locator
*/
public static function inputForUserInCell($cell, $user) {
return Locator::forThe()->css("input")->
descendantOf(self::classCellForUser($cell, $user))->
describedAs("$cell input for user $user in Users Settings");
}

/**
* @return Locator
*/
public static function optionInInputForUser($cell, $user) {
return Locator::forThe()->css(".multiselect__option--highlight")->
descendantOf(self::classCellForUser($cell, $user))->
describedAs("Selected $cell option in $cell input for user $user in Users Settings");
}

/**
* @return Locator
*/
public static function actionsMenuOf($user) {
return Locator::forThe()->css(".icon-more")->
descendantOf(self::rowForUser($user))->
describedAs("Actions menu for user $user in Users Settings");
}

/**
* @return Locator
*/
public static function theAction($action, $user) {
return Locator::forThe()->xpath("//button[normalize-space() = '$action']")->
descendantOf(self::rowForUser($user))->
describedAs("$action action for the user $user row in Users Settings");
}

/**
* @return Locator
*/
public static function passwordCellForUser($user) {
return Locator::forThe()->css(".password")->descendantOf(self::rowForUser($user))->
describedAs("Password cell for user $user in Users Settings");
public static function theColumn($column) {
return Locator::forThe()->xpath("//div[@class='user-list-grid']//div[normalize-space() = '$column']")->
describedAs("The $column column in Users Settings");
}

/**
* @return Locator
*/
public static function passwordInputForUser($user) {
return Locator::forThe()->css("input")->descendantOf(self::passwordCellForUser($user))->
describedAs("Password input for user $user in Users Settings");
public static function selectedSelectOption($cell, $user) {
return Locator::forThe()->css(".multiselect__single")->
descendantOf(self::classCellForUser($cell, $user))->
describedAs("The selected option of the $cell select for the user $user in Users Settings");
}

/**
@@ -98,6 +147,20 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
$this->actor->find(self::newUserButton())->click();
}

/**
* @When I click the :action action in the :user actions menu
*/
public function iClickTheAction($action, $user) {
$this->actor->find(self::theAction($action, $user))->click();
}

/**
* @When I open the actions menu for the user :user
*/
public function iOpenTheActionsMenuOf($user) {
$this->actor->find(self::actionsMenuOf($user))->click();
}

/**
* @When I create user :user with password :password
*/
@@ -108,18 +171,40 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
}

/**
* @When I set the password for :user to :password
* @When I set the :field for :user to :value
*/
public function iSetThePasswordForUserTo($user, $password) {
$this->actor->find(self::passwordCellForUser($user), 10)->click();
$this->actor->find(self::passwordInputForUser($user), 2)->setValue($password . "\r");
public function iSetTheFieldForUserTo($field, $user, $value) {
$this->actor->find(self::inputForUserInCell($field, $user), 2)->setValue($value . "\r");
}

/**
* @When I assign the user :user to the group :group
*/
public function iAssignTheUserToTheGroup($user, $group) {
$this->actor->find(self::inputForUserInCell('groups', $user))->setValue($group);
$this->actor->find(self::optionInInputForUser('groups', $user))->click();
}

/**
* @When I set the user :user quota to :quota
*/
public function iSetTheUserQuotaTo($user, $quota) {
$this->actor->find(self::inputForUserInCell('quota', $user))->setValue($quota);
$this->actor->find(self::optionInInputForUser('quota', $user))->click();
}

/**
* @Then I see that the list of users contains the user :user
*/
public function iSeeThatTheListOfUsersContainsTheUser($user) {
PHPUnit_Framework_Assert::assertNotNull($this->actor->find(self::rowForUser($user), 10));
WaitFor::elementToBeEventuallyShown($this->actor, self::rowForUser($user));
}

/**
* @Then I see that the list of users does not contains the user :user
*/
public function iSeeThatTheListOfUsersDoesNotContainsTheUser($user) {
WaitFor::elementToBeEventuallyNotShown($this->actor, self::rowForUser($user));
}

/**
@@ -130,4 +215,45 @@ class UsersSettingsContext implements Context, ActorAwareInterface {
$this->actor->find(self::newUserForm(), 10)->isVisible());
}

/**
* @Then I see that the :action action in the :user actions menu is shown
*/
public function iSeeTheAction($action, $user) {
PHPUnit_Framework_Assert::assertTrue(
$this->actor->find(self::theAction($action, $user), 10)->isVisible());
}

/**
* @Then I see that the :column column is shown
*/
public function iSeeThatTheColumnIsShown($column) {
PHPUnit_Framework_Assert::assertTrue(
$this->actor->find(self::theColumn($column), 10)->isVisible());
}

/**
* @Then I see that the :field of :user is :value
*/
public function iSeeThatTheFieldOfUserIs($field, $user, $value) {
PHPUnit_Framework_Assert::assertEquals(
$this->actor->find(self::inputForUserInCell($field, $user), 10)->getValue(), $value);
}

/**
* @Then I see that the :cell cell for user :user is done loading
*/
public function iSeeThatTheCellForUserIsDoneLoading($cell, $user) {
WaitFor::elementToBeEventuallyShown($this->actor, self::classCellForUser($cell.' icon-loading-small', $user));
WaitFor::elementToBeEventuallyNotShown($this->actor, self::classCellForUser($cell.' icon-loading-small', $user));
}

/**
* @Then I see that the user quota of :user is :quota
*/
public function iSeeThatTheuserQuotaIs($user, $quota) {
PHPUnit_Framework_Assert::assertEquals(
$this->actor->find(self::selectedSelectOption('quota', $user), 2)->getText(), $quota);
}

}

+ 38
- 0
tests/acceptance/features/core/ElementWrapper.php View File

@@ -147,6 +147,18 @@ class ElementWrapper {
return $this->executeCommand($commandCallback, "visibility could not be got");
}

/**
* Returns whether the wrapped element is checked or not.
*
* @return bool true if the wrapped element is checked, false otherwise.
*/
public function isChecked() {
$commandCallback = function() {
return $this->element->isChecked();
};
return $this->executeCommand($commandCallback, "check state could not be got");
}

/**
* Returns the text of the wrapped element.
*
@@ -205,6 +217,32 @@ class ElementWrapper {
$this->executeCommandOnVisibleElement($commandCallback, "could not be clicked");
}

/**
* Check the wrapped element.
*
* If automatically waits for the wrapped element to be visible (up to the
* timeout set when finding it).
*/
public function check() {
$commandCallback = function() {
$this->element->check();
};
$this->executeCommand($commandCallback, "could not be checked");
}

/**
* uncheck the wrapped element.
*
* If automatically waits for the wrapped element to be visible (up to the
* timeout set when finding it).
*/
public function uncheck() {
$commandCallback = function() {
$this->element->uncheck();
};
$this->executeCommand($commandCallback, "could not be unchecked");
}

/**
* Executes the given command.
*

+ 0
- 1
tests/acceptance/features/login.feature View File

@@ -18,7 +18,6 @@ Feature: login
And I am logged in as the admin
And I open the User settings
And I set the password for user0 to 654321
And I see that the "Password successfully changed" notification is shown
And I act as John
And I log in with user user0 and password 654321
Then I see that the current page is the Files app

+ 115
- 0
tests/acceptance/features/users.feature View File

@@ -0,0 +1,115 @@
Feature: users

Scenario: create a new user
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I click the New user button
And I see that the new user form is shown
When I create user unknownUser with password 123456acb
Then I see that the list of users contains the user unknownUser

Scenario: delete a user
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
And I open the actions menu for the user user0
And I see that the "Delete user" action in the user0 actions menu is shown
When I click the "Delete user" action in the user0 actions menu
Then I see that the list of users does not contains the user user0

Scenario: disable a user
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
And I open the actions menu for the user user0
And I see that the "Disable user" action in the user0 actions menu is shown
When I click the "Disable user" action in the user0 actions menu
Then I see that the list of users does not contains the user user0
When I open the "Disabled users" section
Then I see that the list of users contains the user user0

Scenario: assign user to a group
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
# disabled because we need the TAB patch:
# https://github.com/minkphp/MinkSelenium2Driver/pull/244
# When I assign the user user0 to the group admin
# Then I see that the section Admins is shown
# And I see that the section Admins has a count of 2
Scenario: create and delete a group
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
# disabled because we need the TAB patch:
# https://github.com/minkphp/MinkSelenium2Driver/pull/244
# And I assign the user user0 to the group Group1
# And I see that the section Group1 is shown
# And I click the "icon-delete" button on the Group1 section
# And I see that the confirmation dialog is shown
# When I click the "Yes" button of the confirmation dialog
# Then I see that the section Group1 is not shown

Scenario: change columns visibility
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I open the settings
And I see that the settings are opened
When I toggle the showLanguages checkbox in the settings
Then I see that the "Languages" column is shown
When I toggle the showLastLogin checkbox in the settings
Then I see that the "Last login" column is shown
When I toggle the showStoragePath checkbox in the settings
Then I see that the "Storage location" column is shown
When I toggle the showUserBackend checkbox in the settings
Then I see that the "User backend" column is shown
Scenario: change display name
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
And I see that the displayName of user0 is user0
When I set the displayName for user0 to user1
And I see that the displayName cell for user user0 is done loading
Then I see that the displayName of user0 is user1

Scenario: change password
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
And I see that the password of user0 is ""
When I set the password for user0 to 123456
And I see that the password cell for user user0 is done loading
# password input is emptied on change
Then I see that the password of user0 is ""

Scenario: change email
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
And I see that the mailAddress of user0 is ""
When I set the mailAddress for user0 to "test@nextcloud.com"
And I see that the mailAddress cell for user user0 is done loading
Then I see that the mailAddress of user0 is "test@nextcloud.com"

Scenario: change user quota
Given I act as Jane
And I am logged in as the admin
And I open the User settings
And I see that the list of users contains the user user0
And I see that the user quota of user0 is Unlimited
# disabled because we need the TAB patch:
# https://github.com/minkphp/MinkSelenium2Driver/pull/244
# When I set the user user0 quota to 1GB
# And I see that the quota cell for user user0 is done loading
# Then I see that the user quota of user0 is "1 GB"

Loading…
Cancel
Save