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.

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582
  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. <div id="app-content" class="user-list-grid" @scroll.passive="onScroll">
  24. <form v-show="showConfig.showNewUserForm"
  25. id="new-user"
  26. :class="{'sticky': scrolled && showConfig.showNewUserForm}"
  27. :disabled="loading.all"
  28. class="row"
  29. @submit.prevent="createUser">
  30. <div :class="loading.all?'icon-loading-small':'icon-add'" />
  31. <div class="name">
  32. <input id="newusername"
  33. ref="newusername"
  34. v-model="newUser.id"
  35. :disabled="settings.newUserGenerateUserID"
  36. :placeholder="settings.newUserGenerateUserID
  37. ? t('settings', 'Will be autogenerated')
  38. : t('settings', 'Username')"
  39. autocapitalize="none"
  40. autocomplete="off"
  41. autocorrect="off"
  42. name="username"
  43. pattern="[a-zA-Z0-9 _\.@\-']+"
  44. required
  45. type="text">
  46. <div class="displayName">
  47. <input id="newdisplayname"
  48. v-model="newUser.displayName"
  49. :placeholder="t('settings', 'Display name')"
  50. autocapitalize="none"
  51. autocomplete="off"
  52. autocorrect="off"
  53. name="displayname"
  54. type="text">
  55. </div>
  56. </div>
  57. <div class="password">
  58. <input id="newuserpassword"
  59. ref="newuserpassword"
  60. v-model="newUser.password"
  61. :minlength="minPasswordLength"
  62. :placeholder="t('settings', 'Password')"
  63. :required="newUser.mailAddress===''"
  64. autocapitalize="none"
  65. autocomplete="new-password"
  66. autocorrect="off"
  67. name="password"
  68. type="password">
  69. </div>
  70. <div class="mailAddress">
  71. <input id="newemail"
  72. v-model="newUser.mailAddress"
  73. :placeholder="t('settings', 'Email')"
  74. :required="newUser.password==='' || settings.newUserRequireEmail"
  75. autocapitalize="none"
  76. autocomplete="off"
  77. autocorrect="off"
  78. name="email"
  79. type="email">
  80. </div>
  81. <div class="groups">
  82. <!-- hidden input trick for vanilla html5 form validation -->
  83. <input v-if="!settings.isAdmin"
  84. id="newgroups"
  85. :class="{'icon-loading-small': loading.groups}"
  86. :required="!settings.isAdmin"
  87. :value="newUser.groups"
  88. tabindex="-1"
  89. type="text">
  90. <Multiselect v-model="newUser.groups"
  91. :close-on-select="false"
  92. :disabled="loading.groups||loading.all"
  93. :multiple="true"
  94. :options="canAddGroups"
  95. :placeholder="t('settings', 'Add user in group')"
  96. :tag-width="60"
  97. :taggable="true"
  98. class="multiselect-vue"
  99. label="name"
  100. tag-placeholder="create"
  101. track-by="id"
  102. @tag="createGroup">
  103. <!-- If user is not admin, he is a subadmin.
  104. Subadmins can't create users outside their groups
  105. Therefore, empty select is forbidden -->
  106. <span slot="noResult">{{ t('settings', 'No results') }}</span>
  107. </Multiselect>
  108. </div>
  109. <div v-if="subAdminsGroups.length>0 && settings.isAdmin"
  110. class="subadmins">
  111. <Multiselect v-model="newUser.subAdminsGroups"
  112. :close-on-select="false"
  113. :multiple="true"
  114. :options="subAdminsGroups"
  115. :placeholder="t('settings', 'Set user as admin for')"
  116. :tag-width="60"
  117. class="multiselect-vue"
  118. label="name"
  119. track-by="id">
  120. <span slot="noResult">{{ t('settings', 'No results') }}</span>
  121. </Multiselect>
  122. </div>
  123. <div class="quota">
  124. <Multiselect v-model="newUser.quota"
  125. :allow-empty="false"
  126. :options="quotaOptions"
  127. :placeholder="t('settings', 'Select user quota')"
  128. :taggable="true"
  129. class="multiselect-vue"
  130. label="label"
  131. track-by="id"
  132. @tag="validateQuota" />
  133. </div>
  134. <div v-if="showConfig.showLanguages" class="languages">
  135. <Multiselect v-model="newUser.language"
  136. :allow-empty="false"
  137. :options="languages"
  138. :placeholder="t('settings', 'Default language')"
  139. class="multiselect-vue"
  140. group-label="label"
  141. group-values="languages"
  142. label="name"
  143. track-by="code" />
  144. </div>
  145. <div v-if="showConfig.showStoragePath" class="storageLocation" />
  146. <div v-if="showConfig.showUserBackend" class="userBackend" />
  147. <div v-if="showConfig.showLastLogin" class="lastLogin" />
  148. <div class="userActions">
  149. <input id="newsubmit"
  150. :title="t('settings', 'Add a new user')"
  151. class="button primary icon-checkmark-white has-tooltip"
  152. type="submit"
  153. value="">
  154. <div class="closeButton">
  155. <Actions>
  156. <ActionButton icon="icon-close" @click="onClose">
  157. {{ t('settings', 'Close') }}
  158. </ActionButton>
  159. </Actions>
  160. </div>
  161. </div>
  162. </form>
  163. <div id="grid-header"
  164. :class="{'sticky': scrolled && !showConfig.showNewUserForm}"
  165. class="row">
  166. <div id="headerAvatar" class="avatar" />
  167. <div id="headerName" class="name">
  168. {{ t('settings', 'Username') }}
  169. <div class="subtitle">
  170. {{ t('settings', 'Display name') }}
  171. </div>
  172. </div>
  173. <div id="headerPassword" class="password">
  174. {{ t('settings', 'Password') }}
  175. </div>
  176. <div id="headerAddress" class="mailAddress">
  177. {{ t('settings', 'Email') }}
  178. </div>
  179. <div id="headerGroups" class="groups">
  180. {{ t('settings', 'Groups') }}
  181. </div>
  182. <div v-if="subAdminsGroups.length>0 && settings.isAdmin"
  183. id="headerSubAdmins"
  184. class="subadmins">
  185. {{ t('settings', 'Group admin for') }}
  186. </div>
  187. <div id="headerQuota" class="quota">
  188. {{ t('settings', 'Quota') }}
  189. </div>
  190. <div v-if="showConfig.showLanguages"
  191. id="headerLanguages"
  192. class="languages">
  193. {{ t('settings', 'Language') }}
  194. </div>
  195. <div v-if="showConfig.showUserBackend || showConfig.showStoragePath"
  196. class="headerUserBackend userBackend">
  197. <div v-if="showConfig.showUserBackend" class="userBackend">
  198. {{ t('settings', 'User backend') }}
  199. </div>
  200. <div v-if="showConfig.showStoragePath"
  201. class="subtitle storageLocation">
  202. {{ t('settings', 'Storage location') }}
  203. </div>
  204. </div>
  205. <div v-if="showConfig.showLastLogin"
  206. class="headerLastLogin lastLogin">
  207. {{ t('settings', 'Last login') }}
  208. </div>
  209. <div class="userActions" />
  210. </div>
  211. <user-row v-for="(user, key) in filteredUsers"
  212. :key="key"
  213. :external-actions="externalActions"
  214. :groups="groups"
  215. :languages="languages"
  216. :quota-options="quotaOptions"
  217. :settings="settings"
  218. :show-config="showConfig"
  219. :sub-admins-groups="subAdminsGroups"
  220. :user="user" />
  221. <InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler">
  222. <div slot="spinner">
  223. <div class="users-icon-loading icon-loading" />
  224. </div>
  225. <div slot="no-more">
  226. <div class="users-list-end" />
  227. </div>
  228. <div slot="no-results">
  229. <div id="emptycontent">
  230. <div class="icon-contacts-dark" />
  231. <h2>{{ t('settings', 'No users in here') }}</h2>
  232. </div>
  233. </div>
  234. </InfiniteLoading>
  235. </div>
  236. </template>
  237. <script>
  238. import userRow from './UserList/UserRow'
  239. import { Multiselect, Actions, ActionButton } from '@nextcloud/vue'
  240. import InfiniteLoading from 'vue-infinite-loading'
  241. import Vue from 'vue'
  242. const unlimitedQuota = {
  243. id: 'none',
  244. label: t('settings', 'Unlimited'),
  245. }
  246. const defaultQuota = {
  247. id: 'default',
  248. label: t('settings', 'Default quota'),
  249. }
  250. const newUser = {
  251. id: '',
  252. displayName: '',
  253. password: '',
  254. mailAddress: '',
  255. groups: [],
  256. subAdminsGroups: [],
  257. quota: defaultQuota,
  258. language: {
  259. code: 'en',
  260. name: t('settings', 'Default language'),
  261. },
  262. }
  263. export default {
  264. name: 'UserList',
  265. components: {
  266. userRow,
  267. Multiselect,
  268. InfiniteLoading,
  269. Actions,
  270. ActionButton,
  271. },
  272. props: {
  273. users: {
  274. type: Array,
  275. default: () => [],
  276. },
  277. showConfig: {
  278. type: Object,
  279. required: true,
  280. },
  281. selectedGroup: {
  282. type: String,
  283. default: null,
  284. },
  285. externalActions: {
  286. type: Array,
  287. default: () => [],
  288. },
  289. },
  290. data() {
  291. return {
  292. unlimitedQuota,
  293. defaultQuota,
  294. loading: {
  295. all: false,
  296. groups: false,
  297. },
  298. scrolled: false,
  299. searchQuery: '',
  300. newUser: Object.assign({}, newUser),
  301. }
  302. },
  303. computed: {
  304. settings() {
  305. return this.$store.getters.getServerData
  306. },
  307. filteredUsers() {
  308. if (this.selectedGroup === 'disabled') {
  309. return this.users.filter(user => user.enabled === false)
  310. }
  311. if (!this.settings.isAdmin) {
  312. // we don't want subadmins to edit themselves
  313. return this.users.filter(user => user.enabled !== false)
  314. }
  315. return this.users.filter(user => user.enabled !== false)
  316. },
  317. groups() {
  318. // data provided php side + remove the disabled group
  319. return this.$store.getters.getGroups
  320. .filter(group => group.id !== 'disabled')
  321. .sort((a, b) => a.name.localeCompare(b.name))
  322. },
  323. canAddGroups() {
  324. // disabled if no permission to add new users to group
  325. return this.groups.map(group => {
  326. // clone object because we don't want
  327. // to edit the original groups
  328. group = Object.assign({}, group)
  329. group.$isDisabled = group.canAdd === false
  330. return group
  331. })
  332. },
  333. subAdminsGroups() {
  334. // data provided php side
  335. return this.$store.getters.getSubadminGroups
  336. },
  337. quotaOptions() {
  338. // convert the preset array into objects
  339. const quotaPreset = this.settings.quotaPreset.reduce((acc, cur) => acc.concat({
  340. id: cur,
  341. label: cur,
  342. }), [])
  343. // add default presets
  344. quotaPreset.unshift(this.unlimitedQuota)
  345. quotaPreset.unshift(this.defaultQuota)
  346. return quotaPreset
  347. },
  348. minPasswordLength() {
  349. return this.$store.getters.getPasswordPolicyMinLength
  350. },
  351. usersOffset() {
  352. return this.$store.getters.getUsersOffset
  353. },
  354. usersLimit() {
  355. return this.$store.getters.getUsersLimit
  356. },
  357. usersCount() {
  358. return this.users.length
  359. },
  360. /* LANGUAGES */
  361. languages() {
  362. return [
  363. {
  364. label: t('settings', 'Common languages'),
  365. languages: this.settings.languages.commonlanguages,
  366. },
  367. {
  368. label: t('settings', 'All languages'),
  369. languages: this.settings.languages.languages,
  370. },
  371. ]
  372. },
  373. },
  374. watch: {
  375. // watch url change and group select
  376. selectedGroup: function(val, old) {
  377. // if selected is the disabled group but it's empty
  378. this.redirectIfDisabled()
  379. this.$store.commit('resetUsers')
  380. this.$refs.infiniteLoading.stateChanger.reset()
  381. this.setNewUserDefaultGroup(val)
  382. },
  383. // make sure the infiniteLoading state is changed if we manually
  384. // add/remove data from the store
  385. usersCount: function(val, old) {
  386. // deleting the last user, reset the list
  387. if (val === 0 && old === 1) {
  388. this.$refs.infiniteLoading.stateChanger.reset()
  389. // adding the first user, warn the infiniteLoader that
  390. // the list is not empty anymore (we don't fetch the newly
  391. // added user as we already have all the info we need)
  392. } else if (val === 1 && old === 0) {
  393. this.$refs.infiniteLoading.stateChanger.loaded()
  394. }
  395. },
  396. },
  397. mounted() {
  398. if (!this.settings.canChangePassword) {
  399. OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled'))
  400. }
  401. /**
  402. * Reset and init new user form
  403. */
  404. this.resetForm()
  405. /**
  406. * Register search
  407. */
  408. this.userSearch = new OCA.Search(this.search, this.resetSearch)
  409. /**
  410. * If disabled group but empty, redirect
  411. */
  412. this.redirectIfDisabled()
  413. },
  414. methods: {
  415. onScroll(event) {
  416. this.scrolled = event.target.scrollTo > 0
  417. },
  418. /**
  419. * Validate quota string to make sure it's a valid human file size
  420. *
  421. * @param {string} quota Quota in readable format '5 GB'
  422. * @returns {Object}
  423. */
  424. validateQuota(quota) {
  425. // only used for new presets sent through @Tag
  426. const validQuota = OC.Util.computerFileSize(quota)
  427. if (validQuota !== null && validQuota >= 0) {
  428. // unify format output
  429. quota = OC.Util.humanFileSize(OC.Util.computerFileSize(quota))
  430. this.newUser.quota = { id: quota, label: quota }
  431. return this.newUser.quota
  432. }
  433. // Default is unlimited
  434. this.newUser.quota = this.quotaOptions[0]
  435. return this.quotaOptions[0]
  436. },
  437. infiniteHandler($state) {
  438. this.$store.dispatch('getUsers', {
  439. offset: this.usersOffset,
  440. limit: this.usersLimit,
  441. group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '',
  442. search: this.searchQuery,
  443. })
  444. .then((response) => {
  445. response ? $state.loaded() : $state.complete()
  446. })
  447. },
  448. /* SEARCH */
  449. search(query) {
  450. this.searchQuery = query
  451. this.$store.commit('resetUsers')
  452. this.$refs.infiniteLoading.stateChanger.reset()
  453. },
  454. resetSearch() {
  455. this.search('')
  456. },
  457. resetForm() {
  458. // revert form to original state
  459. this.newUser = Object.assign({}, newUser)
  460. /**
  461. * Init default language from server data. The use of this.settings
  462. * requires a computed variable, which break the v-model binding of the form,
  463. * this is a much easier solution than getter and setter on a computed var
  464. */
  465. if (this.settings.defaultLanguage) {
  466. Vue.set(this.newUser.language, 'code', this.settings.defaultLanguage)
  467. }
  468. /**
  469. * In case the user directly loaded the user list within a group
  470. * the watch won't be triggered. We need to initialize it.
  471. */
  472. this.setNewUserDefaultGroup(this.selectedGroup)
  473. this.loading.all = false
  474. },
  475. createUser() {
  476. this.loading.all = true
  477. this.$store.dispatch('addUser', {
  478. userid: this.newUser.id,
  479. password: this.newUser.password,
  480. displayName: this.newUser.displayName,
  481. email: this.newUser.mailAddress,
  482. groups: this.newUser.groups.map(group => group.id),
  483. subadmin: this.newUser.subAdminsGroups.map(group => group.id),
  484. quota: this.newUser.quota.id,
  485. language: this.newUser.language.code,
  486. })
  487. .then(() => {
  488. this.resetForm()
  489. this.$refs.newusername.focus()
  490. })
  491. .catch((error) => {
  492. this.loading.all = false
  493. if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) {
  494. const statuscode = error.response.data.ocs.meta.statuscode
  495. if (statuscode === 102) {
  496. // wrong username
  497. this.$refs.newusername.focus()
  498. } else if (statuscode === 107) {
  499. // wrong password
  500. this.$refs.newuserpassword.focus()
  501. }
  502. }
  503. })
  504. },
  505. setNewUserDefaultGroup(value) {
  506. if (value && value.length > 0) {
  507. // setting new user default group to the current selected one
  508. const currentGroup = this.groups.find(group => group.id === value)
  509. if (currentGroup) {
  510. this.newUser.groups = [currentGroup]
  511. return
  512. }
  513. }
  514. // fallback, empty selected group
  515. this.newUser.groups = []
  516. },
  517. /**
  518. * Create a new group
  519. *
  520. * @param {string} gid Group id
  521. * @returns {Promise}
  522. */
  523. createGroup(gid) {
  524. this.loading.groups = true
  525. this.$store.dispatch('addGroup', gid)
  526. .then((group) => {
  527. this.newUser.groups.push(this.groups.find(group => group.id === gid))
  528. this.loading.groups = false
  529. })
  530. .catch(() => {
  531. this.loading.groups = false
  532. })
  533. return this.$store.getters.getGroups[this.groups.length]
  534. },
  535. /**
  536. * If the selected group is the disabled group but the count is 0
  537. * redirect to the all users page.
  538. * * we only check for 0 because we don't have the count on ldap
  539. * * and we therefore set the usercount to -1 in this specific case
  540. */
  541. redirectIfDisabled() {
  542. const allGroups = this.$store.getters.getGroups
  543. if (this.selectedGroup === 'disabled'
  544. && allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) {
  545. // disabled group is empty, redirection to all users
  546. this.$router.push({ name: 'users' })
  547. this.$refs.infiniteLoading.stateChanger.reset()
  548. }
  549. },
  550. onClose() {
  551. this.showConfig.showNewUserForm = false
  552. },
  553. },
  554. }
  555. </script>
  556. <style scoped>
  557. .row::v-deep .multiselect__single {
  558. z-index: auto !important;
  559. }
  560. </style>