diff options
author | Christoph Wurst <christoph@winzerhof-wurst.at> | 2019-09-17 17:33:27 +0300 |
---|---|---|
committer | npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com> | 2019-09-28 12:39:28 +0300 |
commit | de6940352a2f708376219a89ec84a8e6d25ca59e (patch) | |
tree | 459bacfc183b24d611be1877fbe22bbcd4efb1d6 /apps/settings/src/components | |
parent | c8cd607681ac128228f57114ce14dd67ab05de04 (diff) |
Move settings to an app
Signed-off-by: Christoph Wurst <christoph@winzerhof-wurst.at>
Signed-off-by: npmbuildbot[bot] <npmbuildbot[bot]@users.noreply.github.com>
Diffstat (limited to 'apps/settings/src/components')
-rw-r--r-- | apps/settings/src/components/AdminTwoFactor.vue | 168 | ||||
-rw-r--r-- | apps/settings/src/components/AuthToken.vue | 306 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenList.vue | 135 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenSection.vue | 178 | ||||
-rw-r--r-- | apps/settings/src/components/AuthTokenSetupDialogue.vue | 204 | ||||
-rw-r--r-- | apps/settings/src/components/appDetails.vue | 237 | ||||
-rw-r--r-- | apps/settings/src/components/appList.vue | 184 | ||||
-rw-r--r-- | apps/settings/src/components/appList/appItem.vue | 136 | ||||
-rw-r--r-- | apps/settings/src/components/appList/appScore.vue | 38 | ||||
-rw-r--r-- | apps/settings/src/components/appManagement.vue | 138 | ||||
-rw-r--r-- | apps/settings/src/components/popoverMenu.vue | 40 | ||||
-rw-r--r-- | apps/settings/src/components/prefixMixin.vue | 32 | ||||
-rw-r--r-- | apps/settings/src/components/svgFilterMixin.vue | 40 | ||||
-rw-r--r-- | apps/settings/src/components/userList.vue | 429 | ||||
-rw-r--r-- | apps/settings/src/components/userList/userRow.vue | 574 |
15 files changed, 2839 insertions, 0 deletions
diff --git a/apps/settings/src/components/AdminTwoFactor.vue b/apps/settings/src/components/AdminTwoFactor.vue new file mode 100644 index 00000000000..a1f28f5cfdd --- /dev/null +++ b/apps/settings/src/components/AdminTwoFactor.vue @@ -0,0 +1,168 @@ +<template> + <div> + <p class="settings-hint"> + {{ t('settings', 'Two-factor authentication can be enforced for all users and specific groups. If they do not have a two-factor provider configured, they will be unable to log into the system.') }} + </p> + <p v-if="loading"> + <span class="icon-loading-small two-factor-loading"></span> + <span>{{ t('settings', 'Enforce two-factor authentication') }}</span> + </p> + <p v-else> + <input type="checkbox" + id="two-factor-enforced" + class="checkbox" + v-model="enforced"> + <label for="two-factor-enforced">{{ t('settings', 'Enforce two-factor authentication') }}</label> + </p> + <template v-if="enforced"> + <h3>{{ t('settings', 'Limit to groups') }}</h3> + {{ t('settings', 'Enforcement of two-factor authentication can be set for certain groups only.') }} + <p> + {{ t('settings', 'Two-factor authentication is enforced for all members of the following groups.') }} + </p> + <p> + <Multiselect v-model="enforcedGroups" + :options="groups" + :placeholder="t('settings', 'Enforced groups')" + :disabled="loading" + :multiple="true" + :searchable="true" + @search-change="searchGroup" + :loading="loadingGroups" + :show-no-options="false" + :close-on-select="false"> + </Multiselect> + </p> + <p> + {{ t('settings', 'Two-factor authentication is not enforced for members of the following groups.') }} + </p> + <p> + <Multiselect v-model="excludedGroups" + :options="groups" + :placeholder="t('settings', 'Excluded groups')" + :disabled="loading" + :multiple="true" + :searchable="true" + @search-change="searchGroup" + :loading="loadingGroups" + :show-no-options="false" + :close-on-select="false"> + </Multiselect> + </p> + <p> + <em> + <!-- this text is also found in the documentation. update it there as well if it ever changes --> + {{ t('settings', 'When groups are selected/excluded, they use the following logic to determine if a user has 2FA enforced: If no groups are selected, 2FA is enabled for everyone except members of the excluded groups. If groups are selected, 2FA is enabled for all members of these. If a user is both in a selected and excluded group, the selected takes precedence and 2FA is enforced.') }} + </em> + </p> + </template> + <p> + <button class="button primary" + v-if="dirty" + v-on:click="saveChanges" + :disabled="loading"> + {{ t('settings', 'Save changes') }} + </button> + </p> + </div> +</template> + +<script> + import Axios from 'nextcloud-axios' + import { mapState } from 'vuex' + import {Multiselect} from 'nextcloud-vue' + import _ from 'lodash' + + export default { + name: "AdminTwoFactor", + components: { + Multiselect + }, + data () { + return { + loading: false, + dirty: false, + groups: [], + loadingGroups: false, + } + }, + computed: { + enforced: { + get: function () { + return this.$store.state.enforced + }, + set: function (val) { + this.dirty = true + this.$store.commit('setEnforced', val) + } + }, + enforcedGroups: { + get: function () { + return this.$store.state.enforcedGroups + }, + set: function (val) { + this.dirty = true + this.$store.commit('setEnforcedGroups', val) + } + }, + excludedGroups: { + get: function () { + return this.$store.state.excludedGroups + }, + set: function (val) { + this.dirty = true + this.$store.commit('setExcludedGroups', val) + } + }, + }, + mounted () { + // Groups are loaded dynamically, but the assigned ones *should* + // be valid groups, so let's add them as initial state + this.groups = _.sortedUniq(_.uniq(this.enforcedGroups.concat(this.excludedGroups))) + + // Populate the groups with a first set so the dropdown is not empty + // when opening the page the first time + this.searchGroup('') + }, + methods: { + searchGroup: _.debounce(function (query) { + this.loadingGroups = true + Axios.get(OC.linkToOCS(`cloud/groups?offset=0&search=${encodeURIComponent(query)}&limit=20`, 2)) + .then(res => res.data.ocs) + .then(ocs => ocs.data.groups) + .then(groups => this.groups = _.sortedUniq(_.uniq(this.groups.concat(groups)))) + .catch(err => console.error('could not search groups', err)) + .then(() => this.loadingGroups = false) + }, 500), + + saveChanges () { + this.loading = true + + const data = { + enforced: this.enforced, + enforcedGroups: this.enforcedGroups, + excludedGroups: this.excludedGroups, + } + Axios.put(OC.generateUrl('/settings/api/admin/twofactorauth'), data) + .then(resp => resp.data) + .then(state => { + this.state = state + this.dirty = false + }) + .catch(err => { + console.error('could not save changes', err) + }) + .then(() => this.loading = false) + } + } + } +</script> + +<style> + .two-factor-loading { + display: inline-block; + vertical-align: sub; + margin-left: -2px; + margin-right: 1px; + } +</style> diff --git a/apps/settings/src/components/AuthToken.vue b/apps/settings/src/components/AuthToken.vue new file mode 100644 index 00000000000..fb5a331b72e --- /dev/null +++ b/apps/settings/src/components/AuthToken.vue @@ -0,0 +1,306 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <tr :data-id="token.id" + :class="wiping"> + <td class="client"> + <div :class="iconName.icon"></div> + </td> + <td class="token-name"> + <input v-if="token.canRename && renaming" + type="text" + ref="input" + v-model="newName" + @keyup.enter="rename" + @blur="cancelRename" + @keyup.esc="cancelRename"> + <span v-else>{{iconName.name}}</span> + <span v-if="wiping" + class="wiping-warning">({{ t('settings', 'Marked for remote wipe') }})</span> + </td> + <td> + <span class="last-activity" v-tooltip="lastActivity">{{lastActivityRelative}}</span> + </td> + <td class="more"> + <Actions v-if="!token.current" + :actions="actions" + :open.sync="actionOpen" + v-tooltip.auto="{ + content: t('settings', 'Device settings'), + container: 'body' + }"> + <ActionCheckbox v-if="token.type === 1" + :checked="token.scope.filesystem" + @change.stop.prevent="$emit('toggleScope', token, 'filesystem', !token.scope.filesystem)"> + <!-- TODO: add text/longtext with some description --> + {{ t('settings', 'Allow filesystem access') }} + </ActionCheckbox> + <ActionButton v-if="token.canRename" + icon="icon-rename" + @click.stop.prevent="startRename"> + <!-- TODO: add text/longtext with some description --> + {{ t('settings', 'Rename') }} + </ActionButton> + + <!-- revoke & wipe --> + <template v-if="token.canDelete"> + <template v-if="token.type !== 2"> + <ActionButton icon="icon-delete" + @click.stop.prevent="revoke"> + <!-- TODO: add text/longtext with some description --> + {{ t('settings', 'Revoke') }} + </ActionButton> + <ActionButton icon="icon-delete" + @click.stop.prevent="wipe"> + {{ t('settings', 'Wipe device') }} + </ActionButton> + </template> + <ActionButton v-else-if="token.type === 2" + icon="icon-delete" + :title="t('settings', 'Revoke')" + @click.stop.prevent="revoke"> + {{ t('settings', 'Revoking this token might prevent the wiping of your device if it hasn\'t started the wipe yet.') }} + </ActionButton> + </template> + </Actions> + </td> + </tr> +</template> + +<script> +import { + Actions, + ActionButton, + ActionCheckbox +} from 'nextcloud-vue'; + +const userAgentMap = { + ie: /(?:MSIE|Trident|Trident\/7.0; rv)[ :](\d+)/, + // Microsoft Edge User Agent from https://msdn.microsoft.com/en-us/library/hh869301(v=vs.85).aspx + edge: /^Mozilla\/5\.0 \([^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Chrome\/[0-9.]+ (?:Mobile Safari|Safari)\/[0-9.]+ Edge\/[0-9.]+$/, + // Firefox User Agent from https://developer.mozilla.org/en-US/docs/Web/HTTP/Gecko_user_agent_string_reference + firefox: /^Mozilla\/5\.0 \([^)]*(Windows|OS X|Linux)[^)]+\) Gecko\/[0-9.]+ Firefox\/(\d+)(?:\.\d)?$/, + // Chrome User Agent from https://developer.chrome.com/multidevice/user-agent + chrome: /^Mozilla\/5\.0 \([^)]*(Windows|OS X|Linux)[^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\) Chrome\/(\d+)[0-9.]+ (?:Mobile Safari|Safari)\/[0-9.]+$/, + // Safari User Agent from http://www.useragentstring.com/pages/Safari/ + safari: /^Mozilla\/5\.0 \([^)]*(Windows|OS X)[^)]+\) AppleWebKit\/[0-9.]+ \(KHTML, like Gecko\)(?: Version\/([0-9]+)[0-9.]+)? Safari\/[0-9.A-Z]+$/, + // Android Chrome user agent: https://developers.google.com/chrome/mobile/docs/user-agent + androidChrome: /Android.*(?:; (.*) Build\/).*Chrome\/(\d+)[0-9.]+/, + iphone: / *CPU +iPhone +OS +([0-9]+)_(?:[0-9_])+ +like +Mac +OS +X */, + ipad: /\(iPad\; *CPU +OS +([0-9]+)_(?:[0-9_])+ +like +Mac +OS +X */, + iosClient: /^Mozilla\/5\.0 \(iOS\) (ownCloud|Nextcloud)\-iOS.*$/, + androidClient: /^Mozilla\/5\.0 \(Android\) ownCloud\-android.*$/, + iosTalkClient: /^Mozilla\/5\.0 \(iOS\) Nextcloud\-Talk.*$/, + androidTalkClient: /^Mozilla\/5\.0 \(Android\) Nextcloud\-Talk.*$/, + // DAVdroid/1.2 (2016/07/03; dav4android; okhttp3) Android/6.0.1 + davDroid: /DAV(droid|x5)\/([0-9.]+)/, + // Mozilla/5.0 (U; Linux; Maemo; Jolla; Sailfish; like Android 4.3) AppleWebKit/538.1 (KHTML, like Gecko) WebPirate/2.0 like Mobile Safari/538.1 (compatible) + webPirate: /(Sailfish).*WebPirate\/(\d+)/, + // Mozilla/5.0 (Maemo; Linux; U; Jolla; Sailfish; Mobile; rv:31.0) Gecko/31.0 Firefox/31.0 SailfishBrowser/1.0 + sailfishBrowser: /(Sailfish).*SailfishBrowser\/(\d+)/ +}; +const nameMap = { + ie: t('setting', 'Internet Explorer'), + edge: t('setting', 'Edge'), + firefox: t('setting', 'Firefox'), + chrome: t('setting', 'Google Chrome'), + safari: t('setting', 'Safari'), + androidChrome: t('setting', 'Google Chrome for Android'), + iphone: t('setting', 'iPhone'), + ipad: t('setting', 'iPad'), + iosClient: t('setting', 'Nextcloud iOS app'), + androidClient: t('setting', 'Nextcloud Android app'), + iosTalkClient: t('setting', 'Nextcloud Talk for iOS'), + androidTalkClient: t('setting', 'Nextcloud Talk for Android'), + davDroid: 'DAVdroid', + webPirate: 'WebPirate', + sailfishBrowser: 'SailfishBrowser' +}; +const iconMap = { + ie: 'icon-desktop', + edge: 'icon-desktop', + firefox: 'icon-desktop', + chrome: 'icon-desktop', + safari: 'icon-desktop', + androidChrome: 'icon-phone', + iphone: 'icon-phone', + ipad: 'icon-tablet', + iosClient: 'icon-phone', + androidClient: 'icon-phone', + iosTalkClient: 'icon-phone', + androidTalkClient: 'icon-phone', + davDroid: 'icon-phone', + webPirate: 'icon-link', + sailfishBrowser: 'icon-link' +}; + +export default { + name: "AuthToken", + components: { + Actions, + ActionButton, + ActionCheckbox + }, + props: { + token: { + type: Object, + required: true, + } + }, + computed: { + lastActivityRelative () { + return OC.Util.relativeModifiedDate(this.token.lastActivity * 1000); + }, + lastActivity () { + return OC.Util.formatDate(this.token.lastActivity * 1000, 'LLL'); + }, + iconName () { + // pretty format sync client user agent + let matches = this.token.name.match(/Mozilla\/5\.0 \((\w+)\) (?:mirall|csyncoC)\/(\d+\.\d+\.\d+)/); + + let icon = ''; + if (matches) { + this.token.name = t('settings', 'Sync client - {os}', { + os: matches[1], + version: matches[2] + }); + icon = 'icon-desktop'; + } + + // preserve title for cases where we format it further + const title = this.token.name; + let name = this.token.name; + for (let client in userAgentMap) { + if (matches = title.match(userAgentMap[client])) { + if (matches[2] && matches[1]) { // version number and os + name = nameMap[client] + ' ' + matches[2] + ' - ' + matches[1]; + } else if (matches[1]) { // only version number + name = nameMap[client] + ' ' + matches[1]; + } else { + name = nameMap[client]; + } + + icon = iconMap[client]; + } + } + if (this.token.current) { + name = t('settings', 'This session'); + } + + return { + icon, + name, + }; + }, + wiping() { + return this.token.type === 2; + } + }, + data () { + return { + showMore: this.token.canScope || this.token.canDelete, + renaming: false, + newName: '', + actionOpen: false, + }; + }, + methods: { + startRename() { + // Close action (popover menu) + this.actionOpen = false; + + this.newName = this.token.name; + this.renaming = true; + this.$nextTick(() => { + this.$refs.input.select(); + }); + }, + cancelRename() { + this.renaming = false; + }, + revoke() { + this.actionOpen = false; + this.$emit('delete', this.token) + }, + rename() { + this.renaming = false; + this.$emit('rename', this.token, this.newName); + }, + wipe() { + this.actionOpen = false; + this.$emit('wipe', this.token); + } + } +} +</script> + +<style lang="scss" scoped> + .wiping { + background-color: var(--color-background-darker); + } + + td { + border-top: 1px solid var(--color-border); + max-width: 200px; + white-space: normal; + vertical-align: middle; + position: relative; + + &%icon { + overflow: visible; + position: relative; + width: 44px; + height: 44px; + } + + &.token-name { + padding: 10px 6px; + + &.token-rename { + padding: 0; + } + + input { + width: 100%; + margin: 0; + } + } + &.token-name .wiping-warning { + color: var(--color-text-lighter); + } + + &.more { + @extend %icon; + padding: 0 10px; + } + + &.client { + @extend %icon; + + div { + opacity: 0.57; + width: 44px; + height: 44px; + } + } + } +</style> diff --git a/apps/settings/src/components/AuthTokenList.vue b/apps/settings/src/components/AuthTokenList.vue new file mode 100644 index 00000000000..a7c0faf1b7b --- /dev/null +++ b/apps/settings/src/components/AuthTokenList.vue @@ -0,0 +1,135 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <table id="app-tokens-table"> + <thead v-if="tokens.length"> + <tr> + <th></th> + <th>{{ t('settings', 'Device') }}</th> + <th>{{ t('settings', 'Last activity') }}</th> + <th></th> + </tr> + </thead> + <tbody class="token-list"> + <AuthToken v-for="token in sortedTokens" + :key="token.id" + :token="token" + @toggleScope="toggleScope" + @rename="rename" + @delete="onDelete" + @wipe="onWipe" /> + </tbody> + </table> +</template> + +<script> + import AuthToken from './AuthToken'; + + export default { + name: 'AuthTokenList', + components: { + AuthToken + }, + props: { + tokens: { + type: Array, + required: true, + } + }, + computed: { + sortedTokens () { + return this.tokens.sort((t1, t2) => { + var ts1 = parseInt(t1.lastActivity, 10); + var ts2 = parseInt(t2.lastActivity, 10); + return ts2 - ts1; + }) + } + }, + methods: { + toggleScope (token, scope, value) { + // Just pass it on + this.$emit('toggleScope', token, scope, value); + }, + rename (token, newName) { + // Just pass it on + this.$emit('rename', token, newName); + }, + onDelete (token) { + // Just pass it on + this.$emit('delete', token); + }, + onWipe(token) { + // Just pass it on + this.$emit('wipe', token); + } + } + } +</script> + +<style lang="scss" scoped> + table { + width: 100%; + min-height: 50px; + padding-top: 5px; + max-width: 580px; + + th { + opacity: .5; + padding: 10px 10px 10px 0; + } + } + + .token-list { + td > a.icon-more { + transition: opacity var(--animation-quick); + } + + a.icon-more { + padding: 14px; + display: block; + width: 44px; + height: 44px; + opacity: .5; + } + + tr { + &:hover td > a.icon, + td > a.icon:focus, + &.active td > a.icon { + opacity: 1; + } + } + } +</style> + +<!-- some styles are not scoped to make them work on subcomponents --> +<style lang="scss"> + #app-tokens-table { + tr > *:nth-child(2) { + padding-left: 6px; + } + + tr > *:nth-child(3) { + text-align: right; + } + } +</style> diff --git a/apps/settings/src/components/AuthTokenSection.vue b/apps/settings/src/components/AuthTokenSection.vue new file mode 100644 index 00000000000..c53256102d3 --- /dev/null +++ b/apps/settings/src/components/AuthTokenSection.vue @@ -0,0 +1,178 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div id="security" class="section"> + <h2>{{ t('settings', 'Devices & sessions') }}</h2> + <p class="settings-hint hidden-when-empty">{{ t('settings', 'Web, desktop and mobile clients currently logged in to your account.') }}</p> + <AuthTokenList :tokens="tokens" + @toggleScope="toggleTokenScope" + @rename="rename" + @delete="deleteToken" + @wipe="wipeToken" /> + <AuthTokenSetupDialogue v-if="canCreateToken" :add="addNewToken" /> + </div> +</template> + +<script> + import Axios from 'nextcloud-axios'; + import confirmPassword from 'nextcloud-password-confirmation'; + + import AuthTokenList from './AuthTokenList'; + import AuthTokenSetupDialogue from './AuthTokenSetupDialogue'; + + const confirm = () => { + return new Promise(res => { + OC.dialogs.confirm( + t('core', 'Do you really want to wipe your data from this device?'), + t('core', 'Confirm wipe'), + res, + true + ) + }) + } + + /** + * Tap into a promise without losing the value + */ + const tap = cb => val => { + cb(val); + return val; + }; + + export default { + name: "AuthTokenSection", + props: { + tokens: { + type: Array, + required: true, + }, + canCreateToken: { + type: Boolean, + required: true + } + }, + components: { + AuthTokenSetupDialogue, + AuthTokenList + }, + data() { + return { + baseUrl: OC.generateUrl('/settings/personal/authtokens'), + } + }, + methods: { + addNewToken (name) { + console.debug('creating a new app token', name); + + const data = { + name, + }; + return Axios.post(this.baseUrl, data) + .then(resp => resp.data) + .then(tap(() => console.debug('app token created'))) + .then(tap(data => this.tokens.push(data.deviceToken))) + .catch(err => { + console.error.bind('could not create app password', err); + OC.Notification.showTemporary(t('core', 'Error while creating device token')); + throw err; + }); + }, + toggleTokenScope (token, scope, value) { + console.debug('updating app token scope', token.id, scope, value); + + const oldVal = token.scope[scope]; + token.scope[scope] = value; + + return this.updateToken(token) + .then(tap(() => console.debug('app token scope updated'))) + .catch(err => { + console.error.bind('could not update app token scope', err); + OC.Notification.showTemporary(t('core', 'Error while updating device token scope')); + + // Restore + token.scope[scope] = oldVal; + + throw err; + }) + }, + rename (token, newName) { + console.debug('renaming app token', token.id, token.name, newName); + + const oldName = token.name; + token.name = newName; + + return this.updateToken(token) + .then(tap(() => console.debug('app token name updated'))) + .catch(err => { + console.error.bind('could not update app token name', err); + OC.Notification.showTemporary(t('core', 'Error while updating device token name')); + + // Restore + token.name = oldName; + }) + }, + updateToken (token) { + return Axios.put(this.baseUrl + '/' + token.id, token) + .then(resp => resp.data) + }, + deleteToken (token) { + console.debug('deleting app token', token); + + this.tokens = this.tokens.filter(t => t !== token); + + return Axios.delete(this.baseUrl + '/' + token.id) + .then(resp => resp.data) + .then(tap(() => console.debug('app token deleted'))) + .catch(err => { + console.error.bind('could not delete app token', err); + OC.Notification.showTemporary(t('core', 'Error while deleting the token')); + + // Restore + this.tokens.push(token); + }) + }, + async wipeToken(token) { + console.debug('wiping app token', token); + + try { + await confirmPassword() + + if (!(await confirm())) { + console.debug('wipe aborted by user') + return; + } + await Axios.post(this.baseUrl + '/wipe/' + token.id) + console.debug('app token marked for wipe') + + token.type = 2; + } catch (err) { + console.error('could not wipe app token', err); + OC.Notification.showTemporary(t('core', 'Error while wiping the device with the token')); + } + } + } + } +</script> + +<style scoped> + +</style> diff --git a/apps/settings/src/components/AuthTokenSetupDialogue.vue b/apps/settings/src/components/AuthTokenSetupDialogue.vue new file mode 100644 index 00000000000..000e873e659 --- /dev/null +++ b/apps/settings/src/components/AuthTokenSetupDialogue.vue @@ -0,0 +1,204 @@ +<!-- + - @copyright 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @author 2019 Christoph Wurst <christoph@winzerhof-wurst.at> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + --> + +<template> + <div v-if="!adding"> + <input v-model="deviceName" + type="text" + @keydown.enter="submit" + :disabled="loading" + :placeholder="t('settings', 'App name')"> + <button class="button" + :disabled="loading" + @click="submit">{{ t('settings', 'Create new app password') }} + </button> + </div> + <div v-else> + {{ t('settings', 'Use the credentials below to configure your app or device.') }} + {{ t('settings', 'For security reasons this password will only be shown once.') }} + <div class="app-password-row"> + <span class="app-password-label">{{ t('settings', 'Username') }}</span> + <input :value="loginName" + type="text" + class="monospaced" + readonly="readonly" + @focus="selectInput"/> + </div> + <div class="app-password-row"> + <span class="app-password-label">{{ t('settings', 'Password') }}</span> + <input :value="appPassword" + type="text" + class="monospaced" + ref="appPassword" + readonly="readonly" + @focus="selectInput"/> + <a class="icon icon-clippy" + ref="clipboardButton" + v-tooltip="copyTooltipOptions" + @mouseover="hoveringCopyButton = true" + @mouseleave="hoveringCopyButton = false" + v-clipboard:copy="appPassword" + v-clipboard:success="onCopyPassword" + v-clipboard:error="onCopyPasswordFailed"></a> + <button class="button" + @click="reset"> + {{ t('settings', 'Done') }} + </button> + </div> + <div class="app-password-row"> + <span class="app-password-label"></span> + <a v-if="!showQR" + @click="showQR = true"> + {{ t('settings', 'Show QR code for mobile apps') }} + </a> + <QR v-else + :value="qrUrl"></QR> + </div> + </div> +</template> + +<script> + import QR from '@chenfengyuan/vue-qrcode'; + import confirmPassword from 'nextcloud-password-confirmation'; + + export default { + name: 'AuthTokenSetupDialogue', + components: { + QR, + }, + props: { + add: { + type: Function, + required: true, + } + }, + data () { + return { + adding: false, + loading: false, + deviceName: '', + appPassword: '', + loginName: '', + passwordCopied: false, + showQR: false, + qrUrl: '', + hoveringCopyButton: false, + } + }, + computed: { + copyTooltipOptions() { + const base = { + hideOnTargetClick: false, + trigger: 'manual', + }; + + if (this.passwordCopied) { + return { + ...base, + content:t('core', 'Copied!'), + show: true, + } + } else { + return { + ...base, + content: t('core', 'Copy'), + show: this.hoveringCopyButton, + } + } + } + }, + methods: { + selectInput (e) { + e.currentTarget.select(); + }, + submit: function () { + confirmPassword() + .then(() => { + this.loading = true; + return this.add(this.deviceName) + }) + .then(token => { + this.adding = true; + this.loginName = token.loginName; + this.appPassword = token.token; + + const server = window.location.protocol + '//' + window.location.host + OC.getRootPath(); + this.qrUrl = `nc://login/user:${token.loginName}&password:${token.token}&server:${server}`; + + this.$nextTick(() => { + this.$refs.appPassword.select(); + }); + }) + .catch(err => { + console.error('could not create a new app password', err); + OC.Notification.showTemporary(t('core', 'Error while creating device token')); + + this.reset(); + }); + }, + onCopyPassword() { + this.passwordCopied = true; + this.$refs.clipboardButton.blur(); + setTimeout(() => this.passwordCopied = false, 3000); + }, + onCopyPasswordFailed() { + OC.Notification.showTemporary(t('core', 'Could not copy app password. Please copy it manually.')); + }, + reset () { + this.adding = false; + this.loading = false; + this.showQR = false; + this.qrUrl = ''; + this.deviceName = ''; + this.appPassword = ''; + this.loginName = ''; + } + } + } +</script> + +<style lang="scss" scoped> + .app-password-row { + display: table-row; + + .icon { + background-size: 16px 16px; + display: inline-block; + position: relative; + top: 3px; + margin-left: 5px; + margin-right: 8px; + } + + } + + .app-password-label { + display: table-cell; + padding-right: 1em; + text-align: right; + vertical-align: middle; + } + + .monospaced { + width: 245px; + font-family: monospace; + } +</style> diff --git a/apps/settings/src/components/appDetails.vue b/apps/settings/src/components/appDetails.vue new file mode 100644 index 00000000000..9d418564e39 --- /dev/null +++ b/apps/settings/src/components/appDetails.vue @@ -0,0 +1,237 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <div id="app-details-view" style="padding: 20px;"> + <h2> + <div v-if="!app.preview" class="icon-settings-dark"></div> + <svg v-if="app.previewAsIcon && app.preview" width="32" height="32" viewBox="0 0 32 32"> + <defs><filter :id="filterId"><feColorMatrix in="SourceGraphic" type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0"></feColorMatrix></filter></defs> + <image x="0" y="0" width="32" height="32" preserveAspectRatio="xMinYMin meet" :filter="filterUrl" :xlink:href="app.preview" class="app-icon"></image> + </svg> + {{ app.name }}</h2> + <img v-if="app.screenshot" :src="app.screenshot" width="100%" /> + <div class="app-level" v-if="app.level === 300 || app.level === 200 || hasRating"> + <span class="supported icon-checkmark-color" v-if="app.level === 300" + v-tooltip.auto="t('settings', 'This app is supported via your current Nextcloud subscription.')"> + {{ t('settings', 'Supported') }}</span> + <span class="official icon-checkmark" v-if="app.level === 200" + v-tooltip.auto="t('settings', 'Official apps are developed by and within the community. They offer central functionality and are ready for production use.')"> + {{ t('settings', 'Official') }}</span> + <app-score v-if="hasRating" :score="app.appstoreData.ratingOverall"></app-score> + </div> + + <div class="app-author" v-if="author"> + {{ t('settings', 'by') }} + <span v-for="(a, index) in author"> + <a v-if="a['@attributes'] && a['@attributes']['homepage']" :href="a['@attributes']['homepage']">{{ a['@value'] }}</a><span v-else-if="a['@value']">{{ a['@value'] }}</span><span v-else>{{ a }}</span><span v-if="index+1 < author.length">, </span> + </span> + </div> + <div class="app-licence" v-if="licence">{{ licence }}</div> + <div class="actions"> + <div class="actions-buttons"> + <input v-if="app.update" class="update primary" type="button" :value="t('settings', 'Update to {version}', {version: app.update})" v-on:click="update(app.id)" :disabled="installing || loading(app.id)"/> + <input v-if="app.canUnInstall" class="uninstall" type="button" :value="t('settings', 'Remove')" v-on:click="remove(app.id)" :disabled="installing || loading(app.id)"/> + <input v-if="app.active" class="enable" type="button" :value="t('settings','Disable')" v-on:click="disable(app.id)" :disabled="installing || loading(app.id)" /> + <input v-if="!app.active && (app.canInstall || app.isCompatible)" class="enable primary" type="button" :value="enableButtonText" v-on:click="enable(app.id)" v-tooltip.auto="enableButtonTooltip" :disabled="!app.canInstall || installing || loading(app.id)" /> + <input v-else-if="!app.active" class="enable force" type="button" :value="forceEnableButtonText" v-on:click="forceEnable(app.id)" v-tooltip.auto="forceEnableButtonTooltip" :disabled="installing || loading(app.id)" /> + </div> + <div class="app-groups"> + <div class="groups-enable" v-if="app.active && canLimitToGroups(app)"> + <input type="checkbox" :value="app.id" v-model="groupCheckedAppsData" v-on:change="setGroupLimit" class="groups-enable__checkbox checkbox" :id="prefix('groups_enable', app.id)"> + <label :for="prefix('groups_enable', app.id)">{{ t('settings', 'Limit to groups') }}</label> + <input type="hidden" class="group_select" :title="t('settings', 'All')" value=""> + <multiselect v-if="isLimitedToGroups(app)" :options="groups" :value="appGroups" @select="addGroupLimitation" @remove="removeGroupLimitation" :options-limit="5" + :placeholder="t('settings', 'Limit app usage to groups')" + label="name" track-by="id" class="multiselect-vue" + :multiple="true" :close-on-select="false" + :tag-width="60" @search-change="asyncFindGroup"> + <span slot="noResult">{{t('settings', 'No results')}}</span> + </multiselect> + </div> + </div> + </div> + + <ul class="app-dependencies"> + <li v-if="app.missingMinOwnCloudVersion">{{ t('settings', 'This app has no minimum Nextcloud version assigned. This will be an error in the future.') }}</li> + <li v-if="app.missingMaxOwnCloudVersion">{{ t('settings', 'This app has no maximum Nextcloud version assigned. This will be an error in the future.') }}</li> + <li v-if="!app.canInstall"> + {{ t('settings', 'This app cannot be installed because the following dependencies are not fulfilled:') }} + <ul class="missing-dependencies"> + <li v-for="dep in app.missingDependencies">{{ dep }}</li> + </ul> + </li> + </ul> + + <p class="documentation"> + <a class="appslink" :href="appstoreUrl" v-if="!app.internal" target="_blank" rel="noreferrer noopener">{{ t('settings', 'View in store')}} ↗</a> + + <a class="appslink" v-if="app.website" :href="app.website" target="_blank" rel="noreferrer noopener">{{ t('settings', 'Visit website') }} ↗</a> + <a class="appslink" v-if="app.bugs" :href="app.bugs" target="_blank" rel="noreferrer noopener">{{ t('settings', 'Report a bug') }} ↗</a> + + <a class="appslink" v-if="app.documentation && app.documentation.user" :href="app.documentation.user" target="_blank" rel="noreferrer noopener">{{ t('settings', 'User documentation') }} ↗</a> + <a class="appslink" v-if="app.documentation && app.documentation.admin" :href="app.documentation.admin" target="_blank" rel="noreferrer noopener">{{ t('settings', 'Admin documentation') }} ↗</a> + <a class="appslink" v-if="app.documentation && app.documentation.developer" :href="app.documentation.developer" target="_blank" rel="noreferrer noopener">{{ t('settings', 'Developer documentation') }} ↗</a> + </p> + + <div class="app-description" v-html="renderMarkdown"></div> + </div> +</template> + +<script> +import { Multiselect } from 'nextcloud-vue'; +import marked from 'marked'; +import dompurify from 'dompurify' + +import AppScore from './appList/appScore'; +import AppManagement from './appManagement'; +import prefix from './prefixMixin'; +import SvgFilterMixin from './svgFilterMixin'; + +export default { + mixins: [AppManagement, prefix, SvgFilterMixin], + name: 'appDetails', + props: ['category', 'app'], + components: { + Multiselect, + AppScore + }, + data() { + return { + groupCheckedAppsData: false, + } + }, + mounted() { + if (this.app.groups.length > 0) { + this.groupCheckedAppsData = true; + } + }, + computed: { + appstoreUrl() { + return `https://apps.nextcloud.com/apps/${this.app.id}`; + }, + licence() { + if (this.app.licence) { + return t('settings', '{license}-licensed', { license: ('' + this.app.licence).toUpperCase() } ); + } + return null; + }, + hasRating() { + return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5; + }, + author() { + if (typeof this.app.author === 'string') { + return [ + { + '@value': this.app.author + } + ] + } + if (this.app.author['@value']) { + return [this.app.author]; + } + return this.app.author; + }, + appGroups() { + return this.app.groups.map(group => {return {id: group, name: group}}); + }, + groups() { + return this.$store.getters.getGroups + .filter(group => group.id !== 'disabled') + .sort((a, b) => a.name.localeCompare(b.name)); + }, + renderMarkdown() { + var renderer = new marked.Renderer(); + renderer.link = function(href, title, text) { + try { + var prot = decodeURIComponent(unescape(href)) + .replace(/[^\w:]/g, '') + .toLowerCase(); + } catch (e) { + return ''; + } + + if (prot.indexOf('http:') !== 0 && prot.indexOf('https:') !== 0) { + return ''; + } + + var out = '<a href="' + href + '" rel="noreferrer noopener"'; + if (title) { + out += ' title="' + title + '"'; + } + out += '>' + text + '</a>'; + return out; + }; + renderer.image = function(href, title, text) { + if (text) { + return text; + } + return title; + }; + renderer.blockquote = function(quote) { + return quote; + }; + return dompurify.sanitize( + marked(this.app.description.trim(), { + renderer: renderer, + gfm: false, + highlight: false, + tables: false, + breaks: false, + pedantic: false, + sanitize: true, + smartLists: true, + smartypants: false + }), + { + SAFE_FOR_JQUERY: true, + ALLOWED_TAGS: [ + 'strong', + 'p', + 'a', + 'ul', + 'ol', + 'li', + 'em', + 'del', + 'blockquote' + ] + } + ); + } + } +} +</script> + +<style scoped> + .force { + background: var(--color-main-background); + border-color: var(--color-error); + color: var(--color-error); + } + .force:hover, + .force:active { + background: var(--color-error); + border-color: var(--color-error) !important; + color: var(--color-main-background); + } +</style> diff --git a/apps/settings/src/components/appList.vue b/apps/settings/src/components/appList.vue new file mode 100644 index 00000000000..dd693b2fdce --- /dev/null +++ b/apps/settings/src/components/appList.vue @@ -0,0 +1,184 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <div id="app-content-inner"> + <div id="apps-list" class="apps-list" :class="{installed: (useBundleView || useListView), store: useAppStoreView}"> + <template v-if="useListView"> + <transition-group name="app-list" tag="div" class="apps-list-container"> + <app-item v-for="app in apps" :key="app.id" :app="app" :category="category" /> + </transition-group> + </template> + <template v-for="bundle in bundles" v-if="useBundleView && bundleApps(bundle.id).length > 0"> + <transition-group name="app-list" tag="div" class="apps-list-container"> + + <div class="apps-header" :key="bundle.id"> + <div class="app-image"></div> + <h2>{{ bundle.name }} <input type="button" :value="bundleToggleText(bundle.id)" v-on:click="toggleBundle(bundle.id)"></h2> + <div class="app-version"></div> + <div class="app-level"></div> + <div class="app-groups"></div> + <div class="actions"> </div> + </div> + <app-item v-for="app in bundleApps(bundle.id)" :key="bundle.id + app.id" :app="app" :category="category"/> + </transition-group> + </template> + <template v-if="useAppStoreView"> + <app-item v-for="app in apps" :key="app.id" :app="app" :category="category" :list-view="false" /> + </template> + + </div> + + <div id="apps-list-search" class="apps-list installed"> + <div class="apps-list-container"> + <template v-if="search !== '' && searchApps.length > 0"> + <div class="section"> + <div></div> + <td colspan="5"> + <h2>{{ t('settings', 'Results from other categories') }}</h2> + </td> + </div> + <app-item v-for="app in searchApps" :key="app.id" :app="app" :category="category" :list-view="true" /> + </template> + </div> + </div> + + <div id="apps-list-empty" class="emptycontent emptycontent-search" v-if="search !== '' && !loading && searchApps.length === 0 && apps.length === 0"> + <div id="app-list-empty-icon" class="icon-settings-dark"></div> + <h2>{{ t('settings', 'No apps found for your version')}}</h2> + </div> + + <div id="searchresults"></div> + </div> +</template> + +<script> +import appItem from './appList/appItem'; +import prefix from './prefixMixin'; + +export default { + name: 'appList', + mixins: [prefix], + props: ['category', 'app', 'search'], + components: { + appItem + }, + computed: { + loading() { + return this.$store.getters.loading('list'); + }, + apps() { + let apps = this.$store.getters.getAllApps + .filter(app => app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) + .sort(function (a, b) { + const sortStringA = '' + (a.active ? 0 : 1) + (a.update ? 0 : 1) + a.name; + const sortStringB = '' + (b.active ? 0 : 1) + (b.update ? 0 : 1) + b.name; + return OC.Util.naturalSortCompare(sortStringA, sortStringB); + }); + + if (this.category === 'installed') { + return apps.filter(app => app.installed); + } + if (this.category === 'enabled') { + return apps.filter(app => app.active && app.installed); + } + if (this.category === 'disabled') { + return apps.filter(app => !app.active && app.installed); + } + if (this.category === 'app-bundles') { + return apps.filter(app => app.bundles); + } + if (this.category === 'updates') { + return apps.filter(app => app.update); + } + // filter app store categories + return apps.filter(app => { + return app.appstore && app.category !== undefined && + (app.category === this.category || app.category.indexOf(this.category) > -1); + }); + }, + bundles() { + return this.$store.getters.getServerData.bundles; + }, + bundleApps() { + return function(bundle) { + return this.$store.getters.getAllApps + .filter(app => app.bundleId === bundle); + } + }, + searchApps() { + if (this.search === '') { + return []; + } + return this.$store.getters.getAllApps + .filter(app => { + if (app.name.toLowerCase().search(this.search.toLowerCase()) !== -1) { + return (!this.apps.find(_app => _app.id === app.id)); + } + return false; + }); + }, + useAppStoreView() { + return !this.useListView && !this.useBundleView; + }, + useListView() { + return (this.category === 'installed' || this.category === 'enabled' || this.category === 'disabled' || this.category === 'updates'); + }, + useBundleView() { + return (this.category === 'app-bundles'); + }, + allBundlesEnabled() { + let self = this; + return function(id) { + return self.bundleApps(id).filter(app => !app.active).length === 0; + } + }, + bundleToggleText() { + let self = this; + return function(id) { + if (self.allBundlesEnabled(id)) { + return t('settings', 'Disable all'); + } + return t('settings', 'Enable all'); + } + } + }, + methods: { + toggleBundle(id) { + if (this.allBundlesEnabled(id)) { + return this.disableBundle(id); + } + return this.enableBundle(id); + }, + enableBundle(id) { + let apps = this.bundleApps(id).map(app => app.id); + this.$store.dispatch('enableApp', { appId: apps, groups: [] }) + .catch((error) => { console.log(error); OC.Notification.show(error)}); + }, + disableBundle(id) { + let apps = this.bundleApps(id).map(app => app.id); + this.$store.dispatch('disableApp', { appId: apps, groups: [] }) + .catch((error) => { OC.Notification.show(error)}); + } + }, +} +</script> diff --git a/apps/settings/src/components/appList/appItem.vue b/apps/settings/src/components/appList/appItem.vue new file mode 100644 index 00000000000..daf5ddacc24 --- /dev/null +++ b/apps/settings/src/components/appList/appItem.vue @@ -0,0 +1,136 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <div class="section" v-bind:class="{ selected: isSelected }" v-on:click="showAppDetails"> + <div class="app-image app-image-icon" v-on:click="showAppDetails"> + <div v-if="(listView && !app.preview) || (!listView && !app.screenshot)" class="icon-settings-dark"></div> + + <svg v-if="listView && app.preview" width="32" height="32" viewBox="0 0 32 32"> + <defs><filter :id="filterId"><feColorMatrix in="SourceGraphic" type="matrix" values="-1 0 0 0 1 0 -1 0 0 1 0 0 -1 0 1 0 0 0 1 0"></feColorMatrix></filter></defs> + <image x="0" y="0" width="32" height="32" preserveAspectRatio="xMinYMin meet" :filter="filterUrl" :xlink:href="app.preview" class="app-icon"></image> + </svg> + + <img v-if="!listView && app.screenshot" :src="app.screenshot" width="100%" /> + </div> + <div class="app-name" v-on:click="showAppDetails"> + {{ app.name }} + </div> + <div class="app-summary" v-if="!listView">{{ app.summary }}</div> + <div class="app-version" v-if="listView"> + <span v-if="app.version">{{ app.version }}</span> + <span v-else-if="app.appstoreData.releases[0].version">{{ app.appstoreData.releases[0].version }}</span> + </div> + + <div class="app-level"> + <span class="supported icon-checkmark-color" v-if="app.level === 300" + v-tooltip.auto="t('settings', 'This app is supported via your current Nextcloud subscription.')"> + {{ t('settings', 'Supported') }}</span> + <span class="official icon-checkmark" v-if="app.level === 200" + v-tooltip.auto="t('settings', 'Official apps are developed by and within the community. They offer central functionality and are ready for production use.')"> + {{ t('settings', 'Official') }}</span> + <app-score v-if="hasRating && !listView" :score="app.score"></app-score> + </div> + + <div class="actions"> + <div class="warning" v-if="app.error">{{ app.error }}</div> + <div class="icon icon-loading-small" v-if="loading(app.id)"></div> + <input v-if="app.update" class="update primary" type="button" :value="t('settings', 'Update to {update}', {update:app.update})" v-on:click.stop="update(app.id)" :disabled="installing || loading(app.id)" /> + <input v-if="app.canUnInstall" class="uninstall" type="button" :value="t('settings', 'Remove')" v-on:click.stop="remove(app.id)" :disabled="installing || loading(app.id)" /> + <input v-if="app.active" class="enable" type="button" :value="t('settings','Disable')" v-on:click.stop="disable(app.id)" :disabled="installing || loading(app.id)" /> + <input v-if="!app.active && (app.canInstall || app.isCompatible)" class="enable" type="button" :value="enableButtonText" v-on:click.stop="enable(app.id)" v-tooltip.auto="enableButtonTooltip" :disabled="!app.canInstall || installing || loading(app.id)" /> + <input v-else-if="!app.active" class="enable force" type="button" :value="forceEnableButtonText" v-on:click.stop="forceEnable(app.id)" v-tooltip.auto="forceEnableButtonTooltip" :disabled="installing || loading(app.id)" /> + </div> + </div> +</template> + +<script> + import AppScore from './appScore'; + import AppManagement from '../appManagement'; + import SvgFilterMixin from '../svgFilterMixin'; + + export default { + name: 'appItem', + mixins: [AppManagement, SvgFilterMixin], + props: { + app: {}, + category: {}, + listView: { + type: Boolean, + default: true, + } + }, + watch: { + '$route.params.id': function (id) { + this.isSelected = (this.app.id === id); + } + }, + components: { + AppScore, + }, + data() { + return { + isSelected: false, + scrolled: false, + }; + }, + mounted() { + this.isSelected = (this.app.id === this.$route.params.id); + }, + computed: { + hasRating() { + return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5; + }, + }, + watchers: { + + }, + methods: { + showAppDetails(event) { + if (event.currentTarget.tagName === 'INPUT' || event.currentTarget.tagName === 'A') { + return; + } + this.$router.push({ + name: 'apps-details', + params: {category: this.category, id: this.app.id} + }); + }, + prefix(prefix, content) { + return prefix + '_' + content; + }, + } + } +</script> + +<style scoped> + .force { + background: var(--color-main-background); + border-color: var(--color-error); + color: var(--color-error); + } + .force:hover, + .force:active { + background: var(--color-error); + border-color: var(--color-error) !important; + color: var(--color-main-background); + } +</style> diff --git a/apps/settings/src/components/appList/appScore.vue b/apps/settings/src/components/appList/appScore.vue new file mode 100644 index 00000000000..bf04c688186 --- /dev/null +++ b/apps/settings/src/components/appList/appScore.vue @@ -0,0 +1,38 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <img :src="scoreImage" class="app-score-image" /> +</template> +<script> + export default { + name: 'appScore', + props: ['score'], + computed: { + scoreImage() { + let score = Math.round( this.score * 10 ); + let imageName = 'rating/s' + score + '.svg'; + return OC.imagePath('core', imageName); + } + } + }; +</script>
\ No newline at end of file diff --git a/apps/settings/src/components/appManagement.vue b/apps/settings/src/components/appManagement.vue new file mode 100644 index 00000000000..79fa0bc75d5 --- /dev/null +++ b/apps/settings/src/components/appManagement.vue @@ -0,0 +1,138 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<script> + export default { + mounted() { + if (this.app.groups.length > 0) { + this.groupCheckedAppsData = true; + } + }, + computed: { + appGroups() { + return this.app.groups.map(group => {return {id: group, name: group}}); + }, + loading() { + let self = this; + return function(id) { + return self.$store.getters.loading(id); + } + }, + installing() { + return this.$store.getters.loading('install'); + }, + enableButtonText() { + if (this.app.needsDownload) { + return t('settings', 'Download and enable'); + } + return t('settings', 'Enable'); + }, + forceEnableButtonText() { + if (this.app.needsDownload) { + return t('settings', 'Enable untested app'); + } + return t('settings', 'Enable untested app'); + }, + enableButtonTooltip() { + if (this.app.needsDownload) { + return t('settings','The app will be downloaded from the app store'); + } + return false; + }, + forceEnableButtonTooltip() { + const base = t('settings', 'This app is not marked as compatible with your Nextcloud version. If you continue you will still be able to install the app. Note that the app might not work as expected.'); + if (this.app.needsDownload) { + return base + ' ' + t('settings','The app will be downloaded from the app store'); + } + return base; + } + }, + methods: { + asyncFindGroup(query) { + return this.$store.dispatch('getGroups', {search: query, limit: 5, offset: 0}); + }, + isLimitedToGroups(app) { + if (this.app.groups.length || this.groupCheckedAppsData) { + return true; + } + return false; + }, + setGroupLimit: function() { + if (!this.groupCheckedAppsData) { + this.$store.dispatch('enableApp', {appId: this.app.id, groups: []}); + } + }, + canLimitToGroups(app) { + if (app.types && app.types.includes('filesystem') + || app.types.includes('prelogin') + || app.types.includes('authentication') + || app.types.includes('logging') + || app.types.includes('prevent_group_restriction')) { + return false; + } + return true; + }, + addGroupLimitation(group) { + let groups = this.app.groups.concat([]).concat([group.id]); + this.$store.dispatch('enableApp', { appId: this.app.id, groups: groups}); + }, + removeGroupLimitation(group) { + let currentGroups = this.app.groups.concat([]); + let index = currentGroups.indexOf(group.id); + if (index > -1) { + currentGroups.splice(index, 1); + } + this.$store.dispatch('enableApp', { appId: this.app.id, groups: currentGroups}); + }, + forceEnable(appId) { + this.$store.dispatch('forceEnableApp', { appId: appId, groups: [] }) + .then((response) => { OC.Settings.Apps.rebuildNavigation(); }) + .catch((error) => { OC.Notification.show(error)}); + }, + enable(appId) { + this.$store.dispatch('enableApp', { appId: appId, groups: [] }) + .then((response) => { OC.Settings.Apps.rebuildNavigation(); }) + .catch((error) => { OC.Notification.show(error)}); + }, + disable(appId) { + this.$store.dispatch('disableApp', { appId: appId }) + .then((response) => { OC.Settings.Apps.rebuildNavigation(); }) + .catch((error) => { OC.Notification.show(error)}); + }, + remove(appId) { + this.$store.dispatch('uninstallApp', { appId: appId }) + .then((response) => { OC.Settings.Apps.rebuildNavigation(); }) + .catch((error) => { OC.Notification.show(error)}); + }, + install(appId) { + this.$store.dispatch('enableApp', { appId: appId }) + .then((response) => { OC.Settings.Apps.rebuildNavigation(); }) + .catch((error) => { OC.Notification.show(error)}); + }, + update(appId) { + this.$store.dispatch('updateApp', { appId: appId }) + .then((response) => { OC.Settings.Apps.rebuildNavigation(); }) + .catch((error) => { OC.Notification.show(error)}); + } + } + } +</script> diff --git a/apps/settings/src/components/popoverMenu.vue b/apps/settings/src/components/popoverMenu.vue new file mode 100644 index 00000000000..dd9a29cc9fe --- /dev/null +++ b/apps/settings/src/components/popoverMenu.vue @@ -0,0 +1,40 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<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/apps/settings/src/components/prefixMixin.vue b/apps/settings/src/components/prefixMixin.vue new file mode 100644 index 00000000000..e2feb63276d --- /dev/null +++ b/apps/settings/src/components/prefixMixin.vue @@ -0,0 +1,32 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<script> + export default { + name: 'prefixMixin', + methods: { + prefix (prefix, content) { + return prefix + '_' + content; + }, + } + } +</script>
\ No newline at end of file diff --git a/apps/settings/src/components/svgFilterMixin.vue b/apps/settings/src/components/svgFilterMixin.vue new file mode 100644 index 00000000000..1d6e83d4829 --- /dev/null +++ b/apps/settings/src/components/svgFilterMixin.vue @@ -0,0 +1,40 @@ +<!-- + - @copyright Copyright (c) 2018 Julius Härtl <jus@bitgrid.net> + - + - @author Julius Härtl <jus@bitgrid.net> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<script> + export default { + name: 'svgFilterMixin', + mounted() { + this.filterId = 'invertIconApps' + Math.floor((Math.random() * 100 )) + new Date().getSeconds() + new Date().getMilliseconds(); + }, + computed: { + filterUrl () { + return `url(#${this.filterId})`; + }, + }, + data() { + return { + filterId: '', + }; + }, + } +</script>
\ No newline at end of file diff --git a/apps/settings/src/components/userList.vue b/apps/settings/src/components/userList.vue new file mode 100644 index 00000000000..7c363b01ae1 --- /dev/null +++ b/apps/settings/src/components/userList.vue @@ -0,0 +1,429 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<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', 'Display 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 && settings.isAdmin">{{ t('settings', 'Group admin for') }}</div> + <div id="headerQuota" class="quota">{{ t('settings', 'Quota') }}</div> + <div id="headerLanguages" class="languages" + v-if="showConfig.showLanguages">{{ t('settings', 'Language') }}</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.all" + :class="{'sticky': scrolled && showConfig.showNewUserForm}"> + <div :class="loading.all?'icon-loading-small':'icon-add'"></div> + <div class="name"> + <input id="newusername" type="text" required v-model="newUser.id" + :placeholder="this.settings.newUserGenerateUserID + ? t('settings', 'Will be autogenerated') + : t('settings', 'Username')" + name="username" autocomplete="off" autocapitalize="none" + autocorrect="off" ref="newusername" pattern="[a-zA-Z0-9 _\.@\-']+" + :disabled="this.settings.newUserGenerateUserID"> + </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===''" ref="newuserpassword" + :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==='' || this.settings.newUserRequireEmail" + :placeholder="t('settings', 'Email')" name="email" + autocomplete="off" autocapitalize="none" autocorrect="off"> + </div> + <div class="groups"> + <!-- hidden input trick for vanilla html5 form validation --> + <input type="text" :value="newUser.groups" v-if="!settings.isAdmin" + tabindex="-1" id="newgroups" :required="!settings.isAdmin" + :class="{'icon-loading-small': loading.groups}"/> + <multiselect v-model="newUser.groups" :options="canAddGroups" :disabled="loading.groups||loading.all" + tag-placeholder="create" :placeholder="t('settings', 'Add user in group')" + label="name" track-by="id" class="multiselect-vue" + :multiple="true" :taggable="true" :close-on-select="false" + :tag-width="60" @tag="createGroup"> + <!-- If user is not admin, he is a subadmin. + Subadmins can't create users outside their groups + Therefore, empty select is forbidden --> + <span slot="noResult">{{t('settings', 'No results')}}</span> + </multiselect> + </div> + <div class="subadmins" v-if="subAdminsGroups.length>0 && settings.isAdmin"> + <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" :tag-width="60"> + <span slot="noResult">{{t('settings', 'No results')}}</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="languages" v-if="showConfig.showLanguages"> + <multiselect :options="languages" v-model="newUser.language" + :placeholder="t('settings', 'Default language')" + label="name" track-by="code" class="multiselect-vue" + :allowEmpty="false" group-values="languages" group-label="label"> + </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')"> + </div> + </form> + + <user-row v-for="(user, key) in filteredUsers" :user="user" :key="key" :settings="settings" :showConfig="showConfig" + :groups="groups" :subAdminsGroups="subAdminsGroups" :quotaOptions="quotaOptions" :languages="languages" + :externalActions="externalActions" /> + <infinite-loading @infinite="infiniteHandler" ref="infiniteLoading"> + <div slot="spinner"><div class="users-icon-loading icon-loading"></div></div> + <div slot="no-more"><div class="users-list-end"></div></div> + <div slot="no-results"> + <div id="emptycontent"> + <div class="icon-contacts-dark"></div> + <h2>{{t('settings', 'No users in here')}}</h2> + </div> + </div> + </infinite-loading> + </div> +</template> + +<script> +import userRow from './userList/userRow'; +import { Multiselect } from 'nextcloud-vue' +import InfiniteLoading from 'vue-infinite-loading'; +import Vue from 'vue'; + +const unlimitedQuota = { + id: 'none', + label: t('settings', 'Unlimited') +} +const defaultQuota = { + id: 'default', + label: t('settings', 'Default quota') +} +const newUser = { + id: '', + displayName: '', + password: '', + mailAddress: '', + groups: [], + subAdminsGroups: [], + quota: defaultQuota, + language: { + code: 'en', + name: t('settings', 'Default language') + } +} + +export default { + name: 'userList', + props: ['users', 'showConfig', 'selectedGroup', 'externalActions'], + components: { + userRow, + Multiselect, + InfiniteLoading + }, + data() { + return { + unlimitedQuota, + defaultQuota, + loading: { + all: false, + groups: false + }, + scrolled: false, + searchQuery: '', + newUser: Object.assign({}, newUser) + }; + }, + mounted() { + if (!this.settings.canChangePassword) { + OC.Notification.showTemporary(t('settings', 'Password change is disabled because the master key is disabled')); + } + + /** + * Reset and init new user form + */ + this.resetForm() + + /** + * Register search + */ + this.userSearch = new OCA.Search(this.search, this.resetSearch); + }, + computed: { + settings() { + return this.$store.getters.getServerData; + }, + filteredUsers() { + if (this.selectedGroup === 'disabled') { + let disabledUsers = this.users.filter(user => user.enabled === false); + if (disabledUsers.length === 0 && this.$refs.infiniteLoading && this.$refs.infiniteLoading.isComplete) { + // disabled group is empty, redirection to all users + this.$router.push({ name: 'users' }); + this.$refs.infiniteLoading.stateChanger.reset() + } + return disabledUsers; + } + if (!this.settings.isAdmin) { + // we don't want subadmins to edit themselves + return this.users.filter(user => user.enabled !== false && user.id !== OC.getCurrentUser().uid); + } + return this.users.filter(user => user.enabled !== false); + }, + groups() { + // data provided php side + remove the disabled group + return this.$store.getters.getGroups + .filter(group => group.id !== 'disabled') + .sort((a, b) => a.name.localeCompare(b.name)); + }, + canAddGroups() { + // disabled if no permission to add new users to group + return this.groups.map(group => { + // clone object because we don't want + // to edit the original groups + group = Object.assign({}, group); + group.$isDisabled = group.canAdd === false; + return group; + }); + }, + subAdminsGroups() { + // data provided php side + return this.$store.getters.getSubadminGroups; + }, + 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; + }, + usersCount() { + return this.users.length + }, + + /* LANGUAGES */ + languages() { + return Array( + { + label: t('settings', 'Common languages'), + languages: this.settings.languages.commonlanguages + }, + { + label: t('settings', 'All languages'), + languages: this.settings.languages.languages + } + ); + } + }, + watch: { + // watch url change and group select + selectedGroup: function (val, old) { + this.$store.commit('resetUsers'); + this.$refs.infiniteLoading.stateChanger.reset() + this.setNewUserDefaultGroup(val); + }, + + // make sure the infiniteLoading state is changed if we manually + // add/remove data from the store + usersCount: function(val, old) { + // deleting the last user, reset the list + if (val === 0 && old === 1) { + this.$refs.infiniteLoading.stateChanger.reset() + // adding the first user, warn the infiniteLoader that + // the list is not empty anymore (we don't fetch the newly + // added user as we already have all the info we need) + } else if (val === 1 && old === 0) { + this.$refs.infiniteLoading.stateChanger.loaded() + } + } + }, + methods: { + onScroll(event) { + this.scrolled = event.target.scrollTo > 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, + group: this.selectedGroup !== 'disabled' ? this.selectedGroup : '', + search: this.searchQuery + }) + .then((response) => { response ? $state.loaded() : $state.complete() }); + }, + + /* SEARCH */ + search(query) { + this.searchQuery = query; + this.$store.commit('resetUsers'); + this.$refs.infiniteLoading.stateChanger.reset() + }, + resetSearch() { + this.search(''); + }, + + resetForm() { + // revert form to original state + this.newUser = Object.assign({}, newUser); + + /** + * Init default language from server data. The use of this.settings + * requires a computed variable, which break the v-model binding of the form, + * this is a much easier solution than getter and setter on a computed var + */ + if (this.settings.defaultLanguage) { + Vue.set(this.newUser.language, 'code', this.settings.defaultLanguage); + } + + /** + * In case the user directly loaded the user list within a group + * the watch won't be triggered. We need to initialize it. + */ + this.setNewUserDefaultGroup(this.selectedGroup); + + this.loading.all = false; + }, + createUser() { + this.loading.all = true; + this.$store.dispatch('addUser', { + userid: this.newUser.id, + password: this.newUser.password, + displayName: this.newUser.displayName, + email: this.newUser.mailAddress, + groups: this.newUser.groups.map(group => group.id), + subadmin: this.newUser.subAdminsGroups.map(group => group.id), + quota: this.newUser.quota.id, + language: this.newUser.language.code, + }) + .then(() => { + this.resetForm() + this.$refs.newusername.focus(); + }) + .catch((error) => { + this.loading.all = false; + if (error.response && error.response.data && error.response.data.ocs && error.response.data.ocs.meta) { + const statuscode = error.response.data.ocs.meta.statuscode + if (statuscode === 102) { + // wrong username + this.$refs.newusername.focus(); + } else if (statuscode === 107) { + // wrong password + this.$refs.newuserpassword.focus(); + } + } + }); + }, + setNewUserDefaultGroup(value) { + if (value && value.length > 0) { + // setting new user default group to the current selected one + let currentGroup = this.groups.find(group => group.id === value); + if (currentGroup) { + this.newUser.groups = [currentGroup]; + return; + } + } + // fallback, empty selected group + this.newUser.groups = []; + }, + + /** + * Create a new group + * + * @param {string} groups Group id + * @returns {Promise} + */ + createGroup(gid) { + this.loading.groups = true; + this.$store.dispatch('addGroup', gid) + .then((group) => { + this.newUser.groups.push(this.groups.find(group => group.id === gid)) + this.loading.groups = false; + }) + .catch(() => { + this.loading.groups = false; + }); + return this.$store.getters.getGroups[this.groups.length]; + } + } +} +</script> diff --git a/apps/settings/src/components/userList/userRow.vue b/apps/settings/src/components/userList/userRow.vue new file mode 100644 index 00000000000..4bcc40965b0 --- /dev/null +++ b/apps/settings/src/components/userList/userRow.vue @@ -0,0 +1,574 @@ +<!-- + - @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com> + - + - @author John Molakvoæ <skjnldsv@protonmail.com> + - + - @license GNU AGPL version 3 or any later version + - + - This program is free software: you can redistribute it and/or modify + - it under the terms of the GNU Affero General Public License as + - published by the Free Software Foundation, either version 3 of the + - License, or (at your option) any later version. + - + - This program is distributed in the hope that it will be useful, + - but WITHOUT ANY WARRANTY; without even the implied warranty of + - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + - GNU Affero General Public License for more details. + - + - You should have received a copy of the GNU Affero General Public License + - along with this program. If not, see <http://www.gnu.org/licenses/>. + - + --> + +<template> + <!-- Obfuscated user: Logged in user does not have permissions to see all of the data --> + <div class="row" v-if="Object.keys(user).length ===1" :data-id="user.id"> + <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"> + <img alt="" width="32" height="32" :src="generateAvatar(user.id, 32)" + :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'" + v-if="!loading.delete && !loading.disable && !loading.wipe"> + </div> + <div class="name">{{user.id}}</div> + <div class="obfuscated">{{t('settings','You do not have permissions to see the details of this user')}}</div> + </div> + + <!-- User full data --> + <div class="row" v-else :class="{'disabled': loading.delete || loading.disable}" :data-id="user.id"> + <div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}"> + <img alt="" width="32" height="32" :src="generateAvatar(user.id, 32)" + :srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'" + v-if="!loading.delete && !loading.disable && !loading.wipe"> + </div> + <!-- dirty hack to ellipsis on two lines --> + <div class="name">{{user.id}}</div> + <form class="displayName" :class="{'icon-loading-small': loading.displayName}" v-on:submit.prevent="updateDisplayName"> + <template v-if="user.backendCapabilities.setDisplayName"> + <input v-if="user.backendCapabilities.setDisplayName" + :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 v-if="user.backendCapabilities.setDisplayName" type="submit" class="icon-confirm" value="" /> + </template> + <div v-else class="name" v-tooltip.auto="t('settings', 'The backend does not support changing the display name')">{{user.displayname}}</div> + </form> + <form class="password" v-if="settings.canChangePassword && user.backendCapabilities.setPassword" :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="availableGroups" :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" + :multiple="true" :taggable="settings.isAdmin" :closeOnSelect="false" + :tag-width="60" + @tag="createGroup" @select="addUserGroup" @remove="removeUserGroup"> + <span slot="limit" class="multiselect__limit" v-tooltip.auto="formatGroupsTitle(userGroups)">+{{userGroups.length-2}}</span> + <span slot="noResult">{{t('settings', 'No results')}}</span> + </multiselect> + </div> + <div class="subadmins" v-if="subAdminsGroups.length>0 && settings.isAdmin" :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" + :multiple="true" :closeOnSelect="false" :tag-width="60" + @select="addUserSubAdmin" @remove="removeUserSubAdmin"> + <span slot="limit" class="multiselect__limit" v-tooltip.auto="formatGroupsTitle(userSubAdminsGroups)">+{{userSubAdminsGroups.length-2}}</span> + <span slot="noResult">{{t('settings', 'No results')}}</span> + </multiselect> + </div> + <div class="quota" :class="{'icon-loading-small': loading.quota}" v-tooltip.auto="usedSpace"> + <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="languages" :class="{'icon-loading-small': loading.languages}" + v-if="showConfig.showLanguages"> + <multiselect :value="userLanguage" :options="languages" :disabled="loading.languages||loading.all" + :placeholder="t('settings', 'No language set')" + label="name" track-by="code" class="multiselect-vue" + :allowEmpty="false" group-values="languages" group-label="label" + @input="setUserLanguage"> + </multiselect> + </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" v-tooltip.auto="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' && !loading.all"> + <div class="icon-more" v-click-outside="hideMenu" @click="toggleMenu"></div> + <div class="popovermenu" :class="{ 'open': openedMenu }"> + <popover-menu :menu="userActions" /> + </div> + </div> + <div class="feedback" :style="{opacity: feedbackMessage !== '' ? 1 : 0}"> + <div class="icon-checkmark"></div> + {{feedbackMessage}} + </div> + </div> + </div> +</template> + +<script> +import ClickOutside from 'vue-click-outside'; +import Vue from 'vue' +import VTooltip from 'v-tooltip' +import { PopoverMenu, Multiselect } from 'nextcloud-vue' + +Vue.use(VTooltip) + +export default { + name: 'userRow', + props: ['user', 'settings', 'groups', 'subAdminsGroups', 'quotaOptions', 'showConfig', 'languages', 'externalActions'], + components: { + PopoverMenu, + Multiselect + }, + directives: { + ClickOutside + }, + mounted() { + // required if popup needs to stay opened after menu click + // since we only have disable/delete actions, let's close it directly + // this.popupItem = this.$el; + }, + data() { + return { + rand: parseInt(Math.random() * 1000), + openedMenu: false, + feedbackMessage: '', + loading: { + all: false, + displayName: false, + password: false, + mailAddress: false, + groups: false, + subadmins: false, + quota: false, + delete: false, + disable: false, + languages: false, + wipe: false, + } + } + }, + computed: { + /* USER POPOVERMENU ACTIONS */ + userActions() { + let actions = [ + { + icon: 'icon-delete', + text: t('settings', 'Delete user'), + action: this.deleteUser, + }, + { + icon: 'icon-delete', + text: t('settings', 'Wipe all devices'), + action: this.wipeUserDevices, + }, + { + icon: this.user.enabled ? 'icon-close' : 'icon-add', + text: this.user.enabled ? t('settings', 'Disable user') : t('settings', 'Enable user'), + action: this.enableDisableUser, + }, + ]; + if (this.user.email !== null && this.user.email !== '') { + actions.push({ + icon: 'icon-mail', + text: t('settings','Resend welcome email'), + action: this.sendWelcomeMail + }) + } + return actions.concat(this.externalActions); + }, + + /* 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; + }, + availableGroups() { + return this.groups.map((group) => { + // clone object because we don't want + // to edit the original groups + let groupClone = Object.assign({}, group); + + // two settings here: + // 1. user NOT in group but no permission to add + // 2. user is in group but no permission to remove + groupClone.$isDisabled = + (group.canAdd === false && + !this.user.groups.includes(group.id)) || + (group.canRemove === false && + this.user.groups.includes(group.id)); + return groupClone; + }); + }, + + /* QUOTA MANAGEMENT */ + usedSpace() { + if (this.user.quota.used) { + return t('settings', '{size} used', {size: OC.Util.humanFileSize(this.user.quota.used)}); + } + return t('settings', '{size} used', {size: OC.Util.humanFileSize(0)}); + }, + 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 === '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; + }, + + /* LANGUAGE */ + userLanguage() { + let availableLanguages = this.languages[0].languages.concat(this.languages[1].languages); + let userLang = availableLanguages.find(lang => lang.code === this.user.language); + if (typeof userLang !== 'object' && this.user.language !== '') { + return { + code: this.user.language, + name: this.user.language + } + } else if(this.user.language === '') { + return false; + } + return userLang; + } + }, + methods: { + /* MENU HANDLING */ + toggleMenu() { + this.openedMenu = !this.openedMenu; + }, + 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 array of groups objects to a string for the popup + * + * @param {array} groups The groups + * @returns {string} + */ + formatGroupsTitle(groups) { + let names = groups.map(group => group.name); + return names.slice(2,).join(', '); + }, + + wipeUserDevices() { + this.loading.wipe = true; + this.loading.all = true; + let userid = this.user.id; + return this.$store.dispatch('wipeUserDevices', userid) + .then(() => { + this.loading.wipe = false + this.loading.all = false + }); + }, + + deleteUser() { + this.loading.delete = true; + this.loading.all = true; + let userid = this.user.id; + return this.$store.dispatch('deleteUser', userid) + .then(() => { + this.loading.delete = false + this.loading.all = false + }); + }, + + enableDisableUser() { + this.loading.delete = true; + this.loading.all = true; + let userid = this.user.id; + let enabled = !this.user.enabled; + return this.$store.dispatch('enableDisableUser', {userid, enabled}) + .then(() => { + this.loading.delete = false + this.loading.all = false + }); + }, + + /** + * 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 and add user to it + * + * @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}); + }) + .catch(() => { + this.loading = {groups:false, subadmins:false}; + }); + return this.$store.getters.getGroups[this.groups.length]; + }, + + /** + * Add user to group + * + * @param {object} group Group object + * @returns {Promise} + */ + addUserGroup(group) { + if (group.canAdd === false) { + return false; + } + 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) { + if (group.canRemove === false) { + return false; + } + 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 + // remove user from current list if current list is the removed group + if (this.$route.params.selectedGroup === gid) { + this.$store.commit('deleteUser', userid); + } + }) + .catch(() => { + 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); + }, + + /** + * Dispatch quota set request + * + * @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 !== null && validQuota >= 0) { + // unify format output + return this.setUserQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota))); + } + // if no valid do not change + return false; + }, + + /** + * Dispatch language set request + * + * @param {Object} lang language object {code:'en', name:'English'} + * @returns {Object} + */ + setUserLanguage(lang) { + this.loading.languages = true; + // ensure we only send the preset id + this.$store.dispatch('setUserData', { + userid: this.user.id, + key: 'language', + value: lang.code + }).then(() => this.loading.languages = false); + return lang; + }, + + /** + * Dispatch new welcome mail request + */ + sendWelcomeMail() { + this.loading.all = true; + this.$store.dispatch('sendWelcomeMail', this.user.id) + .then(success => { + if (success) { + // Show feedback to indicate the success + this.feedbackMessage = t('setting', 'Welcome mail sent!'); + setTimeout(() => { + this.feedbackMessage = ''; + }, 2000); + } + this.loading.all = false; + }); + } + + } +} +</script> |