You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

UserList.vue 9.9KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421
  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 accounts')">
  33. <template #icon>
  34. <NcLoadingIcon v-if="isInitialLoad && loading.users"
  35. :name="t('settings', 'Loading accounts …')"
  36. :size="64" />
  37. <NcIconSvgWrapper v-else :path="mdiAccountGroup" :size="64" />
  38. </template>
  39. </NcEmptyContent>
  40. <VirtualList v-else
  41. :data-component="UserRow"
  42. :data-sources="filteredUsers"
  43. data-key="id"
  44. data-cy-user-list
  45. :item-height="rowHeight"
  46. :style="style"
  47. :extra-props="{
  48. users,
  49. settings,
  50. hasObfuscated,
  51. groups,
  52. subAdminsGroups,
  53. quotaOptions,
  54. languages,
  55. externalActions,
  56. }"
  57. @scroll-end="handleScrollEnd">
  58. <template #before>
  59. <caption class="hidden-visually">
  60. {{ t('settings', 'List of accounts. This list is not fully rendered for performance reasons. The accounts will be rendered as you navigate through the list.') }}
  61. </caption>
  62. </template>
  63. <template #header>
  64. <UserListHeader :has-obfuscated="hasObfuscated" />
  65. </template>
  66. <template #footer>
  67. <UserListFooter :loading="loading.users"
  68. :filtered-users="filteredUsers" />
  69. </template>
  70. </VirtualList>
  71. </Fragment>
  72. </template>
  73. <script>
  74. import { mdiAccountGroup } from '@mdi/js'
  75. import { showError } from '@nextcloud/dialogs'
  76. import { subscribe, unsubscribe } from '@nextcloud/event-bus'
  77. import { Fragment } from 'vue-frag'
  78. import Vue from 'vue'
  79. import NcEmptyContent from '@nextcloud/vue/dist/Components/NcEmptyContent.js'
  80. import NcIconSvgWrapper from '@nextcloud/vue/dist/Components/NcIconSvgWrapper.js'
  81. import NcLoadingIcon from '@nextcloud/vue/dist/Components/NcLoadingIcon.js'
  82. import VirtualList from './Users/VirtualList.vue'
  83. import NewUserModal from './Users/NewUserModal.vue'
  84. import UserListFooter from './Users/UserListFooter.vue'
  85. import UserListHeader from './Users/UserListHeader.vue'
  86. import UserRow from './Users/UserRow.vue'
  87. import { defaultQuota, isObfuscated, unlimitedQuota } from '../utils/userUtils.ts'
  88. import logger from '../logger.ts'
  89. const newUser = Object.freeze({
  90. id: '',
  91. displayName: '',
  92. password: '',
  93. mailAddress: '',
  94. groups: [],
  95. manager: '',
  96. subAdminsGroups: [],
  97. quota: defaultQuota,
  98. language: {
  99. code: 'en',
  100. name: t('settings', 'Default language'),
  101. },
  102. })
  103. export default {
  104. name: 'UserList',
  105. components: {
  106. Fragment,
  107. NcEmptyContent,
  108. NcIconSvgWrapper,
  109. NcLoadingIcon,
  110. NewUserModal,
  111. UserListFooter,
  112. UserListHeader,
  113. VirtualList,
  114. },
  115. props: {
  116. selectedGroup: {
  117. type: String,
  118. default: null,
  119. },
  120. externalActions: {
  121. type: Array,
  122. default: () => [],
  123. },
  124. },
  125. setup() {
  126. // non reactive properties
  127. return {
  128. mdiAccountGroup,
  129. rowHeight: 55,
  130. UserRow,
  131. }
  132. },
  133. data() {
  134. return {
  135. loading: {
  136. all: false,
  137. groups: false,
  138. users: false,
  139. },
  140. newUser: { ...newUser },
  141. isInitialLoad: true,
  142. searchQuery: '',
  143. }
  144. },
  145. computed: {
  146. showConfig() {
  147. return this.$store.getters.getShowConfig
  148. },
  149. settings() {
  150. return this.$store.getters.getServerData
  151. },
  152. style() {
  153. return {
  154. '--row-height': `${this.rowHeight}px`,
  155. }
  156. },
  157. hasObfuscated() {
  158. return this.filteredUsers.some(user => isObfuscated(user))
  159. },
  160. users() {
  161. return this.$store.getters.getUsers
  162. },
  163. filteredUsers() {
  164. if (this.selectedGroup === 'disabled') {
  165. return this.users.filter(user => user.enabled === false)
  166. }
  167. if (!this.settings.isAdmin) {
  168. // we don't want subadmins to edit themselves
  169. return this.users.filter(user => user.enabled !== false)
  170. }
  171. return this.users.filter(user => user.enabled !== false)
  172. },
  173. groups() {
  174. // data provided php side + remove the disabled group
  175. return this.$store.getters.getGroups
  176. .filter(group => group.id !== 'disabled')
  177. .sort((a, b) => a.name.localeCompare(b.name))
  178. },
  179. subAdminsGroups() {
  180. // data provided php side
  181. return this.$store.getters.getSubadminGroups
  182. },
  183. quotaOptions() {
  184. // convert the preset array into objects
  185. const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({
  186. id: cur,
  187. label: cur,
  188. }), [])
  189. // add default presets
  190. if (this.settings.allowUnlimitedQuota) {
  191. quotaPreset.unshift(unlimitedQuota)
  192. }
  193. quotaPreset.unshift(defaultQuota)
  194. return quotaPreset
  195. },
  196. usersOffset() {
  197. return this.$store.getters.getUsersOffset
  198. },
  199. usersLimit() {
  200. return this.$store.getters.getUsersLimit
  201. },
  202. disabledUsersOffset() {
  203. return this.$store.getters.getDisabledUsersOffset
  204. },
  205. disabledUsersLimit() {
  206. return this.$store.getters.getDisabledUsersLimit
  207. },
  208. usersCount() {
  209. return this.users.length
  210. },
  211. /* LANGUAGES */
  212. languages() {
  213. return [
  214. {
  215. label: t('settings', 'Common languages'),
  216. languages: this.settings.languages.commonLanguages,
  217. },
  218. {
  219. label: t('settings', 'Other languages'),
  220. languages: this.settings.languages.otherLanguages,
  221. },
  222. ]
  223. },
  224. },
  225. watch: {
  226. // watch url change and group select
  227. async selectedGroup(val) {
  228. this.isInitialLoad = true
  229. // if selected is the disabled group but it's empty
  230. await this.redirectIfDisabled()
  231. this.$store.commit('resetUsers')
  232. await this.loadUsers()
  233. this.setNewUserDefaultGroup(val)
  234. },
  235. filteredUsers(filteredUsers) {
  236. logger.debug(`${filteredUsers.length} filtered user(s)`)
  237. },
  238. },
  239. async created() {
  240. await this.loadUsers()
  241. },
  242. async mounted() {
  243. if (!this.settings.canChangePassword) {
  244. OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled'))
  245. }
  246. /**
  247. * Reset and init new user form
  248. */
  249. this.resetForm()
  250. /**
  251. * Register search
  252. */
  253. subscribe('nextcloud:unified-search.search', this.search)
  254. subscribe('nextcloud:unified-search.reset', this.resetSearch)
  255. /**
  256. * If disabled group but empty, redirect
  257. */
  258. await this.redirectIfDisabled()
  259. },
  260. beforeDestroy() {
  261. unsubscribe('nextcloud:unified-search.search', this.search)
  262. unsubscribe('nextcloud:unified-search.reset', this.resetSearch)
  263. },
  264. methods: {
  265. async handleScrollEnd() {
  266. await this.loadUsers()
  267. },
  268. async loadUsers() {
  269. this.loading.users = true
  270. try {
  271. if (this.selectedGroup === 'disabled') {
  272. await this.$store.dispatch('getDisabledUsers', {
  273. offset: this.disabledUsersOffset,
  274. limit: this.disabledUsersLimit,
  275. })
  276. } else {
  277. await this.$store.dispatch('getUsers', {
  278. offset: this.usersOffset,
  279. limit: this.usersLimit,
  280. group: this.selectedGroup,
  281. search: this.searchQuery,
  282. })
  283. }
  284. logger.debug(`${this.users.length} total user(s) loaded`)
  285. } catch (error) {
  286. logger.error('Failed to load accounts', { error })
  287. showError('Failed to load accounts')
  288. }
  289. this.loading.users = false
  290. this.isInitialLoad = false
  291. },
  292. closeModal() {
  293. this.$store.commit('setShowConfig', {
  294. key: 'showNewUserForm',
  295. value: false,
  296. })
  297. },
  298. async search({ query }) {
  299. this.searchQuery = query
  300. this.$store.commit('resetUsers')
  301. await this.loadUsers()
  302. },
  303. resetSearch() {
  304. this.search({ query: '' })
  305. },
  306. resetForm() {
  307. // revert form to original state
  308. this.newUser = Object.assign({}, newUser)
  309. /**
  310. * Init default language from server data. The use of this.settings
  311. * requires a computed variable, which break the v-model binding of the form,
  312. * this is a much easier solution than getter and setter on a computed var
  313. */
  314. if (this.settings.defaultLanguage) {
  315. Vue.set(this.newUser.language, 'code', this.settings.defaultLanguage)
  316. }
  317. /**
  318. * In case the user directly loaded the user list within a group
  319. * the watch won't be triggered. We need to initialize it.
  320. */
  321. this.setNewUserDefaultGroup(this.selectedGroup)
  322. this.loading.all = false
  323. },
  324. setNewUserDefaultGroup(value) {
  325. if (value && value.length > 0) {
  326. // setting new account default group to the current selected one
  327. const currentGroup = this.groups.find(group => group.id === value)
  328. if (currentGroup) {
  329. this.newUser.groups = [currentGroup]
  330. return
  331. }
  332. }
  333. // fallback, empty selected group
  334. this.newUser.groups = []
  335. },
  336. /**
  337. * If the selected group is the disabled group but the count is 0
  338. * redirect to the all users page.
  339. * we only check for 0 because we don't have the count on ldap
  340. * and we therefore set the usercount to -1 in this specific case
  341. */
  342. async redirectIfDisabled() {
  343. const allGroups = this.$store.getters.getGroups
  344. if (this.selectedGroup === 'disabled'
  345. && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) {
  346. // disabled group is empty, redirection to all users
  347. this.$router.push({ name: 'users' })
  348. await this.loadUsers()
  349. }
  350. },
  351. },
  352. }
  353. </script>
  354. <style lang="scss" scoped>
  355. @import './Users/shared/styles.scss';
  356. .empty {
  357. :deep {
  358. .icon-vue {
  359. width: 64px;
  360. height: 64px;
  361. svg {
  362. max-width: 64px;
  363. max-height: 64px;
  364. }
  365. }
  366. }
  367. }
  368. </style>