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.

UserRow.vue 19KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706
  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. <!-- Obfuscated user: Logged in user does not have permissions to see all of the data -->
  24. <div v-if="Object.keys(user).length ===1" class="row" :data-id="user.id">
  25. <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}">
  26. <img v-if="!loading.delete && !loading.disable && !loading.wipe"
  27. alt=""
  28. width="32"
  29. height="32"
  30. :src="generateAvatar(user.id, 32)"
  31. :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'">
  32. </div>
  33. <div class="name">
  34. {{ user.id }}
  35. </div>
  36. <div class="obfuscated">
  37. {{ t('settings','You do not have permissions to see the details of this user') }}
  38. </div>
  39. </div>
  40. <!-- User full data -->
  41. <div v-else
  42. class="row"
  43. :class="{'disabled': loading.delete || loading.disable}"
  44. :data-id="user.id">
  45. <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}">
  46. <img v-if="!loading.delete && !loading.disable && !loading.wipe"
  47. alt=""
  48. width="32"
  49. height="32"
  50. :src="generateAvatar(user.id, 32)"
  51. :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'">
  52. </div>
  53. <!-- dirty hack to ellipsis on two lines -->
  54. <div class="name">
  55. {{ user.id }}
  56. </div>
  57. <form class="displayName" :class="{'icon-loading-small': loading.displayName}" @submit.prevent="updateDisplayName">
  58. <template v-if="user.backendCapabilities.setDisplayName">
  59. <input v-if="user.backendCapabilities.setDisplayName"
  60. :id="'displayName'+user.id+rand"
  61. ref="displayName"
  62. type="text"
  63. :disabled="loading.displayName||loading.all"
  64. :value="user.displayname"
  65. autocomplete="new-password"
  66. autocorrect="off"
  67. autocapitalize="off"
  68. spellcheck="false">
  69. <input v-if="user.backendCapabilities.setDisplayName"
  70. type="submit"
  71. class="icon-confirm"
  72. value="">
  73. </template>
  74. <div v-else v-tooltip.auto="t('settings', 'The backend does not support changing the display name')" class="name">
  75. {{ user.displayname }}
  76. </div>
  77. </form>
  78. <form v-if="settings.canChangePassword && user.backendCapabilities.setPassword"
  79. class="password"
  80. :class="{'icon-loading-small': loading.password}"
  81. @submit.prevent="updatePassword">
  82. <input :id="'password'+user.id+rand"
  83. ref="password"
  84. type="password"
  85. required
  86. :disabled="loading.password||loading.all"
  87. :minlength="minPasswordLength"
  88. value=""
  89. :placeholder="t('settings', 'New password')"
  90. autocomplete="new-password"
  91. autocorrect="off"
  92. autocapitalize="off"
  93. spellcheck="false">
  94. <input type="submit" class="icon-confirm" value="">
  95. </form>
  96. <div v-else />
  97. <form class="mailAddress" :class="{'icon-loading-small': loading.mailAddress}" @submit.prevent="updateEmail">
  98. <input :id="'mailAddress'+user.id+rand"
  99. ref="mailAddress"
  100. type="email"
  101. :disabled="loading.mailAddress||loading.all"
  102. :value="user.email"
  103. autocomplete="new-password"
  104. autocorrect="off"
  105. autocapitalize="off"
  106. spellcheck="false">
  107. <input type="submit" class="icon-confirm" value="">
  108. </form>
  109. <div class="groups" :class="{'icon-loading-small': loading.groups}">
  110. <Multiselect :value="userGroups"
  111. :options="availableGroups"
  112. :disabled="loading.groups||loading.all"
  113. tag-placeholder="create"
  114. :placeholder="t('settings', 'Add user in group')"
  115. label="name"
  116. track-by="id"
  117. class="multiselect-vue"
  118. :limit="2"
  119. :multiple="true"
  120. :taggable="settings.isAdmin"
  121. :close-on-select="false"
  122. :tag-width="60"
  123. @tag="createGroup"
  124. @select="addUserGroup"
  125. @remove="removeUserGroup">
  126. <span slot="limit" v-tooltip.auto="formatGroupsTitle(userGroups)" class="multiselect__limit">+{{ userGroups.length-2 }}</span>
  127. <span slot="noResult">{{ t('settings', 'No results') }}</span>
  128. </Multiselect>
  129. </div>
  130. <div v-if="subAdminsGroups.length>0 && settings.isAdmin" class="subadmins" :class="{'icon-loading-small': loading.subadmins}">
  131. <Multiselect :value="userSubAdminsGroups"
  132. :options="subAdminsGroups"
  133. :disabled="loading.subadmins||loading.all"
  134. :placeholder="t('settings', 'Set user as admin for')"
  135. label="name"
  136. track-by="id"
  137. class="multiselect-vue"
  138. :limit="2"
  139. :multiple="true"
  140. :close-on-select="false"
  141. :tag-width="60"
  142. @select="addUserSubAdmin"
  143. @remove="removeUserSubAdmin">
  144. <span slot="limit" v-tooltip.auto="formatGroupsTitle(userSubAdminsGroups)" class="multiselect__limit">+{{ userSubAdminsGroups.length-2 }}</span>
  145. <span slot="noResult">{{ t('settings', 'No results') }}</span>
  146. </Multiselect>
  147. </div>
  148. <div v-tooltip.auto="usedSpace" class="quota" :class="{'icon-loading-small': loading.quota}">
  149. <Multiselect :value="userQuota"
  150. :options="quotaOptions"
  151. :disabled="loading.quota||loading.all"
  152. tag-placeholder="create"
  153. :placeholder="t('settings', 'Select user quota')"
  154. label="label"
  155. track-by="id"
  156. class="multiselect-vue"
  157. :allow-empty="false"
  158. :taggable="true"
  159. @tag="validateQuota"
  160. @input="setUserQuota" />
  161. <progress class="quota-user-progress"
  162. :class="{'warn':usedQuota>80}"
  163. :value="usedQuota"
  164. max="100" />
  165. </div>
  166. <div v-if="showConfig.showLanguages"
  167. class="languages"
  168. :class="{'icon-loading-small': loading.languages}">
  169. <Multiselect :value="userLanguage"
  170. :options="languages"
  171. :disabled="loading.languages||loading.all"
  172. :placeholder="t('settings', 'No language set')"
  173. label="name"
  174. track-by="code"
  175. class="multiselect-vue"
  176. :allow-empty="false"
  177. group-values="languages"
  178. group-label="label"
  179. @input="setUserLanguage" />
  180. </div>
  181. <div v-if="showConfig.showStoragePath" class="storageLocation">
  182. {{ user.storageLocation }}
  183. </div>
  184. <div v-if="showConfig.showUserBackend" class="userBackend">
  185. {{ user.backend }}
  186. </div>
  187. <div v-if="showConfig.showLastLogin" v-tooltip.auto="user.lastLogin>0 ? OC.Util.formatDate(user.lastLogin) : ''" class="lastLogin">
  188. {{ user.lastLogin>0 ? OC.Util.relativeModifiedDate(user.lastLogin) : t('settings','Never') }}
  189. </div>
  190. <div class="userActions">
  191. <div v-if="OC.currentUser !== user.id && user.id !== 'admin' && !loading.all" class="toggleUserActions">
  192. <div v-click-outside="hideMenu" class="icon-more" @click="toggleMenu" />
  193. <div class="popovermenu" :class="{ 'open': openedMenu }">
  194. <PopoverMenu :menu="userActions" />
  195. </div>
  196. </div>
  197. <div class="feedback" :style="{opacity: feedbackMessage !== '' ? 1 : 0}">
  198. <div class="icon-checkmark" />
  199. {{ feedbackMessage }}
  200. </div>
  201. </div>
  202. </div>
  203. </template>
  204. <script>
  205. import ClickOutside from 'vue-click-outside'
  206. import Vue from 'vue'
  207. import VTooltip from 'v-tooltip'
  208. import { PopoverMenu, Multiselect } from 'nextcloud-vue'
  209. Vue.use(VTooltip)
  210. export default {
  211. name: 'UserRow',
  212. components: {
  213. PopoverMenu,
  214. Multiselect
  215. },
  216. directives: {
  217. ClickOutside
  218. },
  219. props: {
  220. user: {
  221. type: Object,
  222. required: true
  223. },
  224. settings: {
  225. type: Object,
  226. default: () => ({})
  227. },
  228. groups: {
  229. type: Array,
  230. default: () => []
  231. },
  232. subAdminsGroups: {
  233. type: Array,
  234. default: () => []
  235. },
  236. quotaOptions: {
  237. type: Array,
  238. default: () => []
  239. },
  240. showConfig: {
  241. type: Object,
  242. default: () => ({})
  243. },
  244. languages: {
  245. type: Array,
  246. required: true
  247. },
  248. externalActions: {
  249. type: Array,
  250. default: () => []
  251. }
  252. },
  253. data() {
  254. return {
  255. rand: parseInt(Math.random() * 1000),
  256. openedMenu: false,
  257. feedbackMessage: '',
  258. loading: {
  259. all: false,
  260. displayName: false,
  261. password: false,
  262. mailAddress: false,
  263. groups: false,
  264. subadmins: false,
  265. quota: false,
  266. delete: false,
  267. disable: false,
  268. languages: false,
  269. wipe: false
  270. }
  271. }
  272. },
  273. computed: {
  274. /* USER POPOVERMENU ACTIONS */
  275. userActions() {
  276. let actions = [
  277. {
  278. icon: 'icon-delete',
  279. text: t('settings', 'Delete user'),
  280. action: this.deleteUser
  281. },
  282. {
  283. icon: 'icon-delete',
  284. text: t('settings', 'Wipe all devices'),
  285. action: this.wipeUserDevices
  286. },
  287. {
  288. icon: this.user.enabled ? 'icon-close' : 'icon-add',
  289. text: this.user.enabled ? t('settings', 'Disable user') : t('settings', 'Enable user'),
  290. action: this.enableDisableUser
  291. }
  292. ]
  293. if (this.user.email !== null && this.user.email !== '') {
  294. actions.push({
  295. icon: 'icon-mail',
  296. text: t('settings', 'Resend welcome email'),
  297. action: this.sendWelcomeMail
  298. })
  299. }
  300. return actions.concat(this.externalActions)
  301. },
  302. /* GROUPS MANAGEMENT */
  303. userGroups() {
  304. let userGroups = this.groups.filter(group => this.user.groups.includes(group.id))
  305. return userGroups
  306. },
  307. userSubAdminsGroups() {
  308. let userSubAdminsGroups = this.subAdminsGroups.filter(group => this.user.subadmin.includes(group.id))
  309. return userSubAdminsGroups
  310. },
  311. availableGroups() {
  312. return this.groups.map((group) => {
  313. // clone object because we don't want
  314. // to edit the original groups
  315. let groupClone = Object.assign({}, group)
  316. // two settings here:
  317. // 1. user NOT in group but no permission to add
  318. // 2. user is in group but no permission to remove
  319. groupClone.$isDisabled
  320. = (group.canAdd === false
  321. && !this.user.groups.includes(group.id))
  322. || (group.canRemove === false
  323. && this.user.groups.includes(group.id))
  324. return groupClone
  325. })
  326. },
  327. /* QUOTA MANAGEMENT */
  328. usedSpace() {
  329. if (this.user.quota.used) {
  330. return t('settings', '{size} used', { size: OC.Util.humanFileSize(this.user.quota.used) })
  331. }
  332. return t('settings', '{size} used', { size: OC.Util.humanFileSize(0) })
  333. },
  334. usedQuota() {
  335. let quota = this.user.quota.quota
  336. if (quota > 0) {
  337. quota = Math.min(100, Math.round(this.user.quota.used / quota * 100))
  338. } else {
  339. var usedInGB = this.user.quota.used / (10 * Math.pow(2, 30))
  340. // asymptotic curve approaching 50% at 10GB to visualize used stace with infinite quota
  341. quota = 95 * (1 - (1 / (usedInGB + 1)))
  342. }
  343. return isNaN(quota) ? 0 : quota
  344. },
  345. // Mapping saved values to objects
  346. userQuota() {
  347. if (this.user.quota.quota >= 0) {
  348. // if value is valid, let's map the quotaOptions or return custom quota
  349. let humanQuota = OC.Util.humanFileSize(this.user.quota.quota)
  350. let userQuota = this.quotaOptions.find(quota => quota.id === humanQuota)
  351. return userQuota || { id: humanQuota, label: humanQuota }
  352. } else if (this.user.quota.quota === 'default') {
  353. // default quota is replaced by the proper value on load
  354. return this.quotaOptions[0]
  355. }
  356. return this.quotaOptions[1] // unlimited
  357. },
  358. /* PASSWORD POLICY? */
  359. minPasswordLength() {
  360. return this.$store.getters.getPasswordPolicyMinLength
  361. },
  362. /* LANGUAGE */
  363. userLanguage() {
  364. let availableLanguages = this.languages[0].languages.concat(this.languages[1].languages)
  365. let userLang = availableLanguages.find(lang => lang.code === this.user.language)
  366. if (typeof userLang !== 'object' && this.user.language !== '') {
  367. return {
  368. code: this.user.language,
  369. name: this.user.language
  370. }
  371. } else if (this.user.language === '') {
  372. return false
  373. }
  374. return userLang
  375. }
  376. },
  377. mounted() {
  378. // required if popup needs to stay opened after menu click
  379. // since we only have disable/delete actions, let's close it directly
  380. // this.popupItem = this.$el;
  381. },
  382. methods: {
  383. /* MENU HANDLING */
  384. toggleMenu() {
  385. this.openedMenu = !this.openedMenu
  386. },
  387. hideMenu() {
  388. this.openedMenu = false
  389. },
  390. /**
  391. * Generate avatar url
  392. *
  393. * @param {string} user The user name
  394. * @param {int} size Size integer, default 32
  395. * @returns {string}
  396. */
  397. generateAvatar(user, size = 32) {
  398. return OC.generateUrl(
  399. '/avatar/{user}/{size}?v={version}',
  400. {
  401. user: user,
  402. size: size,
  403. version: oc_userconfig.avatar.version
  404. }
  405. )
  406. },
  407. /**
  408. * Format array of groups objects to a string for the popup
  409. *
  410. * @param {array} groups The groups
  411. * @returns {string}
  412. */
  413. formatGroupsTitle(groups) {
  414. let names = groups.map(group => group.name)
  415. return names.slice(2).join(', ')
  416. },
  417. wipeUserDevices() {
  418. this.loading.wipe = true
  419. this.loading.all = true
  420. let userid = this.user.id
  421. return this.$store.dispatch('wipeUserDevices', userid)
  422. .then(() => {
  423. this.loading.wipe = false
  424. this.loading.all = false
  425. })
  426. },
  427. deleteUser() {
  428. this.loading.delete = true
  429. this.loading.all = true
  430. let userid = this.user.id
  431. return this.$store.dispatch('deleteUser', userid)
  432. .then(() => {
  433. this.loading.delete = false
  434. this.loading.all = false
  435. })
  436. },
  437. enableDisableUser() {
  438. this.loading.delete = true
  439. this.loading.all = true
  440. let userid = this.user.id
  441. let enabled = !this.user.enabled
  442. return this.$store.dispatch('enableDisableUser', { userid, enabled })
  443. .then(() => {
  444. this.loading.delete = false
  445. this.loading.all = false
  446. })
  447. },
  448. /**
  449. * Set user displayName
  450. *
  451. * @param {string} displayName The display name
  452. */
  453. updateDisplayName() {
  454. let displayName = this.$refs.displayName.value
  455. this.loading.displayName = true
  456. this.$store.dispatch('setUserData', {
  457. userid: this.user.id,
  458. key: 'displayname',
  459. value: displayName
  460. }).then(() => {
  461. this.loading.displayName = false
  462. this.$refs.displayName.value = displayName
  463. })
  464. },
  465. /**
  466. * Set user password
  467. *
  468. * @param {string} password The email adress
  469. */
  470. updatePassword() {
  471. let password = this.$refs.password.value
  472. this.loading.password = true
  473. this.$store.dispatch('setUserData', {
  474. userid: this.user.id,
  475. key: 'password',
  476. value: password
  477. }).then(() => {
  478. this.loading.password = false
  479. this.$refs.password.value = '' // empty & show placeholder
  480. })
  481. },
  482. /**
  483. * Set user mailAddress
  484. *
  485. * @param {string} mailAddress The email adress
  486. */
  487. updateEmail() {
  488. let mailAddress = this.$refs.mailAddress.value
  489. this.loading.mailAddress = true
  490. this.$store.dispatch('setUserData', {
  491. userid: this.user.id,
  492. key: 'email',
  493. value: mailAddress
  494. }).then(() => {
  495. this.loading.mailAddress = false
  496. this.$refs.mailAddress.value = mailAddress
  497. })
  498. },
  499. /**
  500. * Create a new group and add user to it
  501. *
  502. * @param {string} gid Group id
  503. */
  504. async createGroup(gid) {
  505. this.loading = { groups: true, subadmins: true }
  506. try {
  507. await this.$store.dispatch('addGroup', gid)
  508. let userid = this.user.id
  509. await this.$store.dispatch('addUserGroup', { userid, gid })
  510. } catch (error) {
  511. console.error(error)
  512. } finally {
  513. this.loading = { groups: false, subadmins: false }
  514. }
  515. return this.$store.getters.getGroups[this.groups.length]
  516. },
  517. /**
  518. * Add user to group
  519. *
  520. * @param {object} group Group object
  521. */
  522. async addUserGroup(group) {
  523. if (group.canAdd === false) {
  524. return false
  525. }
  526. this.loading.groups = true
  527. let userid = this.user.id
  528. let gid = group.id
  529. try {
  530. await this.$store.dispatch('addUserGroup', { userid, gid })
  531. } catch (error) {
  532. console.error(error)
  533. } finally {
  534. this.loading.groups = false
  535. }
  536. },
  537. /**
  538. * Remove user from group
  539. *
  540. * @param {object} group Group object
  541. */
  542. async removeUserGroup(group) {
  543. if (group.canRemove === false) {
  544. return false
  545. }
  546. this.loading.groups = true
  547. let userid = this.user.id
  548. let gid = group.id
  549. try {
  550. await this.$store.dispatch('removeUserGroup', { userid, gid })
  551. this.loading.groups = false
  552. // remove user from current list if current list is the removed group
  553. if (this.$route.params.selectedGroup === gid) {
  554. this.$store.commit('deleteUser', userid)
  555. }
  556. } catch {
  557. this.loading.groups = false
  558. }
  559. },
  560. /**
  561. * Add user to group
  562. *
  563. * @param {object} group Group object
  564. */
  565. async addUserSubAdmin(group) {
  566. this.loading.subadmins = true
  567. let userid = this.user.id
  568. let gid = group.id
  569. try {
  570. await this.$store.dispatch('addUserSubAdmin', { userid, gid })
  571. this.loading.subadmins = false
  572. } catch (error) {
  573. console.error(error)
  574. }
  575. },
  576. /**
  577. * Remove user from group
  578. *
  579. * @param {object} group Group object
  580. */
  581. async removeUserSubAdmin(group) {
  582. this.loading.subadmins = true
  583. let userid = this.user.id
  584. let gid = group.id
  585. try {
  586. await this.$store.dispatch('removeUserSubAdmin', { userid, gid })
  587. } catch (error) {
  588. console.error(error)
  589. } finally {
  590. this.loading.subadmins = false
  591. }
  592. },
  593. /**
  594. * Dispatch quota set request
  595. *
  596. * @param {string|Object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
  597. * @returns {string}
  598. */
  599. async setUserQuota(quota = 'none') {
  600. this.loading.quota = true
  601. // ensure we only send the preset id
  602. quota = quota.id ? quota.id : quota
  603. try {
  604. await this.$store.dispatch('setUserData', {
  605. userid: this.user.id,
  606. key: 'quota',
  607. value: quota
  608. })
  609. } catch (error) {
  610. console.error(error)
  611. } finally {
  612. this.loading.quota = false
  613. }
  614. return quota
  615. },
  616. /**
  617. * Validate quota string to make sure it's a valid human file size
  618. *
  619. * @param {string} quota Quota in readable format '5 GB'
  620. * @returns {Promise|boolean}
  621. */
  622. validateQuota(quota) {
  623. // only used for new presets sent through @Tag
  624. let validQuota = OC.Util.computerFileSize(quota)
  625. if (validQuota !== null && validQuota >= 0) {
  626. // unify format output
  627. return this.setUserQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota)))
  628. }
  629. // if no valid do not change
  630. return false
  631. },
  632. /**
  633. * Dispatch language set request
  634. *
  635. * @param {Object} lang language object {code:'en', name:'English'}
  636. * @returns {Object}
  637. */
  638. async setUserLanguage(lang) {
  639. this.loading.languages = true
  640. // ensure we only send the preset id
  641. try {
  642. await this.$store.dispatch('setUserData', {
  643. userid: this.user.id,
  644. key: 'language',
  645. value: lang.code
  646. })
  647. } catch (error) {
  648. console.error(error)
  649. } finally {
  650. this.loading.languages = false
  651. }
  652. return lang
  653. },
  654. /**
  655. * Dispatch new welcome mail request
  656. */
  657. sendWelcomeMail() {
  658. this.loading.all = true
  659. this.$store.dispatch('sendWelcomeMail', this.user.id)
  660. .then(success => {
  661. if (success) {
  662. // Show feedback to indicate the success
  663. this.feedbackMessage = t('setting', 'Welcome mail sent!')
  664. setTimeout(() => {
  665. this.feedbackMessage = ''
  666. }, 2000)
  667. }
  668. this.loading.all = false
  669. })
  670. }
  671. }
  672. }
  673. </script>