Welcome to mirror list, hosted at ThFree Co, Russian Federation.

github.com/nextcloud/server.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
Diffstat (limited to 'apps/settings/src')
-rw-r--r--apps/settings/src/.jshintrc3
-rw-r--r--apps/settings/src/App.vue38
-rw-r--r--apps/settings/src/components/AdminTwoFactor.vue168
-rw-r--r--apps/settings/src/components/AuthToken.vue306
-rw-r--r--apps/settings/src/components/AuthTokenList.vue135
-rw-r--r--apps/settings/src/components/AuthTokenSection.vue178
-rw-r--r--apps/settings/src/components/AuthTokenSetupDialogue.vue204
-rw-r--r--apps/settings/src/components/appDetails.vue237
-rw-r--r--apps/settings/src/components/appList.vue184
-rw-r--r--apps/settings/src/components/appList/appItem.vue136
-rw-r--r--apps/settings/src/components/appList/appScore.vue38
-rw-r--r--apps/settings/src/components/appManagement.vue138
-rw-r--r--apps/settings/src/components/popoverMenu.vue40
-rw-r--r--apps/settings/src/components/prefixMixin.vue32
-rw-r--r--apps/settings/src/components/svgFilterMixin.vue40
-rw-r--r--apps/settings/src/components/userList.vue429
-rw-r--r--apps/settings/src/components/userList/userRow.vue574
-rw-r--r--apps/settings/src/main-admin-security.js21
-rw-r--r--apps/settings/src/main-apps-users-management.js57
-rw-r--r--apps/settings/src/main-personal-security.js40
-rw-r--r--apps/settings/src/router.js83
-rw-r--r--apps/settings/src/store/admin-security.js63
-rw-r--r--apps/settings/src/store/api.js81
-rw-r--r--apps/settings/src/store/apps.js327
-rw-r--r--apps/settings/src/store/index.js57
-rw-r--r--apps/settings/src/store/oc.js47
-rw-r--r--apps/settings/src/store/settings.js40
-rw-r--r--apps/settings/src/store/users.js532
-rw-r--r--apps/settings/src/views/Apps.vue226
-rw-r--r--apps/settings/src/views/Users.vue446
30 files changed, 4900 insertions, 0 deletions
diff --git a/apps/settings/src/.jshintrc b/apps/settings/src/.jshintrc
new file mode 100644
index 00000000000..fc024bea970
--- /dev/null
+++ b/apps/settings/src/.jshintrc
@@ -0,0 +1,3 @@
+{
+ "esversion": 6
+}
diff --git a/apps/settings/src/App.vue b/apps/settings/src/App.vue
new file mode 100644
index 00000000000..c7c32ba9d63
--- /dev/null
+++ b/apps/settings/src/App.vue
@@ -0,0 +1,38 @@
+<!--
+ - @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>
+ <router-view></router-view>
+</template>
+
+<script>
+export default {
+ name: 'App',
+ beforeMount: function() {
+ // importing server data into the store
+ const serverDataElmt = document.getElementById('serverData');
+ if (serverDataElmt !== null) {
+ this.$store.commit('setServerData', JSON.parse(document.getElementById('serverData').dataset.server));
+ }
+ }
+}
+</script>
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">&nbsp;</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>
diff --git a/apps/settings/src/main-admin-security.js b/apps/settings/src/main-admin-security.js
new file mode 100644
index 00000000000..a728c085b43
--- /dev/null
+++ b/apps/settings/src/main-admin-security.js
@@ -0,0 +1,21 @@
+import Vue from 'vue'
+
+import AdminTwoFactor from './components/AdminTwoFactor.vue'
+import store from './store/admin-security'
+
+__webpack_nonce__ = btoa(OC.requestToken)
+
+Vue.prototype.t = t;
+
+// Not used here but required for legacy templates
+window.OC = window.OC || {};
+window.OC.Settings = window.OC.Settings || {};
+
+store.replaceState(
+ OCP.InitialState.loadState('settings', 'mandatory2FAState')
+)
+
+const View = Vue.extend(AdminTwoFactor)
+new View({
+ store
+}).$mount('#two-factor-auth-settings')
diff --git a/apps/settings/src/main-apps-users-management.js b/apps/settings/src/main-apps-users-management.js
new file mode 100644
index 00000000000..a8627c2277d
--- /dev/null
+++ b/apps/settings/src/main-apps-users-management.js
@@ -0,0 +1,57 @@
+/**
+ * @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/>.
+ *
+ */
+
+import Vue from 'vue';
+import VTooltip from 'v-tooltip';
+import { sync } from 'vuex-router-sync';
+
+import App from './App.vue';
+import router from './router';
+import store from './store';
+
+Vue.use(VTooltip, { defaultHtml: false });
+
+sync(store, router);
+
+// CSP config for webpack dynamic chunk loading
+// eslint-disable-next-line
+__webpack_nonce__ = btoa(OC.requestToken)
+
+// Correct the root of the app for chunk loading
+// OC.linkTo matches the apps folders
+// OC.generateUrl ensure the index.php (or not)
+// eslint-disable-next-line
+__webpack_public_path__ = OC.linkTo('settings', 'js/')
+
+// bind to window
+Vue.prototype.t = t;
+Vue.prototype.OC = OC;
+Vue.prototype.OCA = OCA;
+Vue.prototype.oc_userconfig = oc_userconfig;
+
+const app = new Vue({
+ router,
+ store,
+ render: h => h(App)
+}).$mount('#content');
+
+export { app, router, store };
diff --git a/apps/settings/src/main-personal-security.js b/apps/settings/src/main-personal-security.js
new file mode 100644
index 00000000000..9f020efd5f4
--- /dev/null
+++ b/apps/settings/src/main-personal-security.js
@@ -0,0 +1,40 @@
+/*
+ * @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/>.
+ */
+
+import Vue from 'vue';
+import VueClipboard from 'vue-clipboard2';
+import VTooltip from 'v-tooltip';
+
+import AuthTokenSection from './components/AuthTokenSection';
+
+__webpack_nonce__ = btoa(OC.requestToken);
+
+Vue.use(VueClipboard);
+Vue.use(VTooltip, { defaultHtml: false });
+Vue.prototype.t = t;
+
+const View = Vue.extend(AuthTokenSection);
+new View({
+ propsData: {
+ tokens: OCP.InitialState.loadState('settings', 'app_tokens'),
+ canCreateToken: OCP.InitialState.loadState('settings', 'can_create_app_token'),
+ }
+}).$mount('#security-authtokens');
diff --git a/apps/settings/src/router.js b/apps/settings/src/router.js
new file mode 100644
index 00000000000..512ab813840
--- /dev/null
+++ b/apps/settings/src/router.js
@@ -0,0 +1,83 @@
+/*
+ * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @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/>.
+ *
+ */
+
+import Vue from 'vue';
+import Router from 'vue-router';
+
+// Dynamic loading
+const Users = () => import('./views/Users');
+const Apps = () => import('./views/Apps');
+
+Vue.use(Router);
+
+/*
+ * This is the list of routes where the vuejs app will
+ * take over php to provide data
+ * You need to forward the php routing (routes.php) to
+ * the settings-vue template, where the vue-router will
+ * ensure the proper route.
+ * ⚠️ Routes needs to match the php routes.
+ */
+
+export default new Router({
+ mode: 'history',
+ // if index.php is in the url AND we got this far, then it's working:
+ // let's keep using index.php in the url
+ base: OC.generateUrl(''),
+ linkActiveClass: 'active',
+ routes: [
+ {
+ path: '/:index(index.php/)?settings/users',
+ component: Users,
+ props: true,
+ name: 'users',
+ children: [
+ {
+ path: ':selectedGroup(.*)',
+ name: 'group',
+ component: Users
+ }
+ ]
+ },
+ {
+ path: '/:index(index.php/)?settings/apps',
+ component: Apps,
+ props: true,
+ name: 'apps',
+ children: [
+ {
+ path: ':category',
+ name: 'apps-category',
+ component: Apps,
+ children: [
+ {
+ path: ':id',
+ name: 'apps-details',
+ component: Apps
+ }
+ ]
+ }
+ ]
+ }
+ ]
+});
diff --git a/apps/settings/src/store/admin-security.js b/apps/settings/src/store/admin-security.js
new file mode 100644
index 00000000000..997aab2af58
--- /dev/null
+++ b/apps/settings/src/store/admin-security.js
@@ -0,0 +1,63 @@
+/*
+ * @copyright 2019 Roeland Jago Douma <roeland@famdouma.nl>
+ *
+ * @author 2019 Roeland Jago Douma <roeland@famdouma.nl>
+ *
+ * @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/>.
+ */
+
+import Vue from 'vue'
+import Vuex from 'vuex'
+
+Vue.use(Vuex)
+
+export const mutations = {
+ setEnforced(state, enabled) {
+ Vue.set(state, 'enforced', enabled)
+ },
+ setEnforcedGroups(state, total) {
+ Vue.set(state, 'enforcedGroups', total)
+ },
+ setExcludedGroups(state, used) {
+ Vue.set(state, 'excludedGroups', used)
+ }
+}
+
+export const actions = {
+ save ({commit}, ) {
+ commit('setEnabled', false);
+
+ return generateCodes()
+ .then(({codes, state}) => {
+ commit('setEnabled', state.enabled);
+ commit('setTotal', state.total);
+ commit('setUsed', state.used);
+ commit('setCodes', codes);
+ return true;
+ });
+ }
+}
+
+export default new Vuex.Store({
+ strict: process.env.NODE_ENV !== 'production',
+ state: {
+ enforced: false,
+ enforcedGroups: [],
+ excludedGroups: [],
+ },
+ mutations,
+ actions
+})
diff --git a/apps/settings/src/store/api.js b/apps/settings/src/store/api.js
new file mode 100644
index 00000000000..185e80253a3
--- /dev/null
+++ b/apps/settings/src/store/api.js
@@ -0,0 +1,81 @@
+/**
+ * @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/>.
+ *
+ */
+
+import axios from 'nextcloud-axios'
+import confirmPassword from 'nextcloud-password-confirmation'
+
+const sanitize = function(url) {
+ return url.replace(/\/$/, ''); // Remove last url slash
+};
+
+export default {
+
+ /**
+ * This Promise is used to chain a request that require an admin password confirmation
+ * Since chaining Promise have a very precise behavior concerning catch and then,
+ * you'll need to be careful when using it.
+ * e.g
+ * // store
+ * action(context) {
+ * return api.requireAdmin().then((response) => {
+ * return api.get('url')
+ * .then((response) => {API success})
+ * .catch((error) => {API failure});
+ * }).catch((error) => {requireAdmin failure});
+ * }
+ * // vue
+ * this.$store.dispatch('action').then(() => {always executed})
+ *
+ * Since Promise.then().catch().then() will always execute the last then
+ * this.$store.dispatch('action').then will always be executed
+ *
+ * If you want requireAdmin failure to also catch the API request failure
+ * you will need to throw a new error in the api.get.catch()
+ *
+ * e.g
+ * api.requireAdmin().then((response) => {
+ * api.get('url')
+ * .then((response) => {API success})
+ * .catch((error) => {throw error;});
+ * }).catch((error) => {requireAdmin OR API failure});
+ *
+ * @returns {Promise}
+ */
+ requireAdmin() {
+ return confirmPassword();
+ },
+ get(url) {
+ return axios.get(sanitize(url));
+ },
+ post(url, data) {
+ return axios.post(sanitize(url), data);
+ },
+ patch(url, data) {
+ return axios.patch(sanitize(url), data);
+ },
+ put(url, data) {
+ return axios.put(sanitize(url), data);
+ },
+ delete(url, data) {
+ return axios.delete(sanitize(url), { data: data });
+ }
+}; \ No newline at end of file
diff --git a/apps/settings/src/store/apps.js b/apps/settings/src/store/apps.js
new file mode 100644
index 00000000000..8074eac2e00
--- /dev/null
+++ b/apps/settings/src/store/apps.js
@@ -0,0 +1,327 @@
+/*
+ * @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/>.
+ *
+ */
+
+import api from './api';
+import Vue from 'vue';
+
+const state = {
+ apps: [],
+ categories: [],
+ updateCount: 0,
+ loading: {},
+ loadingList: false,
+};
+
+const mutations = {
+
+ APPS_API_FAILURE(state, error) {
+ OC.Notification.showHtml(t('settings','An error occured during the request. Unable to proceed.')+'<br>'+error.error.response.data.data.message, {timeout: 7});
+ console.log(state, error);
+ },
+
+ initCategories(state, {categories, updateCount}) {
+ state.categories = categories;
+ state.updateCount = updateCount;
+ },
+
+ setUpdateCount(state, updateCount) {
+ state.updateCount = updateCount;
+ },
+
+ addCategory(state, category) {
+ state.categories.push(category);
+ },
+
+ appendCategories(state, categoriesArray) {
+ // convert obj to array
+ state.categories = categoriesArray;
+ },
+
+ setAllApps(state, apps) {
+ state.apps = apps;
+ },
+
+ setError(state, {appId, error}) {
+ if (!Array.isArray(appId)) {
+ appId = [appId];
+ }
+ appId.forEach((_id) => {
+ let app = state.apps.find(app => app.id === _id);
+ app.error = error;
+ });
+ },
+
+ clearError(state, {appId, error}) {
+ let app = state.apps.find(app => app.id === appId);
+ app.error = null;
+ },
+
+ enableApp(state, {appId, groups}) {
+ let app = state.apps.find(app => app.id === appId);
+ app.active = true;
+ app.groups = groups;
+ },
+
+ disableApp(state, appId) {
+ let app = state.apps.find(app => app.id === appId);
+ app.active = false;
+ app.groups = [];
+ if (app.removable) {
+ app.canUnInstall = true;
+ }
+ },
+
+ uninstallApp(state, appId) {
+ state.apps.find(app => app.id === appId).active = false;
+ state.apps.find(app => app.id === appId).groups = [];
+ state.apps.find(app => app.id === appId).needsDownload = true;
+ state.apps.find(app => app.id === appId).installed = false;
+ state.apps.find(app => app.id === appId).canUnInstall = false;
+ state.apps.find(app => app.id === appId).canInstall = true;
+ },
+
+ updateApp(state, appId) {
+ let app = state.apps.find(app => app.id === appId);
+ let version = app.update;
+ app.update = null;
+ app.version = version;
+ state.updateCount--;
+
+ },
+
+ resetApps(state) {
+ state.apps = [];
+ },
+ reset(state) {
+ state.apps = [];
+ state.categories = [];
+ state.updateCount = 0;
+ },
+ startLoading(state, id) {
+ if (Array.isArray(id)) {
+ id.forEach((_id) => {
+ Vue.set(state.loading, _id, true);
+ })
+ } else {
+ Vue.set(state.loading, id, true);
+ }
+ },
+ stopLoading(state, id) {
+ if (Array.isArray(id)) {
+ id.forEach((_id) => {
+ Vue.set(state.loading, _id, false);
+ })
+ } else {
+ Vue.set(state.loading, id, false);
+ }
+ },
+};
+
+const getters = {
+ loading(state) {
+ return function(id) {
+ return state.loading[id];
+ }
+ },
+ getCategories(state) {
+ return state.categories;
+ },
+ getAllApps(state) {
+ return state.apps;
+ },
+ getUpdateCount(state) {
+ return state.updateCount;
+ }
+};
+
+const actions = {
+
+ enableApp(context, { appId, groups }) {
+ let apps;
+ if (Array.isArray(appId)) {
+ apps = appId;
+ } else {
+ apps = [appId];
+ }
+ return api.requireAdmin().then((response) => {
+ context.commit('startLoading', apps);
+ context.commit('startLoading', 'install');
+ return api.post(OC.generateUrl(`settings/apps/enable`), {appIds: apps, groups: groups})
+ .then((response) => {
+ context.commit('stopLoading', apps);
+ context.commit('stopLoading', 'install');
+ apps.forEach(_appId => {
+ context.commit('enableApp', {appId: _appId, groups: groups});
+ });
+
+ // check for server health
+ return api.get(OC.generateUrl('apps/files'))
+ .then(() => {
+ if (response.data.update_required) {
+ OC.dialogs.info(
+ t(
+ 'settings',
+ 'The app has been enabled but needs to be updated. You will be redirected to the update page in 5 seconds.'
+ ),
+ t('settings','App update'),
+ function () {
+ window.location.reload();
+ },
+ true
+ );
+ setTimeout(function() {
+ location.reload();
+ }, 5000);
+ }
+ })
+ .catch((error) => {
+ if (!Array.isArray(appId)) {
+ context.commit('setError', {
+ appId: apps,
+ error: t('settings', 'Error: This app can not be enabled because it makes the server unstable')
+ });
+ }
+ });
+ })
+ .catch((error) => {
+ context.commit('stopLoading', apps);
+ context.commit('stopLoading', 'install');
+ context.commit('setError', {
+ appId: apps,
+ error: error.response.data.data.message
+ });
+ context.commit('APPS_API_FAILURE', { appId, error});
+ })
+ }).catch((error) => context.commit('API_FAILURE', { appId, error }));
+ },
+ forceEnableApp(context, { appId, groups }) {
+ let apps;
+ if (Array.isArray(appId)) {
+ apps = appId;
+ } else {
+ apps = [appId];
+ }
+ return api.requireAdmin().then(() => {
+ context.commit('startLoading', apps);
+ context.commit('startLoading', 'install');
+ return api.post(OC.generateUrl(`settings/apps/force`), {appId})
+ .then((response) => {
+ // TODO: find a cleaner solution
+ location.reload();
+ })
+ .catch((error) => {
+ context.commit('stopLoading', apps);
+ context.commit('stopLoading', 'install');
+ context.commit('setError', {
+ appId: apps,
+ error: error.response.data.data.message
+ });
+ context.commit('APPS_API_FAILURE', { appId, error});
+ })
+ }).catch((error) => context.commit('API_FAILURE', { appId, error }));
+ },
+ disableApp(context, { appId }) {
+ let apps;
+ if (Array.isArray(appId)) {
+ apps = appId;
+ } else {
+ apps = [appId];
+ }
+ return api.requireAdmin().then((response) => {
+ context.commit('startLoading', apps);
+ return api.post(OC.generateUrl(`settings/apps/disable`), {appIds: apps})
+ .then((response) => {
+ context.commit('stopLoading', apps);
+ apps.forEach(_appId => {
+ context.commit('disableApp', _appId);
+ });
+ return true;
+ })
+ .catch((error) => {
+ context.commit('stopLoading', apps);
+ context.commit('APPS_API_FAILURE', { appId, error })
+ })
+ }).catch((error) => context.commit('API_FAILURE', { appId, error }));
+ },
+ uninstallApp(context, { appId }) {
+ return api.requireAdmin().then((response) => {
+ context.commit('startLoading', appId);
+ return api.get(OC.generateUrl(`settings/apps/uninstall/${appId}`))
+ .then((response) => {
+ context.commit('stopLoading', appId);
+ context.commit('uninstallApp', appId);
+ return true;
+ })
+ .catch((error) => {
+ context.commit('stopLoading', appId);
+ context.commit('APPS_API_FAILURE', { appId, error })
+ })
+ }).catch((error) => context.commit('API_FAILURE', { appId, error }));
+ },
+
+ updateApp(context, { appId }) {
+ return api.requireAdmin().then((response) => {
+ context.commit('startLoading', appId);
+ context.commit('startLoading', 'install');
+ return api.get(OC.generateUrl(`settings/apps/update/${appId}`))
+ .then((response) => {
+ context.commit('stopLoading', 'install');
+ context.commit('stopLoading', appId);
+ context.commit('updateApp', appId);
+ return true;
+ })
+ .catch((error) => {
+ context.commit('stopLoading', appId);
+ context.commit('stopLoading', 'install');
+ context.commit('APPS_API_FAILURE', { appId, error })
+ })
+ }).catch((error) => context.commit('API_FAILURE', { appId, error }));
+ },
+
+ getAllApps(context) {
+ context.commit('startLoading', 'list');
+ return api.get(OC.generateUrl(`settings/apps/list`))
+ .then((response) => {
+ context.commit('setAllApps', response.data.apps);
+ context.commit('stopLoading', 'list');
+ return true;
+ })
+ .catch((error) => context.commit('API_FAILURE', error))
+ },
+
+ getCategories(context) {
+ context.commit('startLoading', 'categories');
+ return api.get(OC.generateUrl('settings/apps/categories'))
+ .then((response) => {
+ if (response.data.length > 0) {
+ context.commit('appendCategories', response.data);
+ context.commit('stopLoading', 'categories');
+ return true;
+ }
+ return false;
+ })
+ .catch((error) => context.commit('API_FAILURE', error));
+ },
+
+};
+
+export default { state, mutations, getters, actions }; \ No newline at end of file
diff --git a/apps/settings/src/store/index.js b/apps/settings/src/store/index.js
new file mode 100644
index 00000000000..00bcd67db39
--- /dev/null
+++ b/apps/settings/src/store/index.js
@@ -0,0 +1,57 @@
+/*
+ * @copyright Copyright (c) 2018 John Molakvoæ <skjnldsv@protonmail.com>
+ *
+ * @author John Molakvoæ <skjnldsv@protonmail.com>
+ * @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/>.
+ *
+ */
+
+import Vue from 'vue';
+import Vuex from 'vuex';
+import users from './users';
+import apps from './apps';
+import settings from './settings';
+import oc from './oc';
+
+Vue.use(Vuex)
+
+const debug = process.env.NODE_ENV !== 'production';
+
+const mutations = {
+ API_FAILURE(state, error) {
+ try {
+ let message = error.error.response.data.ocs.meta.message;
+ OC.Notification.showHtml(t('settings','An error occured during the request. Unable to proceed.')+'<br>'+message, {timeout: 7});
+ } catch(e) {
+ OC.Notification.showTemporary(t('settings','An error occured during the request. Unable to proceed.'));
+ }
+ console.log(state, error);
+ }
+};
+
+export default new Vuex.Store({
+ modules: {
+ users,
+ apps,
+ settings,
+ oc
+ },
+ strict: debug,
+
+ mutations
+});
diff --git a/apps/settings/src/store/oc.js b/apps/settings/src/store/oc.js
new file mode 100644
index 00000000000..afa13fe6b18
--- /dev/null
+++ b/apps/settings/src/store/oc.js
@@ -0,0 +1,47 @@
+/*
+ * @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/>.
+ *
+ */
+
+import api from './api';
+
+const state = {};
+const mutations = {};
+const getters = {};
+const actions = {
+ /**
+ * Set application config in database
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.app Application name
+ * @param {boolean} options.key Config key
+ * @param {boolean} options.value Value to set
+ * @returns{Promise}
+ */
+ setAppConfig(context, {app, key, value}) {
+ return api.requireAdmin().then((response) => {
+ return api.post(OC.linkToOCS(`apps/provisioning_api/api/v1/config/apps/${app}/${key}`, 2), {value: value})
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { app, key, value, error }));;
+ }
+};
+
+export default {state, mutations, getters, actions};
diff --git a/apps/settings/src/store/settings.js b/apps/settings/src/store/settings.js
new file mode 100644
index 00000000000..5f0bcfa60aa
--- /dev/null
+++ b/apps/settings/src/store/settings.js
@@ -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/>.
+ *
+ */
+
+import api from './api';
+
+const state = {
+ serverData: {}
+};
+const mutations = {
+ setServerData(state, data) {
+ state.serverData = data;
+ }
+};
+const getters = {
+ getServerData(state) {
+ return state.serverData;
+ }
+};
+const actions = {};
+
+export default {state, mutations, getters, actions};
diff --git a/apps/settings/src/store/users.js b/apps/settings/src/store/users.js
new file mode 100644
index 00000000000..1b174b21bf4
--- /dev/null
+++ b/apps/settings/src/store/users.js
@@ -0,0 +1,532 @@
+/*
+ * @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/>.
+ *
+ */
+
+import api from './api';
+
+const orderGroups = function(groups, orderBy) {
+ /* const SORT_USERCOUNT = 1;
+ * const SORT_GROUPNAME = 2;
+ * https://github.com/nextcloud/server/blob/208e38e84e1a07a49699aa90dc5b7272d24489f0/lib/private/Group/MetaData.php#L34
+ */
+ if (orderBy === 1) {
+ return groups.sort((a, b) => a.usercount-a.disabled < b.usercount - b.disabled);
+ } else {
+ return groups.sort((a, b) => a.name.localeCompare(b.name));
+ }
+};
+
+const defaults = {
+ group: {
+ id: '',
+ name: '',
+ usercount: 0,
+ disabled: 0,
+ canAdd: true,
+ canRemove: true
+ }
+};
+
+const state = {
+ users: [],
+ groups: [],
+ orderBy: 1,
+ minPasswordLength: 0,
+ usersOffset: 0,
+ usersLimit: 25,
+ userCount: 0
+};
+
+const mutations = {
+ appendUsers(state, usersObj) {
+ // convert obj to array
+ let users = state.users.concat(Object.keys(usersObj).map(userid => usersObj[userid]));
+ state.usersOffset += state.usersLimit;
+ state.users = users;
+ },
+ setPasswordPolicyMinLength(state, length) {
+ state.minPasswordLength = length!=='' ? length : 0;
+ },
+ initGroups(state, {groups, orderBy, userCount}) {
+ state.groups = groups.map(group => Object.assign({}, defaults.group, group));
+ state.orderBy = orderBy;
+ state.userCount = userCount;
+ state.groups = orderGroups(state.groups, state.orderBy);
+
+ },
+ addGroup(state, {gid, displayName}) {
+ try {
+ if (typeof state.groups.find((group) => group.id === gid) !== 'undefined') {
+ return;
+ }
+ // extend group to default values
+ let group = Object.assign({}, defaults.group, {
+ id: gid,
+ name: displayName,
+ });
+ state.groups.push(group);
+ state.groups = orderGroups(state.groups, state.orderBy);
+ } catch (e) {
+ console.log('Can\'t create group', e);
+ }
+ },
+ removeGroup(state, gid) {
+ let groupIndex = state.groups.findIndex(groupSearch => groupSearch.id == gid);
+ if (groupIndex >= 0) {
+ state.groups.splice(groupIndex, 1);
+ }
+ },
+ addUserGroup(state, { userid, gid }) {
+ let group = state.groups.find(groupSearch => groupSearch.id == gid);
+ let user = state.users.find(user => user.id == userid);
+ // increase count if user is enabled
+ if (group && user.enabled) {
+ group.usercount++;
+ }
+ let groups = user.groups;
+ groups.push(gid);
+ state.groups = orderGroups(state.groups, state.orderBy);
+ },
+ removeUserGroup(state, { userid, gid }) {
+ let group = state.groups.find(groupSearch => groupSearch.id == gid);
+ let user = state.users.find(user => user.id == userid);
+ // lower count if user is enabled
+ if (group && user.enabled) {
+ group.usercount--;
+ }
+ let groups = user.groups;
+ groups.splice(groups.indexOf(gid),1);
+ state.groups = orderGroups(state.groups, state.orderBy);
+ },
+ addUserSubAdmin(state, { userid, gid }) {
+ let groups = state.users.find(user => user.id == userid).subadmin;
+ groups.push(gid);
+ },
+ removeUserSubAdmin(state, { userid, gid }) {
+ let groups = state.users.find(user => user.id == userid).subadmin;
+ groups.splice(groups.indexOf(gid),1);
+ },
+ deleteUser(state, userid) {
+ let userIndex = state.users.findIndex(user => user.id == userid);
+ state.users.splice(userIndex, 1);
+ },
+ addUserData(state, response) {
+ state.users.push(response.data.ocs.data);
+ },
+ enableDisableUser(state, { userid, enabled }) {
+ let user = state.users.find(user => user.id == userid);
+ user.enabled = enabled;
+ // increment or not
+ state.groups.find(group => group.id == 'disabled').usercount += enabled ? -1 : 1;
+ state.userCount += enabled ? 1 : -1;
+ user.groups.forEach(group => {
+ // Increment disabled count
+ state.groups.find(groupSearch => groupSearch.id == group).disabled += enabled ? -1 : 1;
+ });
+ },
+ setUserData(state, { userid, key, value }) {
+ if (key === 'quota') {
+ let humanValue = OC.Util.computerFileSize(value);
+ state.users.find(user => user.id == userid)[key][key] = humanValue!==null ? humanValue : value;
+ } else {
+ state.users.find(user => user.id == userid)[key] = value;
+ }
+ },
+
+ /**
+ * Reset users list
+ */
+ resetUsers(state) {
+ state.users = [];
+ state.usersOffset = 0;
+ }
+};
+
+const getters = {
+ getUsers(state) {
+ return state.users;
+ },
+ getGroups(state) {
+ return state.groups;
+ },
+ getSubadminGroups(state) {
+ // Can't be subadmin of admin or disabled
+ return state.groups.filter(group => group.id !== 'admin' && group.id !== 'disabled');
+ },
+ getPasswordPolicyMinLength(state) {
+ return state.minPasswordLength;
+ },
+ getUsersOffset(state) {
+ return state.usersOffset;
+ },
+ getUsersLimit(state) {
+ return state.usersLimit;
+ },
+ getUserCount(state) {
+ return state.userCount;
+ }
+};
+
+const actions = {
+
+ /**
+ * Get all users with full details
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {int} options.offset List offset to request
+ * @param {int} options.limit List number to return from offset
+ * @param {string} options.search Search amongst users
+ * @param {string} options.group Get users from group
+ * @returns {Promise}
+ */
+ getUsers(context, { offset, limit, search, group }) {
+ search = typeof search === 'string' ? search : '';
+ group = typeof group === 'string' ? group : '';
+ if (group !== '') {
+ return api.get(OC.linkToOCS(`cloud/groups/${group}/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2))
+ .then((response) => {
+ if (Object.keys(response.data.ocs.data.users).length > 0) {
+ context.commit('appendUsers', response.data.ocs.data.users);
+ return true;
+ }
+ return false;
+ })
+ .catch((error) => context.commit('API_FAILURE', error));
+ }
+
+ return api.get(OC.linkToOCS(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2))
+ .then((response) => {
+ if (Object.keys(response.data.ocs.data.users).length > 0) {
+ context.commit('appendUsers', response.data.ocs.data.users);
+ return true;
+ }
+ return false;
+ })
+ .catch((error) => context.commit('API_FAILURE', error));
+ },
+
+ getGroups(context, { offset, limit, search }) {
+ search = typeof search === 'string' ? search : '';
+ let limitParam = limit === -1 ? '' : `&limit=${limit}`;
+ return api.get(OC.linkToOCS(`cloud/groups?offset=${offset}&search=${search}${limitParam}`, 2))
+ .then((response) => {
+ if (Object.keys(response.data.ocs.data.groups).length > 0) {
+ response.data.ocs.data.groups.forEach(function(group) {
+ context.commit('addGroup', {gid: group, displayName: group});
+ });
+ return true;
+ }
+ return false;
+ })
+ .catch((error) => context.commit('API_FAILURE', error));
+ },
+
+ /**
+ * Get all users with full details
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {int} options.offset List offset to request
+ * @param {int} options.limit List number to return from offset
+ * @returns {Promise}
+ */
+ getUsersFromList(context, { offset, limit, search }) {
+ search = typeof search === 'string' ? search : '';
+ return api.get(OC.linkToOCS(`cloud/users/details?offset=${offset}&limit=${limit}&search=${search}`, 2))
+ .then((response) => {
+ if (Object.keys(response.data.ocs.data.users).length > 0) {
+ context.commit('appendUsers', response.data.ocs.data.users);
+ return true;
+ }
+ return false;
+ })
+ .catch((error) => context.commit('API_FAILURE', error));
+ },
+
+ /**
+ * Get all users with full details from a groupid
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {int} options.offset List offset to request
+ * @param {int} options.limit List number to return from offset
+ * @returns {Promise}
+ */
+ getUsersFromGroup(context, { groupid, offset, limit }) {
+ return api.get(OC.linkToOCS(`cloud/users/${groupid}/details?offset=${offset}&limit=${limit}`, 2))
+ .then((response) => context.commit('getUsersFromList', response.data.ocs.data.users))
+ .catch((error) => context.commit('API_FAILURE', error));
+ },
+
+
+ getPasswordPolicyMinLength(context) {
+ if(OC.getCapabilities().password_policy && OC.getCapabilities().password_policy.minLength) {
+ context.commit('setPasswordPolicyMinLength', OC.getCapabilities().password_policy.minLength);
+ return OC.getCapabilities().password_policy.minLength;
+ }
+ return false;
+ },
+
+ /**
+ * Add group
+ *
+ * @param {Object} context
+ * @param {string} gid Group id
+ * @returns {Promise}
+ */
+ addGroup(context, gid) {
+ return api.requireAdmin().then((response) => {
+ return api.post(OC.linkToOCS(`cloud/groups`, 2), {groupid: gid})
+ .then((response) => {
+ context.commit('addGroup', {gid: gid, displayName: gid})
+ return {gid: gid, displayName: gid}
+ })
+ .catch((error) => {throw error;});
+ }).catch((error) => {
+ context.commit('API_FAILURE', { gid, error });
+ // let's throw one more time to prevent the view
+ // from adding the user to a group that doesn't exists
+ throw error;
+ });
+ },
+
+ /**
+ * Remove group
+ *
+ * @param {Object} context
+ * @param {string} gid Group id
+ * @returns {Promise}
+ */
+ removeGroup(context, gid) {
+ return api.requireAdmin().then((response) => {
+ return api.delete(OC.linkToOCS(`cloud/groups/${gid}`, 2))
+ .then((response) => context.commit('removeGroup', gid))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { gid, error }));
+ },
+
+ /**
+ * Add user to group
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.userid User id
+ * @param {string} options.gid Group id
+ * @returns {Promise}
+ */
+ addUserGroup(context, { userid, gid }) {
+ return api.requireAdmin().then((response) => {
+ return api.post(OC.linkToOCS(`cloud/users/${userid}/groups`, 2), { groupid: gid })
+ .then((response) => context.commit('addUserGroup', { userid, gid }))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ },
+
+ /**
+ * Remove user from group
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.userid User id
+ * @param {string} options.gid Group id
+ * @returns {Promise}
+ */
+ removeUserGroup(context, { userid, gid }) {
+ return api.requireAdmin().then((response) => {
+ return api.delete(OC.linkToOCS(`cloud/users/${userid}/groups`, 2), { groupid: gid })
+ .then((response) => context.commit('removeUserGroup', { userid, gid }))
+ .catch((error) => {throw error;});
+ }).catch((error) => {
+ context.commit('API_FAILURE', { userid, error });
+ // let's throw one more time to prevent
+ // the view from removing the user row on failure
+ throw error;
+ });
+ },
+
+ /**
+ * Add user to group admin
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.userid User id
+ * @param {string} options.gid Group id
+ * @returns {Promise}
+ */
+ addUserSubAdmin(context, { userid, gid }) {
+ return api.requireAdmin().then((response) => {
+ return api.post(OC.linkToOCS(`cloud/users/${userid}/subadmins`, 2), { groupid: gid })
+ .then((response) => context.commit('addUserSubAdmin', { userid, gid }))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ },
+
+ /**
+ * Remove user from group admin
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.userid User id
+ * @param {string} options.gid Group id
+ * @returns {Promise}
+ */
+ removeUserSubAdmin(context, { userid, gid }) {
+ return api.requireAdmin().then((response) => {
+ return api.delete(OC.linkToOCS(`cloud/users/${userid}/subadmins`, 2), { groupid: gid })
+ .then((response) => context.commit('removeUserSubAdmin', { userid, gid }))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ },
+
+ /**
+ * Mark all user devices for remote wipe
+ *
+ * @param {Object} context
+ * @param {string} userid User id
+ * @returns {Promise}
+ */
+ wipeUserDevices(context, userid) {
+ return api.requireAdmin().then((response) => {
+ return api.post(OC.linkToOCS(`cloud/users/${userid}/wipe`, 2))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ },
+
+ /**
+ * Delete a user
+ *
+ * @param {Object} context
+ * @param {string} userid User id
+ * @returns {Promise}
+ */
+ deleteUser(context, userid) {
+ return api.requireAdmin().then((response) => {
+ return api.delete(OC.linkToOCS(`cloud/users/${userid}`, 2))
+ .then((response) => context.commit('deleteUser', userid))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ },
+
+ /**
+ * Add a user
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.userid User id
+ * @param {string} options.password User password
+ * @param {string} options.displayName User display name
+ * @param {string} options.email User email
+ * @param {string} options.groups User groups
+ * @param {string} options.subadmin User subadmin groups
+ * @param {string} options.quota User email
+ * @returns {Promise}
+ */
+ addUser({commit, dispatch}, { userid, password, displayName, email, groups, subadmin, quota, language }) {
+ return api.requireAdmin().then((response) => {
+ return api.post(OC.linkToOCS(`cloud/users`, 2), { userid, password, displayName, email, groups, subadmin, quota, language })
+ .then((response) => dispatch('addUserData', userid || response.data.ocs.data.id))
+ .catch((error) => {throw error;});
+ }).catch((error) => {
+ commit('API_FAILURE', { userid, error });
+ throw error;
+ });
+ },
+
+ /**
+ * Get user data and commit addition
+ *
+ * @param {Object} context
+ * @param {string} userid User id
+ * @returns {Promise}
+ */
+ addUserData(context, userid) {
+ return api.requireAdmin().then((response) => {
+ return api.get(OC.linkToOCS(`cloud/users/${userid}`, 2))
+ .then((response) => context.commit('addUserData', response))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ },
+
+ /** Enable or disable user
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.userid User id
+ * @param {boolean} options.enabled User enablement status
+ * @returns {Promise}
+ */
+ enableDisableUser(context, { userid, enabled = true }) {
+ let userStatus = enabled ? 'enable' : 'disable';
+ return api.requireAdmin().then((response) => {
+ return api.put(OC.linkToOCS(`cloud/users/${userid}/${userStatus}`, 2))
+ .then((response) => context.commit('enableDisableUser', { userid, enabled }))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ },
+
+ /**
+ * Edit user data
+ *
+ * @param {Object} context
+ * @param {Object} options
+ * @param {string} options.userid User id
+ * @param {string} options.key User field to edit
+ * @param {string} options.value Value of the change
+ * @returns {Promise}
+ */
+ setUserData(context, { userid, key, value }) {
+ let allowedEmpty = ['email', 'displayname'];
+ if (['email', 'language', 'quota', 'displayname', 'password'].indexOf(key) !== -1) {
+ // We allow empty email or displayname
+ if (typeof value === 'string' &&
+ (
+ (allowedEmpty.indexOf(key) === -1 && value.length > 0) ||
+ allowedEmpty.indexOf(key) !== -1
+ )
+ ) {
+ return api.requireAdmin().then((response) => {
+ return api.put(OC.linkToOCS(`cloud/users/${userid}`, 2), { key: key, value: value })
+ .then((response) => context.commit('setUserData', { userid, key, value }))
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ }
+ }
+ return Promise.reject(new Error('Invalid request data'));
+ },
+
+ /**
+ * Send welcome mail
+ *
+ * @param {Object} context
+ * @param {string} userid User id
+ * @returns {Promise}
+ */
+ sendWelcomeMail(context, userid) {
+ return api.requireAdmin().then((response) => {
+ return api.post(OC.linkToOCS(`cloud/users/${userid}/welcome`, 2))
+ .then(response => true)
+ .catch((error) => {throw error;});
+ }).catch((error) => context.commit('API_FAILURE', { userid, error }));
+ }
+};
+
+export default { state, mutations, getters, actions };
diff --git a/apps/settings/src/views/Apps.vue b/apps/settings/src/views/Apps.vue
new file mode 100644
index 00000000000..35caf7cea59
--- /dev/null
+++ b/apps/settings/src/views/Apps.vue
@@ -0,0 +1,226 @@
+<!--
+ - @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>
+ <Content app-name="settings" :class="{ 'with-app-sidebar': currentApp}"
+ :content-class="{ 'icon-loading': loadingList }" :navigation-class="{ 'icon-loading': loading }">
+ <AppNavigation>
+ <ul id="appscategories">
+ <AppNavigationItem v-for="item in menu" :key="item.key" :item="item" />
+ </ul>
+ </AppNavigation>
+ <AppContent class="app-settings-content" :class="{ 'icon-loading': loadingList }">
+ <AppList :category="category" :app="currentApp" :search="searchQuery" />
+ </AppContent>
+ <AppSidebar v-if="id && currentApp" @close="hideAppDetails">
+ <AppDetails :category="category" :app="currentApp" />
+ </AppSidebar>
+ </Content>
+</template>
+
+<script>
+import {
+ AppContent,
+ AppNavigation,
+ AppNavigationItem,
+ AppSidebar,
+ Content
+} from 'nextcloud-vue';
+import AppList from '../components/appList';
+import Vue from 'vue';
+import VueLocalStorage from 'vue-localstorage'
+import AppDetails from '../components/appDetails';
+
+Vue.use(VueLocalStorage)
+
+export default {
+ name: 'Apps',
+ props: {
+ category: {
+ type: String,
+ default: 'installed',
+ },
+ id: {
+ type: String,
+ default: '',
+ }
+ },
+ components: {
+ AppContent,
+ AppNavigation,
+ AppNavigationItem,
+ AppSidebar,
+ Content,
+ AppDetails,
+ AppList
+ },
+ methods: {
+ setSearch(query) {
+ this.searchQuery = query;
+ },
+ resetSearch() {
+ this.setSearch('');
+ },
+ hideAppDetails() {
+ this.$router.push({
+ name: 'apps-category',
+ params: {category: this.category}
+ })
+ }
+ },
+ beforeMount() {
+ this.$store.dispatch('getCategories');
+ this.$store.dispatch('getAllApps');
+ this.$store.dispatch('getGroups', {offset: 0, limit: 5});
+ this.$store.commit('setUpdateCount', this.$store.getters.getServerData.updateCount)
+ },
+ mounted() {
+ /**
+ * Register search
+ */
+ this.appSearch = new OCA.Search(this.setSearch, this.resetSearch);
+ },
+ data() {
+ return {
+ searchQuery: ''
+ }
+ },
+ watch: {
+ category: function (val, old) {
+ this.setSearch('');
+ }
+ },
+ computed: {
+ loading() {
+ return this.$store.getters.loading('categories');
+ },
+ loadingList() {
+ return this.$store.getters.loading('list');
+ },
+ currentApp() {
+ return this.apps.find(app => app.id === this.id );
+ },
+ categories() {
+ return this.$store.getters.getCategories;
+ },
+ apps() {
+ return this.$store.getters.getAllApps;
+ },
+ updateCount() {
+ return this.$store.getters.getUpdateCount;
+ },
+ settings() {
+ return this.$store.getters.getServerData;
+ },
+
+ // BUILD APP NAVIGATION MENU OBJECT
+ menu() {
+ // Data provided php side
+ let categories = this.$store.getters.getCategories;
+ categories = Array.isArray(categories) ? categories : [];
+
+ // Map groups
+ categories = categories.map(category => {
+ let item = {};
+ item.id = 'app-category-' + category.ident;
+ item.icon = 'icon-category-' + category.ident;
+ item.classes = []; // empty classes, active will be set later
+ item.router = { // router link to
+ name: 'apps-category',
+ params: {category: category.ident}
+ };
+ item.text = category.displayName;
+
+ return item;
+ });
+
+
+ // Add everyone group
+ let defaultCategories = [
+ {
+ id: 'app-category-your-apps',
+ classes: [],
+ router: {name: 'apps'},
+ icon: 'icon-category-installed',
+ text: t('settings', 'Your apps'),
+ },
+ {
+ id: 'app-category-enabled',
+ classes: [],
+ icon: 'icon-category-enabled',
+ router: {name: 'apps-category', params: {category: 'enabled'}},
+ text: t('settings', 'Active apps'),
+ }, {
+ id: 'app-category-disabled',
+ classes: [],
+ icon: 'icon-category-disabled',
+ router: {name: 'apps-category', params: {category: 'disabled'}},
+ text: t('settings', 'Disabled apps'),
+ }
+ ];
+
+ if (!this.settings.appstoreEnabled) {
+ return defaultCategories
+ }
+
+ if (this.$store.getters.getUpdateCount > 0) {
+ defaultCategories.push({
+ id: 'app-category-updates',
+ classes: [],
+ icon: 'icon-download',
+ router: {name: 'apps-category', params: {category: 'updates'}},
+ text: t('settings', 'Updates'),
+ utils: {counter: this.$store.getters.getUpdateCount}
+ });
+ }
+
+ defaultCategories.push({
+ id: 'app-category-app-bundles',
+ classes: [],
+ icon: 'icon-category-app-bundles',
+ router: {name: 'apps-category', params: {category: 'app-bundles'}},
+ text: t('settings', 'App bundles'),
+ });
+
+ categories = defaultCategories.concat(categories);
+
+ // Set current group as active
+ let activeGroup = categories.findIndex(group => group.id === 'app-category-' + this.category);
+ if (activeGroup >= 0) {
+ categories[activeGroup].classes.push('active');
+ } else {
+ categories[0].classes.push('active');
+ }
+
+ categories.push({
+ id: 'app-developer-docs',
+ classes: [],
+ href: this.settings.developerDocumentation,
+ text: t('settings', 'Developer documentation') + ' ↗',
+ });
+
+ // Return
+ return categories
+ },
+ }
+}
+</script>
diff --git a/apps/settings/src/views/Users.vue b/apps/settings/src/views/Users.vue
new file mode 100644
index 00000000000..dcbef1cd799
--- /dev/null
+++ b/apps/settings/src/views/Users.vue
@@ -0,0 +1,446 @@
+<!--
+ - @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>
+ <Content app-name="settings" :navigation-class="{ 'icon-loading': loadingAddGroup }">
+ <AppNavigation>
+ <AppNavigationNew button-id="new-user-button" :text="t('settings','New user')" button-class="icon-add" @click="toggleNewUserMenu" />
+ <ul id="usergrouplist">
+ <AppNavigationItem v-for="item in menu" :key="item.key" :item="item" />
+ </ul>
+ <AppNavigationSettings>
+ <div>
+ <p>{{t('settings', 'Default quota:')}}</p>
+ <Multiselect :value="defaultQuota" :options="quotaOptions"
+ tag-placeholder="create" :placeholder="t('settings', 'Select default quota')"
+ label="label" track-by="id"
+ :allowEmpty="false" :taggable="true"
+ @tag="validateQuota" @input="setDefaultQuota">
+ </Multiselect>
+
+ </div>
+ <div>
+ <input type="checkbox" id="showLanguages" class="checkbox" v-model="showLanguages">
+ <label for="showLanguages">{{t('settings', 'Show Languages')}}</label>
+ </div>
+ <div>
+ <input type="checkbox" id="showLastLogin" class="checkbox" v-model="showLastLogin">
+ <label for="showLastLogin">{{t('settings', 'Show last login')}}</label>
+ </div>
+ <div>
+ <input type="checkbox" id="showUserBackend" class="checkbox" v-model="showUserBackend">
+ <label for="showUserBackend">{{t('settings', 'Show user backend')}}</label>
+ </div>
+ <div>
+ <input type="checkbox" id="showStoragePath" class="checkbox" v-model="showStoragePath">
+ <label for="showStoragePath">{{t('settings', 'Show storage path')}}</label>
+ </div>
+ </AppNavigationSettings>
+ </AppNavigation>
+ <AppContent>
+ <UserList #content :users="users" :showConfig="showConfig" :selectedGroup="selectedGroup" :externalActions="externalActions" />
+ </AppContent>
+ </Content>
+</template>
+
+<script>
+import Vue from 'vue';
+import VueLocalStorage from 'vue-localstorage'
+import {
+ AppContent,
+ AppNavigation,
+ AppNavigationItem,
+ AppNavigationNew,
+ AppNavigationSettings,
+ AppSidebar,
+ Content,
+ Multiselect
+} from 'nextcloud-vue';
+import UserList from '../components/userList';
+import api from '../store/api';
+
+Vue.use(VueLocalStorage)
+
+export default {
+ name: 'Users',
+ props: ['selectedGroup'],
+ components: {
+ AppContent,
+ AppNavigation,
+ AppNavigationItem,
+ AppNavigationNew,
+ AppNavigationSettings,
+ AppSidebar,
+ Content,
+ UserList,
+ Multiselect,
+ },
+ beforeMount() {
+ this.$store.commit('initGroups', {
+ groups: this.$store.getters.getServerData.groups,
+ orderBy: this.$store.getters.getServerData.sortGroups,
+ userCount: this.$store.getters.getServerData.userCount
+ });
+ this.$store.dispatch('getPasswordPolicyMinLength');
+ },
+ created() {
+ // init the OCA.Settings.UserList object
+ // and add the registerAction method
+ Object.assign(OCA, {
+ Settings: {
+ UserList: {
+ registerAction: this.registerAction
+ }
+ }
+ });
+ },
+ data() {
+ return {
+ // default quota is set to unlimited
+ unlimitedQuota: {id: 'none', label: t('settings', 'Unlimited')},
+ // temporary value used for multiselect change
+ selectedQuota: false,
+ externalActions: [],
+ showAddGroupEntry: false,
+ loadingAddGroup: false,
+ showConfig: {
+ showStoragePath: false,
+ showUserBackend: false,
+ showLastLogin: false,
+ showNewUserForm: false,
+ showLanguages: false
+ }
+ }
+ },
+ methods: {
+ toggleNewUserMenu() {
+ this.showConfig.showNewUserForm = !this.showConfig.showNewUserForm;
+ if (this.showConfig.showNewUserForm) {
+ Vue.nextTick(() => {
+ window.newusername.focus();
+ });
+ }
+ },
+ getLocalstorage(key) {
+ // force initialization
+ let localConfig = this.$localStorage.get(key);
+ // if localstorage is null, fallback to original values
+ this.showConfig[key] = localConfig !== null ? localConfig === 'true' : this.showConfig[key];
+ return this.showConfig[key];
+ },
+ setLocalStorage(key, status) {
+ this.showConfig[key] = status;
+ this.$localStorage.set(key, status);
+ return status;
+ },
+ removeGroup(groupid) {
+ let self = this;
+ // TODO migrate to a vue js confirm dialog component
+ OC.dialogs.confirm(
+ t('settings', 'You are about to remove the group {group}. The users will NOT be deleted.', {group: groupid}),
+ t('settings','Please confirm the group removal '),
+ function (success) {
+ if (success) {
+ self.$store.dispatch('removeGroup', groupid);
+ }
+ }
+ );
+ },
+
+ /**
+ * Dispatch default quota set request
+ *
+ * @param {string|Object} quota Quota in readable format '5 GB' or Object {id: '5 GB', label: '5GB'}
+ * @returns {string}
+ */
+ setDefaultQuota(quota = 'none') {
+ this.$store.dispatch('setAppConfig', {
+ app: 'files',
+ key: 'default_quota',
+ // ensure we only send the preset id
+ value: quota.id ? quota.id : quota
+ }).then(() => {
+ if (typeof quota !== 'object') {
+ quota = {id: quota, label: quota};
+ }
+ this.defaultQuota = 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) {
+ return this.setDefaultQuota('none');
+ } else {
+ // unify format output
+ return this.setDefaultQuota(OC.Util.humanFileSize(OC.Util.computerFileSize(quota)));
+ }
+ // if no valid do not change
+ return false;
+ },
+
+ /**
+ * Register a new action for the user menu
+ *
+ * @param {string} icon the icon class
+ * @param {string} text the text to display
+ * @param {function} action the function to run
+ */
+ registerAction(icon, text, action) {
+ this.externalActions.push({
+ icon: icon,
+ text: text,
+ action: action
+ });
+ return this.externalActions;
+ },
+
+ /**
+ * Create a new group
+ *
+ * @param {Object} event The form submit event
+ */
+ createGroup(event) {
+ let gid = event.target[0].value;
+ this.loadingAddGroup = true;
+ this.$store.dispatch('addGroup', gid)
+ .then(() => {
+ this.showAddGroupEntry = false;
+ this.loadingAddGroup = false;
+ this.$router.push({
+ name: 'group',
+ params: {
+ selectedGroup: gid
+ }
+ })
+ })
+ .catch(() => {
+ this.loadingAddGroup = false;
+ });
+ }
+ },
+ computed: {
+ users() {
+ return this.$store.getters.getUsers;
+ },
+ usersOffset() {
+ return this.$store.getters.getUsersOffset;
+ },
+ usersLimit() {
+ return this.$store.getters.getUsersLimit;
+ },
+
+ // Local settings
+ showLanguages: {
+ get: function() {return this.getLocalstorage('showLanguages')},
+ set: function(status) {
+ this.setLocalStorage('showLanguages', status);
+ }
+ },
+ showLastLogin: {
+ get: function() {return this.getLocalstorage('showLastLogin')},
+ set: function(status) {
+ this.setLocalStorage('showLastLogin', status);
+ }
+ },
+ showUserBackend: {
+ get: function() {return this.getLocalstorage('showUserBackend')},
+ set: function(status) {
+ this.setLocalStorage('showUserBackend', status);
+ }
+ },
+ showStoragePath: {
+ get: function() {return this.getLocalstorage('showStoragePath')},
+ set: function(status) {
+ this.setLocalStorage('showStoragePath', status);
+ }
+ },
+
+ userCount() {
+ return this.$store.getters.getUserCount;
+ },
+ settings() {
+ return this.$store.getters.getServerData;
+ },
+
+ // default quota
+ 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);
+ return quotaPreset;
+ },
+ // mapping saved values to objects
+ defaultQuota: {
+ get: function() {
+ if (this.selectedQuota !== false) {
+ return this.selectedQuota;
+ }
+ if (this.settings.defaultQuota !== this.unlimitedQuota.id && OC.Util.computerFileSize(this.settings.defaultQuota) >= 0) {
+ // if value is valid, let's map the quotaOptions or return custom quota
+ return {id:this.settings.defaultQuota, label:this.settings.defaultQuota};
+ }
+ return this.unlimitedQuota; // unlimited
+ },
+ set: function(quota) {
+ this.selectedQuota = quota;
+ }
+
+ },
+
+ // BUILD APP NAVIGATION MENU OBJECT
+ menu() {
+ // Data provided php side
+ let self = this;
+ let groups = this.$store.getters.getGroups;
+ groups = Array.isArray(groups) ? groups : [];
+
+ // Map groups
+ groups = groups.map(group => {
+ let item = {};
+ item.id = group.id.replace(' ', '_');
+ item.key = item.id;
+ item.utils = {}
+
+ // router link to
+ item.router = {
+ name: 'group',
+ params: {selectedGroup: group.id}
+ };
+
+ // group name
+ item.text = group.name;
+ item.title = group.name;
+
+ // users count for all groups
+ if (group.usercount - group.disabled > 0 || group.usercount === -1) {
+ item.utils.counter = group.usercount - group.disabled;
+ }
+
+ if (item.id !== 'admin' && item.id !== 'disabled' && this.settings.isAdmin) {
+ // add delete button on real groups
+ item.utils.actions = [{
+ icon: 'icon-delete',
+ text: t('settings', 'Remove group'),
+ action: function() {
+ self.removeGroup(group.id)
+ }
+ }];
+ };
+ return item;
+ });
+
+ // Every item is added on top of the array, so we're going backward
+ // Groups, separator, disabled, admin, everyone
+
+ // Add separator
+ let realGroups = groups.find((group) => {return group.id !== 'disabled' && group.id !== 'admin'});
+ realGroups = typeof realGroups === 'undefined' ? [] : realGroups;
+ realGroups = Array.isArray(realGroups) ? realGroups : [realGroups];
+ if (realGroups.length > 0) {
+ let separator = {
+ caption: true,
+ text: t('settings', 'Groups')
+ };
+ groups.unshift(separator);
+ }
+
+ // Adjust admin and disabled groups
+ let adminGroup = groups.find(group => group.id == 'admin');
+ let disabledGroup = groups.find(group => group.id == 'disabled');
+
+ // filter out admin and disabled
+ groups = groups.filter(group => ['admin', 'disabled'].indexOf(group.id) === -1);
+
+ if (adminGroup && adminGroup.text) {
+ adminGroup.text = t('settings', 'Admins'); // rename admin group
+ adminGroup.icon = 'icon-user-admin'; // set icon
+ groups.unshift(adminGroup); // add admin group if present
+ }
+ if (disabledGroup && disabledGroup.text) {
+ disabledGroup.text = t('settings', 'Disabled users'); // rename disabled group
+ disabledGroup.icon = 'icon-disabled-users'; // set icon
+ if (disabledGroup.utils && (
+ disabledGroup.utils.counter > 0 // add disabled if not empty
+ || disabledGroup.utils.counter === -1) // add disabled if ldap enabled
+ ) {
+ groups.unshift(disabledGroup);
+ }
+ }
+
+
+ // Add everyone group
+ let everyoneGroup = {
+ id: 'everyone',
+ key: 'everyone',
+ icon: 'icon-contacts-dark',
+ router: {name:'users'},
+ text: t('settings', 'Everyone'),
+ };
+ // users count
+ if (this.userCount > 0) {
+ Vue.set(everyoneGroup, 'utils', {
+ counter: this.userCount
+ });
+ }
+ groups.unshift(everyoneGroup);
+
+ let addGroup = {
+ id: 'addgroup',
+ key: 'addgroup',
+ icon: 'icon-add',
+ text: t('settings', 'Add group'),
+ classes: this.loadingAddGroup ? 'icon-loading-small' : ''
+ };
+ if (this.showAddGroupEntry) {
+ Vue.set(addGroup, 'edit', {
+ text: t('settings', 'Add group'),
+ action: this.createGroup,
+ reset: function() {
+ self.showAddGroupEntry = false
+ }
+ });
+ addGroup.classes = 'editing';
+ } else {
+ Vue.set(addGroup, 'action', function() {
+ self.showAddGroupEntry = true
+ // focus input
+ Vue.nextTick(() => {
+ window.addgroup.querySelector('form > input[type="text"]').focus()
+ })
+ })
+ }
+ groups.unshift(addGroup);
+
+ return groups;
+ },
+ }
+}
+</script>