UserList.vue 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470
  1. <!--
  2. - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
  3. -
  4. - @author John Molakvoæ <skjnldsv@protonmail.com>
  5. -
  6. - @license GNU AGPL version 3 or any later version
  7. -
  8. - This program is free software: you can redistribute it and/or modify
  9. - it under the terms of the GNU Affero General Public License as
  10. - published by the Free Software Foundation, either version 3 of the
  11. - License, or (at your option) any later version.
  12. -
  13. - This program is distributed in the hope that it will be useful,
  14. - but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. - GNU Affero General Public License for more details.
  17. -
  18. - You should have received a copy of the GNU Affero General Public License
  19. - along with this program. If not, see <http://www.gnu.org/licenses/>.
  20. -
  21. -->
  22. <template>
  23. <Fragment>
  24. <NewUserModal v-if="showConfig.showNewUserForm"
  25. :loading="loading"
  26. :new-user="newUser"
  27. :quota-options="quotaOptions"
  28. @reset="resetForm"
  29. @close="closeModal" />
  30. <NcEmptyContent v-if="filteredUsers.length === 0"
  31. class="empty"
  32. :name="isInitialLoad && loading.users ? null : t('settings', 'No users')">
  33. <template #icon>
  34. <NcLoadingIcon v-if="isInitialLoad && loading.users"
  35. :name="t('settings', 'Loading users …')"
  36. :size="64" />
  37. <NcIconSvgWrapper v-else
  38. :svg="usersSvg" />
  39. </template>
  40. </NcEmptyContent>
  41. <RecycleScroller v-else
  42. ref="scroller"
  43. class="user-list"
  44. :style="style"
  45. :items="filteredUsers"
  46. key-field="id"
  47. role="table"
  48. list-tag="tbody"
  49. list-class="user-list__body"
  50. item-tag="tr"
  51. item-class="user-list__row"
  52. :item-size="rowHeight"
  53. @hook:mounted="handleMounted"
  54. @scroll-end="handleScrollEnd">
  55. <template #before>
  56. <caption class="hidden-visually">
  57. {{ t('settings', 'List of users. This list is not fully rendered for performance reasons. The users will be rendered as you navigate through the list.') }}
  58. </caption>
  59. <UserListHeader :has-obfuscated="hasObfuscated" />
  60. </template>
  61. <template #default="{ item: user }">
  62. <UserRow :user="user"
  63. :users="users"
  64. :settings="settings"
  65. :has-obfuscated="hasObfuscated"
  66. :groups="groups"
  67. :sub-admins-groups="subAdminsGroups"
  68. :quota-options="quotaOptions"
  69. :languages="languages"
  70. :external-actions="externalActions" />
  71. </template>
  72. <template #after>
  73. <UserListFooter :loading="loading.users"
  74. :filtered-users="filteredUsers" />
  75. </template>
  76. </RecycleScroller>
  77. </Fragment>
  78. </template>
  79. <script>
  80. import Vue from 'vue'
  81. import { Fragment } from 'vue-frag'
  82. import { RecycleScroller } from 'vue-virtual-scroller'
  83. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  84. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
  85. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  86. import { subscribe, unsubscribe } from '@nextcloud/event-bus'
  87. import { showError } from '@nextcloud/dialogs'
  88. import NewUserModal from './Users/NewUserModal.vue'
  89. import UserListFooter from './Users/UserListFooter.vue'
  90. import UserListHeader from './Users/UserListHeader.vue'
  91. import UserRow from './Users/UserRow.vue'
  92. import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts'
  93. import logger from '../logger.js'
  94. import usersSvg from '../../img/users.svg?raw'
  95. const newUser = {
  96. id: '',
  97. displayName: '',
  98. password: '',
  99. mailAddress: '',
  100. groups: [],
  101. manager: '',
  102. subAdminsGroups: [],
  103. quota: defaultQuota,
  104. language: {
  105. code: 'en',
  106. name: t('settings', 'Default language'),
  107. },
  108. }
  109. export default {
  110. name: 'UserList',
  111. components: {
  112. Fragment,
  113. NcEmptyContent,
  114. NcIconSvgWrapper,
  115. NcLoadingIcon,
  116. NewUserModal,
  117. RecycleScroller,
  118. UserListFooter,
  119. UserListHeader,
  120. UserRow,
  121. },
  122. props: {
  123. selectedGroup: {
  124. type: String,
  125. default: null,
  126. },
  127. externalActions: {
  128. type: Array,
  129. default: () => [],
  130. },
  131. },
  132. data() {
  133. return {
  134. loading: {
  135. all: false,
  136. groups: false,
  137. users: false,
  138. },
  139. isInitialLoad: true,
  140. rowHeight: 55,
  141. usersSvg,
  142. searchQuery: '',
  143. newUser: Object.assign({}, newUser),
  144. }
  145. },
  146. computed: {
  147. showConfig() {
  148. return this.$store.getters.getShowConfig
  149. },
  150. settings() {
  151. return this.$store.getters.getServerData
  152. },
  153. style() {
  154. return {
  155. '--row-height': `${this.rowHeight}px`,
  156. }
  157. },
  158. hasObfuscated() {
  159. return this.filteredUsers.some(user => isObfuscated(user))
  160. },
  161. users() {
  162. return this.$store.getters.getUsers
  163. },
  164. filteredUsers() {
  165. if (this.selectedGroup === 'disabled') {
  166. return this.users.filter(user => user.enabled === false)
  167. }
  168. if (!this.settings.isAdmin) {
  169. // we don't want subadmins to edit themselves
  170. return this.users.filter(user => user.enabled !== false)
  171. }
  172. return this.users.filter(user => user.enabled !== false)
  173. },
  174. groups() {
  175. // data provided php side + remove the disabled group
  176. return this.$store.getters.getGroups
  177. .filter(group => group.id !== 'disabled')
  178. .sort((a, b) => a.name.localeCompare(b.name))
  179. },
  180. subAdminsGroups() {
  181. // data provided php side
  182. return this.$store.getters.getSubadminGroups
  183. },
  184. quotaOptions() {
  185. // convert the preset array into objects
  186. const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({
  187. id: cur,
  188. label: cur,
  189. }), [])
  190. // add default presets
  191. if (this.settings.allowUnlimitedQuota) {
  192. quotaPreset.unshift(unlimitedQuota)
  193. }
  194. quotaPreset.unshift(defaultQuota)
  195. return quotaPreset
  196. },
  197. usersOffset() {
  198. return this.$store.getters.getUsersOffset
  199. },
  200. usersLimit() {
  201. return this.$store.getters.getUsersLimit
  202. },
  203. usersCount() {
  204. return this.users.length
  205. },
  206. /* LANGUAGES */
  207. languages() {
  208. return [
  209. {
  210. label: t('settings', 'Common languages'),
  211. languages: this.settings.languages.commonLanguages,
  212. },
  213. {
  214. label: t('settings', 'Other languages'),
  215. languages: this.settings.languages.otherLanguages,
  216. },
  217. ]
  218. },
  219. },
  220. watch: {
  221. // watch url change and group select
  222. async selectedGroup(val, old) {
  223. this.isInitialLoad = true
  224. // if selected is the disabled group but it's empty
  225. await this.redirectIfDisabled()
  226. this.$store.commit('resetUsers')
  227. await this.loadUsers()
  228. this.setNewUserDefaultGroup(val)
  229. },
  230. filteredUsers(filteredUsers) {
  231. logger.debug(`${filteredUsers.length} filtered user(s)`)
  232. },
  233. },
  234. async created() {
  235. await this.loadUsers()
  236. },
  237. async mounted() {
  238. if (!this.settings.canChangePassword) {
  239. OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled'))
  240. }
  241. /**
  242. * Reset and init new user form
  243. */
  244. this.resetForm()
  245. /**
  246. * Register search
  247. */
  248. subscribe('nextcloud:unified-search.search', this.search)
  249. subscribe('nextcloud:unified-search.reset', this.resetSearch)
  250. /**
  251. * If disabled group but empty, redirect
  252. */
  253. await this.redirectIfDisabled()
  254. },
  255. beforeDestroy() {
  256. unsubscribe('nextcloud:unified-search.search', this.search)
  257. unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
  258. },
  259. methods: {
  260. async handleMounted() {
  261. // Add proper semantics to the recycle scroller slots
  262. const header = this.$refs.scroller.$refs.before
  263. const footer = this.$refs.scroller.$refs.after
  264. header.classList.add('user-list__header')
  265. header.setAttribute('role', 'rowgroup')
  266. footer.classList.add('user-list__footer')
  267. footer.setAttribute('role', 'rowgroup')
  268. },
  269. async handleScrollEnd() {
  270. await this.loadUsers()
  271. },
  272. async loadUsers() {
  273. this.loading.users = true
  274. try {
  275. await this.$store.dispatch('getUsers', {
  276. offset: this.usersOffset,
  277. limit: this.usersLimit,
  278. group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '',
  279. search: this.searchQuery,
  280. })
  281. logger.debug(`${this.users.length} total user(s) loaded`)
  282. } catch (error) {
  283. logger.error('Failed to load users', { error })
  284. showError('Failed to load users')
  285. }
  286. this.loading.users = false
  287. this.isInitialLoad = false
  288. },
  289. closeModal() {
  290. this.$store.commit('setShowConfig', {
  291. key: 'showNewUserForm',
  292. value: false,
  293. })
  294. },
  295. async search({ query }) {
  296. this.searchQuery = query
  297. this.$store.commit('resetUsers')
  298. await this.loadUsers()
  299. },
  300. resetSearch() {
  301. this.search({ query: '' })
  302. },
  303. resetForm() {
  304. // revert form to original state
  305. this.newUser = Object.assign({}, newUser)
  306. /**
  307. * Init default language from server data. The use of this.settings
  308. * requires a computed variable, which break the v-model binding of the form,
  309. * this is a much easier solution than getter and setter on a computed var
  310. */
  311. if (this.settings.defaultLanguage) {
  312. Vue.set(this.newUser.language, 'code', this.settings.defaultLanguage)
  313. }
  314. /**
  315. * In case the user directly loaded the user list within a group
  316. * the watch won't be triggered. We need to initialize it.
  317. */
  318. this.setNewUserDefaultGroup(this.selectedGroup)
  319. this.loading.all = false
  320. },
  321. setNewUserDefaultGroup(value) {
  322. if (value && value.length > 0) {
  323. // setting new user default group to the current selected one
  324. const currentGroup = this.groups.find(group => group.id === value)
  325. if (currentGroup) {
  326. this.newUser.groups = [currentGroup]
  327. return
  328. }
  329. }
  330. // fallback, empty selected group
  331. this.newUser.groups = []
  332. },
  333. /**
  334. * If the selected group is the disabled group but the count is 0
  335. * redirect to the all users page.
  336. * we only check for 0 because we don't have the count on ldap
  337. * and we therefore set the usercount to -1 in this specific case
  338. */
  339. async redirectIfDisabled() {
  340. const allGroups = this.$store.getters.getGroups
  341. if (this.selectedGroup === 'disabled'
  342. && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) {
  343. // disabled group is empty, redirection to all users
  344. this.$router.push({ name: 'users' })
  345. await this.loadUsers()
  346. }
  347. },
  348. },
  349. }
  350. </script>
  351. <style lang="scss" scoped>
  352. @import './Users/shared/styles.scss';
  353. .empty {
  354. :deep {
  355. .icon-vue {
  356. width: 64px;
  357. height: 64px;
  358. svg {
  359. max-width: 64px;
  360. max-height: 64px;
  361. }
  362. }
  363. }
  364. }
  365. .user-list {
  366. --avatar-cell-width: 48px;
  367. --cell-padding: 7px;
  368. --cell-width: 200px;
  369. --cell-min-width: calc(var(--cell-width) - (2 * var(--cell-padding)));
  370. display: block;
  371. overflow: auto;
  372. height: 100%;
  373. :deep {
  374. .user-list {
  375. &__body {
  376. display: flex;
  377. flex-direction: column;
  378. width: 100%;
  379. // Necessary for virtual scrolling absolute
  380. position: relative;
  381. margin-top: var(--row-height);
  382. }
  383. &__row {
  384. @include row;
  385. border-bottom: 1px solid var(--color-border);
  386. &:hover {
  387. background-color: var(--color-background-hover);
  388. .row__cell:not(.row__cell--actions) {
  389. background-color: var(--color-background-hover);
  390. }
  391. }
  392. }
  393. }
  394. .vue-recycle-scroller__slot {
  395. &.user-list__header,
  396. &.user-list__footer {
  397. position: sticky;
  398. }
  399. &.user-list__header {
  400. top: 0;
  401. z-index: 10;
  402. }
  403. &.user-list__footer {
  404. left: 0;
  405. }
  406. }
  407. }
  408. }
  409. </style>