aboutsummaryrefslogtreecommitdiffstats
path: root/settings/src
diff options
context:
space:
mode:
Diffstat (limited to 'settings/src')
-rw-r--r--settings/src/.jshintrc3
-rw-r--r--settings/src/App.vue16
-rw-r--r--settings/src/components/appNavigation.vue32
-rw-r--r--settings/src/components/appNavigation/navigationItem.vue117
-rw-r--r--settings/src/components/popoverMenu.vue18
-rw-r--r--settings/src/components/popoverMenu/popoverItem.vue28
-rw-r--r--settings/src/components/userList.vue298
-rw-r--r--settings/src/components/userList/userRow.vue449
-rw-r--r--settings/src/main.js22
-rw-r--r--settings/src/router.js36
-rw-r--r--settings/src/store/api.js99
-rw-r--r--settings/src/store/index.js28
-rw-r--r--settings/src/store/oc.js25
-rw-r--r--settings/src/store/settings.js18
-rw-r--r--settings/src/store/users.js420
-rw-r--r--settings/src/views/Users.vue301
16 files changed, 1910 insertions, 0 deletions
diff --git a/settings/src/.jshintrc b/settings/src/.jshintrc
new file mode 100644
index 00000000000..fc024bea970
--- /dev/null
+++ b/settings/src/.jshintrc
@@ -0,0 +1,3 @@
+{
+ "esversion": 6
+}
diff --git a/settings/src/App.vue b/settings/src/App.vue
new file mode 100644
index 00000000000..4e1708bed4f
--- /dev/null
+++ b/settings/src/App.vue
@@ -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>
diff --git a/settings/src/components/appNavigation.vue b/settings/src/components/appNavigation.vue
new file mode 100644
index 00000000000..02858b4bb5b
--- /dev/null
+++ b/settings/src/components/appNavigation.vue
@@ -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>
diff --git a/settings/src/components/appNavigation/navigationItem.vue b/settings/src/components/appNavigation/navigationItem.vue
new file mode 100644
index 00000000000..ee748ee826e
--- /dev/null
+++ b/settings/src/components/appNavigation/navigationItem.vue
@@ -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>
diff --git a/settings/src/components/popoverMenu.vue b/settings/src/components/popoverMenu.vue
new file mode 100644
index 00000000000..92f62c5090d
--- /dev/null
+++ b/settings/src/components/popoverMenu.vue
@@ -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>
diff --git a/settings/src/components/popoverMenu/popoverItem.vue b/settings/src/components/popoverMenu/popoverItem.vue
new file mode 100644
index 00000000000..710aff80aa6
--- /dev/null
+++ b/settings/src/components/popoverMenu/popoverItem.vue
@@ -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>
diff --git a/settings/src/components/userList.vue b/settings/src/components/userList.vue
new file mode 100644
index 00000000000..cb0a6944823
--- /dev/null
+++ b/settings/src/components/userList.vue
@@ -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>
diff --git a/settings/src/components/userList/userRow.vue b/settings/src/components/userList/userRow.vue
new file mode 100644
index 00000000000..da3f93ed2c8
--- /dev/null
+++ b/settings/src/components/userList/userRow.vue
@@ -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>
diff --git a/settings/src/main.js b/settings/src/main.js
new file mode 100644
index 00000000000..e09925a95de
--- /dev/null
+++ b/settings/src/main.js
@@ -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 }; \ No newline at end of file
diff --git a/settings/src/router.js b/settings/src/router.js
new file mode 100644
index 00000000000..7eedb359b69
--- /dev/null
+++ b/settings/src/router.js
@@ -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
+ }
+ ]
+ }
+ ]
+});
diff --git a/settings/src/store/api.js b/settings/src/store/api.js
new file mode 100644
index 00000000000..7501a7bb4cc
--- /dev/null
+++ b/settings/src/store/api.js
@@ -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));
+ }
+}; \ No newline at end of file
diff --git a/settings/src/store/index.js b/settings/src/store/index.js
new file mode 100644
index 00000000000..4332ece33e4
--- /dev/null
+++ b/settings/src/store/index.js
@@ -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
+});
diff --git a/settings/src/store/oc.js b/settings/src/store/oc.js
new file mode 100644
index 00000000000..4bb82075e8a
--- /dev/null
+++ b/settings/src/store/oc.js
@@ -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};
diff --git a/settings/src/store/settings.js b/settings/src/store/settings.js
new file mode 100644
index 00000000000..0ba827467a1
--- /dev/null
+++ b/settings/src/store/settings.js
@@ -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};
diff --git a/settings/src/store/users.js b/settings/src/store/users.js
new file mode 100644
index 00000000000..f349c7b4a06
--- /dev/null
+++ b/settings/src/store/users.js
@@ -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 }; \ No newline at end of file
diff --git a/settings/src/views/Users.vue b/settings/src/views/Users.vue
new file mode 100644
index 00000000000..c88487635a9
--- /dev/null
+++ b/settings/src/views/Users.vue
@@ -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>