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 | 108 | ||||
-rw-r--r-- | settings/src/components/popoverMenu.vue | 18 | ||||
-rw-r--r-- | settings/src/components/popoverMenu/popoverItem.vue | 23 | ||||
-rw-r--r-- | settings/src/components/userList.vue | 205 | ||||
-rw-r--r-- | settings/src/components/userList/userRow.vue | 370 | ||||
-rw-r--r-- | settings/src/main.js | 20 | ||||
-rw-r--r-- | settings/src/router.js | 23 | ||||
-rw-r--r-- | settings/src/store/api.js | 50 | ||||
-rw-r--r-- | settings/src/store/index.js | 24 | ||||
-rw-r--r-- | settings/src/store/settings.js | 18 | ||||
-rw-r--r-- | settings/src/store/users.js | 380 | ||||
-rw-r--r-- | settings/src/views/Users.vue | 152 |
15 files changed, 1442 insertions, 0 deletions
diff --git a/settings/src/.jshintrc b/settings/src/.jshintrc new file mode 100644 index 00000000000..e688987c23f --- /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..3b1cd53ca35 --- /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..256f71d18cc --- /dev/null +++ b/settings/src/components/appNavigation.vue @@ -0,0 +1,32 @@ +<template> + <div id="app-navigation"> + <div class="app-navigation-new" v-if="menu.new"> + <button type="button" :id="menu.new.id" :class="menu.new.icon" @click="menu.new.action">{{menu.new.text}}</button> + </div> + <ul :id="menu.id"> + <navigation-item v-for="(item, key) in menu.items" :item="item" :key="key" /> + </ul> + <div id="app-settings"> + <div id="app-settings-header"> + <button class="settings-button" + data-apps-slide-toggle="#app-settings-content" + >{{t('settings', 'Settings')}}</button> + </div> + <div id="app-settings-content"> + <slot name="settings-content"></slot> + </div> + </div> + </div> +</template> + +<script> +import navigationItem from './appNavigation/navigationItem'; + +export default { + name: 'appNavigation', + props: ['menu'], + components: { + navigationItem + } +} +</script> diff --git a/settings/src/components/appNavigation/navigationItem.vue b/settings/src/components/appNavigation/navigationItem.vue new file mode 100644 index 00000000000..19b0d90e3f4 --- /dev/null +++ b/settings/src/components/appNavigation/navigationItem.vue @@ -0,0 +1,108 @@ +<template> + <li :id="item.id" :class="[{'icon-loading-small': item.loading, 'open': item.opened, 'collapsible': item.collapsible&&item.children&&item.children.length>0 }, item.classes]"> + + <!-- Bullet --> + <div v-if="item.bullet" class="app-navigation-entry-bullet" :style="{ backgroundColor: item.bullet }"></div> + + <!-- Main link --> + <a :href="(item.href) ? item.href : '#' " @click="toggleCollapse" :class="item.icon" >{{item.text}}</a> + + <!-- Popover, counter and button(s) --> + <div v-if="item.utils" class="app-navigation-entry-utils"> + <ul> + <!-- counter --> + <li v-if="Number.isInteger(item.utils.counter)" + class="app-navigation-entry-utils-counter">{{item.utils.counter}}</li> + + <!-- first action if only one action and counter --> + <li v-if="item.utils.actions && item.utils.actions.length === 1 && Number.isInteger(item.utils.counter)" + class="app-navigation-entry-utils-menu-button"> + <button :class="item.utils.actions[0].icon"></button> + </li> + + <!-- second action only two actions and no counter --> + <li v-else-if="item.utils.actions && item.utils.actions.length === 2 && !Number.isInteger(item.utils.counter)" + v-for="action in item.utils.actions" :key="action.action" + class="app-navigation-entry-utils-menu-button"> + <button :class="action.icon"></button> + </li> + + <!-- menu if only at least one action and counter OR two actions and no counter--> + <li v-else-if="item.utils.actions && item.utils.actions.length > 1 && (Number.isInteger(item.utils.counter) || item.utils.actions.length > 2)" + class="app-navigation-entry-utils-menu-button"> + <button v-click-outside="hideMenu" @click="showMenu" ></button> + </li> + </ul> + </div> + + <!-- if more than 2 actions or more than 1 actions with counter --> + <div v-if="item.utils && item.utils.actions && item.utils.actions.length > 1 && (Number.isInteger(item.utils.counter) || item.utils.actions.length > 2)" + class="app-navigation-entry-menu" :class="{ 'open': openedMenu }"> + <popover-menu :menu="item.utils.actions"/> + </div> + + <!-- undo entry --> + <div class="app-navigation-entry-deleted" v-if="item.undo"> + <div class="app-navigation-entry-deleted-description">{{item.undo.text}}</div> + <button class="app-navigation-entry-deleted-button icon-history" :title="t('settings', 'Undo')"></button> + </div> + + <!-- edit entry --> + <div class="app-navigation-entry-edit" v-if="item.edit"> + <form> + <input type="text" v-model="item.text"> + <input type="submit" value="" class="icon-confirm"> + <input type="submit" value="" class="icon-close" @click.stop.prevent="cancelEdit"> + </form> + </div> + + <!-- if the item has children, inject the component with proper data --> + <ul v-if="item.children"> + <navigation-item v-for="(item, key) in item.children" :item="item" :key="key" /> + </ul> + </li> +</template> + +<script> +import popoverMenu from '../popoverMenu'; +import ClickOutside from 'vue-click-outside'; +import Vue from 'vue'; + +export default { + name: 'navigationItem', + props: ['item'], + components: { + popoverMenu + }, + directives: { + ClickOutside + }, + data () { + return { + openedMenu: false + } + }, + methods: { + showMenu () { + this.openedMenu = true; + }, + hideMenu () { + this.openedMenu = false; + }, + toggleCollapse () { + // if item.opened isn't set, Vue won't trigger view updates https://vuejs.org/v2/api/#Vue-set + // ternary is here to detect the undefined state of item.opened + Vue.set(this.item, 'opened', this.item.opened ? !this.item.opened : true); + }, + cancelEdit () { + // remove the editing class + if (Array.isArray(this.item.classes)) + this.item.classes = this.item.classes.filter(item => item !== 'editing'); + } + }, + mounted () { + // prevent click outside event with popupItem. + this.popupItem = this.$el; + }, +} +</script> 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..84907341327 --- /dev/null +++ b/settings/src/components/popoverMenu/popoverItem.vue @@ -0,0 +1,23 @@ +<template> + <li> + <a @click="dispatchToStore" v-if="item.href" :href="(item.href) ? item.href : '#' "> + <span :class="item.icon"></span> + <span>{{item.text}}</span> + </a> + <button @click="dispatchToStore(item.action)" v-else> + <span :class="item.icon"></span> + <span>{{item.text}}</span> + </button> + </li> +</template> + +<script> +export default { + props: ['item'], + methods: { + dispatchToStore () { + this.$store.dispatch(this.item.action, this.item.data); + } + } +} +</script> diff --git a/settings/src/components/userList.vue b/settings/src/components/userList.vue new file mode 100644 index 00000000000..08153a44770 --- /dev/null +++ b/settings/src/components/userList.vue @@ -0,0 +1,205 @@ +<template> + <div id="app-content" class="user-list-grid" v-on:scroll.passive="onScroll"> + <div class="row" id="grid-header" :class="{'sticky': scrolled && !showConfig.showNewUserForm}"> + <div id="headerAvatar" class="avatar"></div> + <div id="headerName" class="name">{{ t('settings', 'Username') }}</div> + <div id="headerDisplayName" class="displayName">{{ t('settings', 'Full name') }}</div> + <div id="headerPassword" class="password">{{ t('settings', 'Password') }}</div> + <div id="headerAddress" class="mailAddress">{{ t('settings', 'Email') }}</div> + <div id="headerGroups" class="groups">{{ t('settings', 'Groups') }}</div> + <div id="headerSubAdmins" class="subadmins" + v-if="subAdminsGroups.length>0">{{ t('settings', 'Group admin for') }}</div> + <div id="headerQuota" class="quota">{{ t('settings', 'Quota') }}</div> + <div class="headerStorageLocation storageLocation" + v-if="showConfig.showStoragePath">{{ t('settings', 'Storage location') }}</div> + <div class="headerUserBackend userBackend" + v-if="showConfig.showUserBackend">{{ t('settings', 'User backend') }}</div> + <div class="headerLastLogin lastLogin" + v-if="showConfig.showLastLogin">{{ t('settings', 'Last login') }}</div> + <div class="userActions"></div> + </div> + + <form class="row" id="new-user" v-show="showConfig.showNewUserForm" + v-on:submit.prevent="createUser" :disabled="loading" + :class="{'sticky': scrolled && showConfig.showNewUserForm}"> + <div :class="loading?'icon-loading-small':'icon-add'"></div> + <div class="name"> + <input id="newusername" type="text" required v-model="newUser.id" + :placeholder="t('settings', 'User name')" name="username" + autocomplete="off" autocapitalize="none" autocorrect="off" + pattern="[a-zA-Z0-9 _\.@\-']+"> + </div> + <div class="displayName"> + <input id="newdisplayname" type="text" v-model="newUser.displayName" + :placeholder="t('settings', 'Display name')" name="displayname" + autocomplete="off" autocapitalize="none" autocorrect="off"> + </div> + <div class="password"> + <input id="newuserpassword" type="password" v-model="newUser.password" + :required="newUser.mailAddress===''" + :placeholder="t('settings', 'Password')" name="password" + autocomplete="new-password" autocapitalize="none" autocorrect="off" + :minlength="minPasswordLength"> + </div> + <div class="mailAddress"> + <input id="newemail" type="email" v-model="newUser.mailAddress" + :required="newUser.password===''" + :placeholder="t('settings', 'Mail address')" name="email" + autocomplete="off" autocapitalize="none" autocorrect="off"> + </div> + <div class="groups"> + <multiselect :options="groups" v-model="newUser.groups" + :placeholder="t('settings', 'Add user in group')" + label="name" track-by="id" class="multiselect-vue" + :multiple="true" :close-on-select="false"> + <span slot="noResult">{{t('settings','No result')}}</span> + </multiselect> + </div> + <div class="subadmins" v-if="subAdminsGroups.length>0"> + <multiselect :options="subAdminsGroups" v-model="newUser.subAdminsGroups" + :placeholder="t('settings', 'Set user as admin for')" + label="name" track-by="id" class="multiselect-vue" + :multiple="true" :close-on-select="false"> + <span slot="noResult">{{t('settings','No result')}}</span> + </multiselect> + </div> + <div class="quota"> + <multiselect :options="quotaOptions" v-model="newUser.quota" + :placeholder="t('settings', 'Select user quota')" + label="label" track-by="id" class="multiselect-vue" + :allowEmpty="false" :taggable="true" + @tag="validateQuota" > + </multiselect> + </div> + <div class="storageLocation" v-if="showConfig.showStoragePath"></div> + <div class="userBackend" v-if="showConfig.showUserBackend"></div> + <div class="lastLogin" v-if="showConfig.showLastLogin"></div> + <div class="userActions"> + <input type="submit" id="newsubmit" class="button primary icon-checkmark-white has-tooltip" + value="" :title="t('settings', 'Add a new user')"> + <input type="reset" id="newreset" class="button icon-close has-tooltip" @click="resetForm" + value="" :title="t('settings', 'Cancel and reset the form')"> + </div> + </form> + + <user-row v-for="(user, key) in users" :user="user" :key="key" :settings="settings" :showConfig="showConfig" + :groups="groups" :subAdminsGroups="subAdminsGroups" :quotaOptions="quotaOptions" /> + <infinite-loading @infinite="infiniteHandler"> + <span slot="spinner"><div class="users-icon-loading"></div></span> + <span slot="no-more"><div class="users-list-end">— {{t('settings', 'no more results')}} —</div></span> + </infinite-loading> + </div> +</template> + +<script> +import userRow from './userList/userRow'; +import Multiselect from 'vue-multiselect'; +import InfiniteLoading from 'vue-infinite-loading'; + +export default { + name: 'userList', + props: ['users', 'showConfig'], + components: { + userRow, + Multiselect, + InfiniteLoading + }, + data() { + let unlimitedQuota = {id:'none', label:t('settings', 'Unlimited')}, + defaultQuota = {id:'default', label:t('settings', 'Default quota')}; + return { + unlimitedQuota: unlimitedQuota, + defaultQuota: defaultQuota, + loading: false, + scrolled: false, + newUser: { + id:'', + displayName:'', + password:'', + mailAddress:'', + groups: [], + subAdminsGroups: [], + quota: defaultQuota + } + }; + }, + mounted() { + if (!this.settings.canChangePassword) { + OC.Notification.showTemporary(t('settings','Password change is disabled because the master key is disabled')); + } + }, + computed: { + settings() { + return this.$store.getters.getServerData; + }, + groups() { + // data provided php side + remove the disabled group + return this.$store.getters.getGroups.filter(group => group.id !== '_disabled'); + }, + subAdminsGroups() { + // data provided php side + return this.$store.getters.getServerData.subadmingroups; + }, + quotaOptions() { + // convert the preset array into objects + let quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({id:cur, label:cur}), []); + // add default presets + quotaPreset.unshift(this.unlimitedQuota); + quotaPreset.unshift(this.defaultQuota); + return quotaPreset; + }, + minPasswordLength() { + return this.$store.getters.getPasswordPolicyMinLength; + }, + usersOffset() { + return this.$store.getters.getUsersOffset; + }, + usersLimit() { + return this.$store.getters.getUsersLimit; + }, + }, + methods: { + onScroll(event) { + this.scrolled = event.target.scrollTop>0; + }, + + /** + * Validate quota string to make sure it's a valid human file size + * + * @param {string} quota Quota in readable format '5 GB' + * @returns {Object} + */ + validateQuota(quota) { + // only used for new presets sent through @Tag + let validQuota = OC.Util.computerFileSize(quota); + if (validQuota !== null && validQuota > 0) { + // unify format output + quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota)); + return this.newUser.quota = {id: quota, label: quota}; + } + // Default is unlimited + return this.newUser.quota = this.quotaOptions[0]; + }, + + infiniteHandler($state) { + this.$store.dispatch('getUsers', {offset:this.usersOffset, limit:this.usersLimit}) + .then((response) => {response?$state.loaded():$state.complete()}); + }, + + resetForm () { + // revert form to original state + Object.assign(this.newUser, this.$options.data.call(this).newUser); + this.loading = false; + }, + createUser() { + this.loading = true; + this.$store.dispatch('addUser', { + userid: this.newUser.id, + password: this.newUser.password, + email: this.newUser.mailAddress, + groups: this.newUser.groups.map(group => group.id) + }).then(() =>this.resetForm()); + } + } +} +</script> diff --git a/settings/src/components/userList/userRow.vue b/settings/src/components/userList/userRow.vue new file mode 100644 index 00000000000..816b0a33fa3 --- /dev/null +++ b/settings/src/components/userList/userRow.vue @@ -0,0 +1,370 @@ +<template> + <div class="row"> + <div class="avatar"><img alt="" width="32" height="32" :src="generateAvatar(user.id, 32)" :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'"></div> + <div class="name">{{user.id}}</div> + <form class="displayName" :class="{'icon-loading-small': loading.displayName}" v-on:submit.prevent="updateDisplayName"> + <input :id="'displayName'+user.id+rand" type="text" + :disabled="loading.displayName||loading.all" + :value="user.displayname" ref="displayName" + autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" /> + <input type="submit" class="icon-confirm" value="" /> + </form> + <form class="password" v-if="settings.canChangePassword" :class="{'icon-loading-small': loading.password}" + v-on:submit.prevent="updatePassword"> + <input :id="'password'+user.id+rand" type="password" required + :disabled="loading.password||loading.all" :minlength="minPasswordLength" + value="" :placeholder="t('settings', 'New password')" ref="password" + autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" /> + <input type="submit" class="icon-confirm" value="" /> + </form> + <div v-else></div> + <form class="mailAddress" :class="{'icon-loading-small': loading.mailAddress}" v-on:submit.prevent="updateEmail"> + <input :id="'mailAddress'+user.id+rand" type="email" + :disabled="loading.mailAddress||loading.all" + :value="user.email" ref="mailAddress" + autocomplete="new-password" autocorrect="off" autocapitalize="off" spellcheck="false" /> + <input type="submit" class="icon-confirm" value="" /> + </form> + <div class="groups" :class="{'icon-loading-small': loading.groups}"> + <multiselect :value="userGroups" :options="groups" :disabled="loading.groups||loading.all" + tag-placeholder="create" :placeholder="t('settings', 'Add user in group')" + label="name" track-by="id" class="multiselect-vue" + :limit="2" :limitText="limitGroups" + :multiple="true" :taggable="true" :closeOnSelect="false" + @tag="createGroup" @select="addUserGroup" @remove="removeUserGroup"> + </multiselect> + </div> + <div class="subadmins" v-if="subAdminsGroups.length>0" :class="{'icon-loading-small': loading.subadmins}"> + <multiselect :value="userSubAdminsGroups" :options="subAdminsGroups" :disabled="loading.subadmins||loading.all" + :placeholder="t('settings', 'Set user as admin for')" + label="name" track-by="id" class="multiselect-vue" + :limit="2" :limitText="limitGroups" + :multiple="true" :closeOnSelect="false" + @select="addUserSubAdmin" @remove="removeUserSubAdmin"> + <span slot="noResult">{{t('settings','No result')}}</span> + </multiselect> + </div> + <div class="quota" :class="{'icon-loading-small': loading.quota}"> + <multiselect :value="userQuota" :options="quotaOptions" :disabled="loading.quota||loading.all" + tag-placeholder="create" :placeholder="t('settings', 'Select user quota')" + label="label" track-by="id" class="multiselect-vue" + :allowEmpty="false" :taggable="true" + @tag="validateQuota" @input="setUserQuota"> + </multiselect> + <progress class="quota-user-progress" :class="{'warn':usedQuota>80}" :value="usedQuota" max="100"></progress> + </div> + <div class="storageLocation" v-if="showConfig.showStoragePath">{{user.storageLocation}}</div> + <div class="userBackend" v-if="showConfig.showUserBackend">{{user.backend}}</div> + <div class="lastLogin" v-if="showConfig.showLastLogin" :title="user.lastLogin>0 ? OC.Util.formatDate(user.lastLogin) : ''"> + {{user.lastLogin>0 ? OC.Util.relativeModifiedDate(user.lastLogin) : t('settings','Never')}} + </div> + <div class="userActions"> + <div class="toggleUserActions" v-if="OC.currentUser !== user.id && user.id !== 'admin'"> + <div class="icon-more" v-click-outside="hideMenu" @click="showMenu"></div> + <div class="popovermenu" :class="{ 'open': openedMenu }"> + <popover-menu :menu="userActions" /> + </div> + </div> + </div> + </div> +</template> + +<script> +import popoverMenu from '../popoverMenu'; +import ClickOutside from 'vue-click-outside'; +import Multiselect from 'vue-multiselect'; +//import Multiselect from '../../../node_modules/vue-multiselect/src/index'; + +export default { + name: 'userRow', + props: ['user', 'settings', 'groups', 'subAdminsGroups', 'quotaOptions', 'showConfig'], + components: { + popoverMenu, + Multiselect + }, + directives: { + ClickOutside + }, + mounted() { + // prevent click outside event with popupItem. + this.popupItem = this.$el; + }, + data() { + return { + rand: parseInt(Math.random() * 1000), + openedMenu: false, + loading: { + all: false, + displayName: false, + password: false, + mailAddress: false, + groups: false, + subadmins: false, + quota: false + } + } + }, + computed: { + /* USER POPOVERMENU ACTIONS */ + userActions() { + return [{ + icon: 'icon-delete', + text: t('settings','Delete user'), + action: 'deleteUser', + data: this.user.id + },{ + 'icon': this.user.enabled ? 'icon-close' : 'icon-add', + 'text': this.user.enabled ? t('settings','Disable user') : t('settings','Enable user'), + 'action': 'enableDisableUser', + data: {userid: this.user.id, enabled: !this.user.enabled} + }] + }, + + /* GROUPS MANAGEMENT */ + userGroups() { + let userGroups = this.groups.filter(group => this.user.groups.includes(group.id)); + return userGroups; + }, + userSubAdminsGroups() { + let userSubAdminsGroups = this.subAdminsGroups.filter(group => this.user.subadmin.includes(group.id)); + return userSubAdminsGroups; + }, + + /* QUOTA MANAGEMENT */ + usedQuota() { + let quota = this.user.quota.quota; + if (quota > 0) { + quota = Math.min(100, Math.round(this.user.quota.used / quota * 100)); + } else { + var usedInGB = this.user.quota.used / (10 * Math.pow(2, 30)); + //asymptotic curve approaching 50% at 10GB to visualize used stace with infinite quota + quota = 95 * (1 - (1 / (usedInGB + 1))); + } + return isNaN(quota) ? 0 : quota; + }, + // Mapping saved values to objects + userQuota() { + if (this.user.quota.quota > 0) { + // if value is valid, let's map the quotaOptions or return custom quota + let humanQuota = OC.Util.humanFileSize(this.user.quota.quota); + let userQuota = this.quotaOptions.find(quota => quota.id === humanQuota); + return userQuota ? userQuota : {id:humanQuota, label:humanQuota}; + } else if (this.user.quota.quota === 0 || this.user.quota.quota === 'default') { + // default quota is replaced by the proper value on load + return this.quotaOptions[0]; + } + return this.quotaOptions[1]; // unlimited + }, + + /* PASSWORD POLICY? */ + minPasswordLength() { + return this.$store.getters.getPasswordPolicyMinLength; + } + }, + methods: { + /* MENU HANDLING */ + showMenu () { + this.openedMenu = true; + }, + hideMenu () { + this.openedMenu = false; + }, + + /** + * Generate avatar url + * + * @param {string} user The user name + * @param {int} size Size integer, default 32 + * @returns {string} + */ + generateAvatar(user, size=32) { + return OC.generateUrl( + '/avatar/{user}/{size}?v={version}', + { + user: user, + size: size, + version: oc_userconfig.avatar.version + } + ); + }, + + + /** + * Format the limit text in the selected options + * + * @param {int} count elements left + * @returns {string} + */ + limitGroups(count) { + return '+'+count; + }, + + /** + * Set user displayName + * + * @param {string} displayName The display name + * @returns {Promise} + */ + updateDisplayName() { + let displayName = this.$refs.displayName.value; + this.loading.displayName = true; + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'displayname', + value: displayName + }).then(() => { + this.loading.displayName = false; + this.$refs.displayName.value = displayName; + }); + }, + + /** + * Set user password + * + * @param {string} password The email adress + * @returns {Promise} + */ + updatePassword() { + let password = this.$refs.password.value; + this.loading.password = true; + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'password', + value: password + }).then(() => { + this.loading.password = false; + this.$refs.password.value = ''; // empty & show placeholder + }); + }, + + /** + * Set user mailAddress + * + * @param {string} mailAddress The email adress + * @returns {Promise} + */ + updateEmail() { + let mailAddress = this.$refs.mailAddress.value; + this.loading.mailAddress = true; + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'email', + value: mailAddress + }).then(() => { + this.loading.mailAddress = false; + this.$refs.mailAddress.value = mailAddress; + }); + }, + + /** + * Create a new group + * + * @param {string} groups Group id + * @returns {Promise} + */ + createGroup(gid) { + this.loading = {groups:true, subadmins:true} + this.$store.dispatch('addGroup', gid).then(() => { + this.loading = {groups:false, subadmins:false}; + let userid = this.user.id; + this.$store.dispatch('addUserGroup', {userid, gid}); + }); + return this.$store.getters.getGroups[this.groups.length]; + }, + + /** + * Add user to group + * + * @param {object} group Group object + * @returns {Promise} + */ + addUserGroup(group) { + this.loading.groups = true; + let userid = this.user.id; + let gid = group.id; + return this.$store.dispatch('addUserGroup', {userid, gid}) + .then(() => this.loading.groups = false); + }, + + /** + * Remove user from group + * + * @param {object} group Group object + * @returns {Promise} + */ + removeUserGroup(group) { + this.loading.groups = true; + let userid = this.user.id; + let gid = group.id; + return this.$store.dispatch('removeUserGroup', {userid, gid}) + .then(() => this.loading.groups = false); + }, + + /** + * Add user to group + * + * @param {object} group Group object + * @returns {Promise} + */ + addUserSubAdmin(group) { + this.loading.subadmins = true; + let userid = this.user.id; + let gid = group.id; + return this.$store.dispatch('addUserSubAdmin', {userid, gid}) + .then(() => this.loading.subadmins = false); + }, + + /** + * Remove user from group + * + * @param {object} group Group object + * @returns {Promise} + */ + removeUserSubAdmin(group) { + this.loading.subadmins = true; + let userid = this.user.id; + let gid = group.id; + return this.$store.dispatch('removeUserSubAdmin', {userid, gid}) + .then(() => this.loading.subadmins = false); + }, + + + /** + * Validate quota string to make sure it's a valid human file size + * + * @param {string|Object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'} + * @returns {string} + */ + setUserQuota(quota = 'none') { + this.loading.quota = true; + // ensure we only send the preset id + quota = quota.id ? quota.id : quota; + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'quota', + value: quota + }).then(() => this.loading.quota = false); + return quota; + }, + + /** + * Validate quota string to make sure it's a valid human file size + * + * @param {string} quota Quota in readable format '5 GB' + * @returns {Promise|boolean} + */ + validateQuota(quota) { + // only used for new presets sent through @Tag + let validQuota = OC.Util.computerFileSize(quota); + if (validQuota === 0) { + return this.setUserQuota('none'); + } else if (validQuota !== null) { + // unify format output + return this.setUserQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota))); + } + // if no valid doo not change + return false; + } + } +} +</script> diff --git a/settings/src/main.js b/settings/src/main.js new file mode 100644 index 00000000000..a3ca283f584 --- /dev/null +++ b/settings/src/main.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import { sync } from 'vuex-router-sync'; +import App from './App.vue'; +import router from './router'; +import store from './store'; + +sync(store, router); + +// bind to window +Vue.prototype.t = t; +Vue.prototype.OC = OC; +Vue.prototype.oc_userconfig = oc_userconfig; + +const app = new Vue({ + router, + store, + render: h => h(App) +}).$mount('#content'); + +export { app, router, store };
\ No newline at end of file diff --git a/settings/src/router.js b/settings/src/router.js new file mode 100644 index 00000000000..ffbefda1016 --- /dev/null +++ b/settings/src/router.js @@ -0,0 +1,23 @@ +import Vue from 'vue'; +import Router from 'vue-router'; +import Users from './views/Users'; + +Vue.use(Router); + +/* + * This is the list of routes where the vuejs app will + * take over php to provide data + * You need to forward the php routing (routes.php) to + * /settings/main.php, where the vue-router will ensure + * the proper route. + * ⚠️ Routes needs to match the php routes. + */ + +export default new Router({ + mode: 'history', + base: window.location.pathName, + routes: [{ + path: '/settings/users', + component: Users + }] +});
\ No newline at end of file diff --git a/settings/src/store/api.js b/settings/src/store/api.js new file mode 100644 index 00000000000..d67c77a5ff3 --- /dev/null +++ b/settings/src/store/api.js @@ -0,0 +1,50 @@ +import axios from 'axios'; + +const requestToken = document.getElementsByTagName('head')[0].getAttribute('data-requesttoken'); +const tokenHeaders = { headers: { requesttoken: requestToken } }; + +const sanitize = function(url) { + return url.replace(/\/$/, ''); // Remove last slash of url +} + +export default { + requireAdmin() { + return new Promise(function(resolve, reject) { + setTimeout(reject, 5000); // automatically reject 5s if not ok + function waitForpassword() { + if (OC.PasswordConfirmation.requiresPasswordConfirmation()) { + setTimeout(waitForpassword, 500); + return; + } + resolve(); + } + waitForpassword(); + OC.PasswordConfirmation.requirePasswordConfirmation(); + }).catch((error) => console.log('Required password not entered')); + }, + get(url) { + return axios.get(sanitize(url), tokenHeaders) + .then((response) => Promise.resolve(response)) + .catch((error) => Promise.reject(error)); + }, + post(url, data) { + return axios.post(sanitize(url), data, tokenHeaders) + .then((response) => Promise.resolve(response)) + .catch((error) => Promise.reject(error)); + }, + patch(url, data) { + return axios.patch(sanitize(url), { data: data, headers: tokenHeaders.headers }) + .then((response) => Promise.resolve(response)) + .catch((error) => Promise.reject(error)); + }, + put(url, data) { + return axios.put(sanitize(url), data, tokenHeaders) + .then((response) => Promise.resolve(response)) + .catch((error) => Promise.reject(error)); + }, + delete(url, data) { + return axios.delete(sanitize(url), { data: data, headers: tokenHeaders.headers }) + .then((response) => Promise.resolve(response)) + .catch((error) => Promise.reject(error)); + } +};
\ 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..045b097d03f --- /dev/null +++ b/settings/src/store/index.js @@ -0,0 +1,24 @@ +import Vue from 'vue' +import Vuex from 'vuex' +import users from './users' +import settings from './settings' + +Vue.use(Vuex) + +const debug = process.env.NODE_ENV !== 'production' + +const mutations = { + API_FAILURE(state, error) { + console.log(state, error); + } +} + +export default new Vuex.Store({ + modules: { + users, + settings + }, + strict: debug, + + mutations +}) diff --git a/settings/src/store/settings.js b/settings/src/store/settings.js new file mode 100644 index 00000000000..43c13357a08 --- /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..b992fdb10b1 --- /dev/null +++ b/settings/src/store/users.js @@ -0,0 +1,380 @@ +import api from './api'; + +const orderGroups = function(groups, orderBy) { + /* const SORT_USERCOUNT = 1; + * const SORT_GROUPNAME = 2; + * https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34 + */ + if (orderBy === 1) { + return groups.sort((a, b) => a.usercount < b.usercount); + } else { + return groups.sort((a, b) => a.name.localeCompare(b.name)); + } +} + +const state = { + users: [], + groups: [], + orderBy: 1, + minPasswordLength: 0, + usersOffset: 0, + usersLimit: 25, +}; + +const mutations = { + appendUsers(state, usersObj) { + // convert obj to array + let users = state.users.concat(Object.keys(usersObj).map(userid => usersObj[userid])); + state.usersOffset += state.usersLimit; + state.users = users; + }, + setPasswordPolicyMinLength(state, length) { + state.minPasswordLength = length!=='' ? length : 0; + }, + initGroups(state, {groups, orderBy}) { + state.groups = groups; + state.orderBy = orderBy; + state.groups = orderGroups(state.groups, state.orderBy); + }, + addGroup(state, groupid) { + try { + state.groups.push({ + id: groupid, + name: groupid, + usercount: 0 // user will be added after the creation + }); + state.groups = orderGroups(state.groups, state.orderBy); + } catch (e) { + console.log('Can\'t create group', e); + } + }, + addUserGroup(state, { userid, gid }) { + // this should not be needed as it would means the user contains a group + // the server database doesn't have. + let group = state.groups.find(groupSearch => groupSearch.id == gid); + if (group) { + group.usercount++; // increase count + } + let groups = state.users.find(user => user.id == userid).groups; + groups.push(gid); + state.groups = orderGroups(state.groups, state.orderBy); + }, + removeUserGroup(state, { userid, gid }) { + // this should not be needed as it would means the user contains a group + // the server database doesn't have. + let group = state.groups.find(groupSearch => groupSearch.id == gid); + if (group) { + group.usercount--; // lower count + } + let groups = state.users.find(user => user.id == userid).groups; + groups.splice(groups.indexOf(gid),1); + state.groups = orderGroups(state.groups, state.orderBy); + }, + addUserSubAdmin(state, { userid, gid }) { + let groups = state.users.find(user => user.id == userid).subadmin; + groups.push(gid); + }, + removeUserSubAdmin(state, { userid, gid }) { + let groups = state.users.find(user => user.id == userid).subadmin; + groups.splice(groups.indexOf(gid),1); + }, + deleteUser(state, userid) { + let userIndex = state.users.findIndex(user => user.id == userid); + state.users.splice(userIndex, 1); + }, + addUserData(state, response) { + state.users.push(response.data.ocs.data); + }, + enableDisableUser(state, { userid, enabled }) { + state.users.find(user => user.id == userid).enabled = enabled; + state.groups.find(group => group.id == '_disabled').usercount += enabled ? -1 : 1; + }, + setUserData(state, { userid, key, value }) { + if (key === 'quota') { + let humanValue = OC.Util.computerFileSize(value); + state.users.find(user => user.id == userid)[key][key] = humanValue?humanValue:value; + } else { + state.users.find(user => user.id == userid)[key] = value; + } + }, +}; + +const getters = { + getUsers(state) { + return state.users; + }, + getGroups(state) { + return state.groups; + }, + getPasswordPolicyMinLength(state) { + return state.minPasswordLength; + }, + getUsersOffset(state) { + return state.usersOffset; + }, + getUsersLimit(state) { + return state.usersLimit; + } +}; + +const actions = { + /** + * Get all users with full details + * + * @param {Object} context + * @param {Object} options + * @param {int} options.offset List offset to request + * @param {int} options.limit List number to return from offset + * @returns {Promise} + */ + getUsers(context, { offset, limit, search }) { + search = typeof search === 'string' ? search : ''; + return api.get(OC.linkToOCS(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2)) + .then((response) => { + if (Object.keys(response.data.ocs.data.users).length > 0) { + context.commit('appendUsers', response.data.ocs.data.users); + return true; + } + return false; + }) + .catch((error) => context.commit('API_FAILURE', error)); + }, + + /** + * Get all users with full details + * + * @param {Object} context + * @param {Object} options + * @param {int} options.offset List offset to request + * @param {int} options.limit List number to return from offset + * @returns {Promise} + */ + getUsersFromList(context, { offset, limit, search }) { + search = typeof search === 'string' ? search : ''; + return api.get(OC.linkToOCS(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2)) + .then((response) => { + if (Object.keys(response.data.ocs.data.users).length > 0) { + context.commit('appendUsers', response.data.ocs.data.users); + return true; + } + return false; + }) + .catch((error) => context.commit('API_FAILURE', error)); + }, + + /** + * Get all users with full details from a groupid + * + * @param {Object} context + * @param {Object} options + * @param {int} options.offset List offset to request + * @param {int} options.limit List number to return from offset + * @returns {Promise} + */ + getUsersFromGroup(context, { groupid, offset, limit }) { + return api.get(OC.linkToOCS(`cloud/users/${groupid}/details?offset=${offset}&limit=${limit}`, 2)) + .then((response) => context.commit('getUsersFromList', response.data.ocs.data.users)) + .catch((error) => context.commit('API_FAILURE', error)); + }, + + + getPasswordPolicyMinLength(context) { + return api.get(OC.linkToOCS('apps/provisioning_api/api/v1/config/apps/password_policy/minLength', 2)) + .then((response) => context.commit('setPasswordPolicyMinLength', response.data.ocs.data.data)) + .catch((error) => context.commit('API_FAILURE', error)); + }, + + /** + * Add group + * + * @param {Object} context + * @param {string} gid Group id + * @returns {Promise} + */ + addGroup(context, gid) { + return api.requireAdmin().then((response) => { + return api.post(OC.linkToOCS(`cloud/groups`, 2), {groupid: gid}) + .then((response) => context.commit('addGroup', gid)) + .catch((error) => context.commit('API_FAILURE', error)); + }); + }, + + /** + * Add group + * + * @param {Object} context + * @param {string} gid Group id + * @returns {Promise} + */ + removeGroup(context, gid) { + return api.requireAdmin().then((response) => { + return api.post(OC.linkToOCS(`cloud/groups`, 2), {groupid: gid}) + .then((response) => context.commit('removeGroup', gid)) + .catch((error) => context.commit('API_FAILURE', error)); + }); + }, + + /** + * Add user to group + * + * @param {Object} context + * @param {Object} options + * @param {string} options.userid User id + * @param {string} options.gid Group id + * @returns {Promise} + */ + addUserGroup(context, { userid, gid }) { + return api.requireAdmin().then((response) => { + return api.post(OC.linkToOCS(`cloud/users/${userid}/groups`, 2), { groupid: gid }) + .then((response) => context.commit('addUserGroup', { userid, gid })) + .catch((error) => context.commit('API_FAILURE', error)); + }); + }, + + /** + * Remove user from group + * + * @param {Object} context + * @param {Object} options + * @param {string} options.userid User id + * @param {string} options.gid Group id + * @returns {Promise} + */ + removeUserGroup(context, { userid, gid }) { + return api.requireAdmin().then((response) => { + return api.delete(OC.linkToOCS(`cloud/users/${userid}/groups`, 2), { groupid: gid }) + .then((response) => context.commit('removeUserGroup', { userid, gid })) + .catch((error) => context.commit('API_FAILURE', { userid, error })); + }); + }, + + /** + * Add user to group admin + * + * @param {Object} context + * @param {Object} options + * @param {string} options.userid User id + * @param {string} options.gid Group id + * @returns {Promise} + */ + addUserSubAdmin(context, { userid, gid }) { + return api.requireAdmin().then((response) => { + return api.post(OC.linkToOCS(`cloud/users/${userid}/subadmins`, 2), { groupid: gid }) + .then((response) => context.commit('addUserSubAdmin', { userid, gid })) + .catch((error) => context.commit('API_FAILURE', error)); + }); + }, + + /** + * Remove user from group admin + * + * @param {Object} context + * @param {Object} options + * @param {string} options.userid User id + * @param {string} options.gid Group id + * @returns {Promise} + */ + removeUserSubAdmin(context, { userid, gid }) { + return api.requireAdmin().then((response) => { + return api.delete(OC.linkToOCS(`cloud/users/${userid}/subadmins`, 2), { groupid: gid }) + .then((response) => context.commit('removeUserSubAdmin', { userid, gid })) + .catch((error) => context.commit('API_FAILURE', { userid, error })); + }); + }, + + /** + * Delete a user + * + * @param {Object} context + * @param {string} userid User id + * @returns {Promise} + */ + deleteUser(context, userid) { + return api.requireAdmin().then((response) => { + return api.delete(OC.linkToOCS(`cloud/users/${userid}`, 2)) + .then((response) => context.commit('deleteUser', userid)) + .catch((error) => context.commit('API_FAILURE', { userid, error })); + }); + }, + + /** + * Add a user + * + * @param {Object} context + * @param {Object} options + * @param {string} options.userid User id + * @param {string} options.password User password + * @param {string} options.email User email + * @returns {Promise} + */ + addUser({context, dispatch}, {userid, password, email, groups}) { + return api.requireAdmin().then((response) => { + return api.post(OC.linkToOCS(`cloud/users`, 2), {userid, password, email, groups}) + .then((response) => dispatch('addUserData', userid)) + .catch((error) => context.commit('API_FAILURE', { userid, error })); + }); + }, + + /** + * Get user data and commit addition + * + * @param {Object} context + * @param {string} userid User id + * @returns {Promise} + */ + addUserData(context, userid) { + return api.requireAdmin().then((response) => { + return api.get(OC.linkToOCS(`cloud/users/${userid}`, 2)) + .then((response) => context.commit('addUserData', response)) + .catch((error) => context.commit('API_FAILURE', { userid, error })); + }); + }, + + /** Enable or disable user + * + * @param {Object} context + * @param {Object} options + * @param {string} options.userid User id + * @param {boolean} options.enabled User enablement status + * @returns {Promise} + */ + enableDisableUser(context, { userid, enabled = true }) { + let userStatus = enabled ? 'enable' : 'disable'; + return api.requireAdmin().then((response) => { + return api.put(OC.linkToOCS(`cloud/users/${userid}/${userStatus}`, 2)) + .then((response) => context.commit('enableDisableUser', { userid, enabled })) + .catch((error) => context.commit('API_FAILURE', { userid, error })); + }); + }, + + /** + * Edit user data + * + * @param {Object} context + * @param {Object} options + * @param {string} options.userid User id + * @param {string} options.key User field to edit + * @param {string} options.value Value of the change + * @returns {Promise} + */ + setUserData(context, { userid, key, value }) { + if (['email', 'quota', 'displayname', 'password'].indexOf(key) !== -1) { + // We allow empty email or displayname + if (typeof value === 'string' && + ( + (['quota', 'password'].indexOf(key) !== -1 && value.length > 0) || + ['email', 'displayname'].indexOf(key) !== -1 + ) + ) { + return api.requireAdmin().then((response) => { + return api.put(OC.linkToOCS(`cloud/users/${userid}`, 2), { key: key, value: value }) + .then((response) => context.commit('setUserData', { userid, key, value })) + .catch((error) => context.commit('API_FAILURE', { userid, error })); + }); + } + } + return Promise.reject(new Error('Invalid request data')); + } +}; + +export default { state, mutations, getters, actions };
\ 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..59b72a92fb7 --- /dev/null +++ b/settings/src/views/Users.vue @@ -0,0 +1,152 @@ +<template> + <div id="app"> + <app-navigation :menu="menu"> + <template slot="settings-content"> + <div> + <input type="checkbox" id="showLastLogin" class="checkbox" + :checked="showLastLogin" v-model="showLastLogin"> + <label for="showLastLogin">{{t('settings', 'Show last login')}}</label> + </div> + <div> + <input type="checkbox" id="showUserBackend" class="checkbox" + :checked="showUserBackend" v-model="showUserBackend"> + <label for="showUserBackend">{{t('settings', 'Show user backend')}}</label> + </div> + <div> + <input type="checkbox" id="showStoragePath" class="checkbox" + :checked="showStoragePath" v-model="showStoragePath"> + <label for="showStoragePath">{{t('settings', 'Show storage path')}}</label> + </div> + </template> + </app-navigation> + <user-list :users="users" :showConfig="showConfig" /> + </div> +</template> + +<script> +import appNavigation from '../components/appNavigation'; +import userList from '../components/userList'; +import Vue from 'vue'; +import VueLocalStorage from 'vue-localstorage' +Vue.use(VueLocalStorage) + +export default { + name: 'Users', + components: { + appNavigation, + userList + }, + beforeMount() { + this.$store.commit('initGroups', { + groups: this.$store.getters.getServerData.groups, + orderBy: this.$store.getters.getServerData.sortGroups + }); + this.$store.dispatch('getPasswordPolicyMinLength'); + }, + data() { + return { + showConfig: { + showStoragePath: false, + showUserBackend: false, + showLastLogin: false, + showNewUserForm: false + } + } + }, + methods: { + getLocalstorage(key) { + // force initialization + this.showConfig[key] = this.$localStorage.get(key) === 'true'; + return this.showConfig[key]; + }, + setLocalStorage(key, status) { + this.showConfig[key] = status; + this.$localStorage.set(key, status); + return status; + } + }, + computed: { + users() { + return this.$store.getters.getUsers; + }, + loading() { + return Object.keys(this.users).length === 0; + }, + usersOffset() { + return this.$store.getters.getUsersOffset; + }, + usersLimit() { + return this.$store.getters.getUsersLimit; + }, + showLastLogin: { + get: function() {return this.getLocalstorage('showLastLogin')}, + set: function(status) { + this.setLocalStorage('showLastLogin', status); + } + }, + showUserBackend: { + get: function() {return this.getLocalstorage('showUserBackend')}, + set: function(status) { + this.setLocalStorage('showUserBackend', status); + } + }, + showStoragePath: { + get: function() {return this.getLocalstorage('showStoragePath')}, + set: function(status) { + this.setLocalStorage('showStoragePath', status); + } + }, + menu() { + let self = this; + // Data provided php side + let groups = this.$store.getters.getGroups; + groups = Array.isArray(groups) ? groups : []; + + // Map groups + groups = groups.map(group => { + let item = {}; + item.id = group.id.replace(' ', '_'); + item.classes = []; + item.href = '#group'+group.id.replace(' ', '_'); + item.text = group.name; + item.utils = {counter: group.usercount}; + return item; + }); + + // Adjust data + if (groups[0].id === 'admin') { + groups[0].text = t('settings', 'Admins');} // rename admin group + if (groups[1].id === '_disabled') { + groups[1].text = t('settings', 'Disabled users'); // rename disabled group + if (groups[1].utils.counter === 0) { + groups.splice(1, 1); // remove disabled if empty + } + } + + // Add everyone group + groups.unshift({ + id: '_everyone', + classes: ['active'], + href:'#group_everyone', + text: t('settings', 'Everyone'), + utils: {counter: this.users.length} + }); + + // Return + return { + id: 'usergrouplist', + new: { + id:'new-user-button', + text: t('settings','New user'), + icon: 'icon-add', + action: function(){self.showConfig.showNewUserForm=!self.showConfig.showNewUserForm} + }, + items: groups + } + } + } +} +</script> + +<style lang="scss"> +</style> |