diff options
Diffstat (limited to 'settings/src')
-rw-r--r-- | settings/src/.jshintrc | 3 | ||||
-rw-r--r-- | settings/src/App.vue | 16 | ||||
-rw-r--r-- | settings/src/components/appNavigation.vue | 32 | ||||
-rw-r--r-- | settings/src/components/appNavigation/navigationItem.vue | 117 | ||||
-rw-r--r-- | settings/src/components/popoverMenu.vue | 18 | ||||
-rw-r--r-- | settings/src/components/popoverMenu/popoverItem.vue | 28 | ||||
-rw-r--r-- | settings/src/components/userList.vue | 298 | ||||
-rw-r--r-- | settings/src/components/userList/userRow.vue | 449 | ||||
-rw-r--r-- | settings/src/main.js | 22 | ||||
-rw-r--r-- | settings/src/router.js | 36 | ||||
-rw-r--r-- | settings/src/store/api.js | 99 | ||||
-rw-r--r-- | settings/src/store/index.js | 28 | ||||
-rw-r--r-- | settings/src/store/oc.js | 25 | ||||
-rw-r--r-- | settings/src/store/settings.js | 18 | ||||
-rw-r--r-- | settings/src/store/users.js | 420 | ||||
-rw-r--r-- | settings/src/views/Users.vue | 301 |
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> |