mirror of https://github.com/nextcloud/server.git
Comply to eslint
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>pull/17263/head
parent
7fb6512351
commit
b9bc2417e7
@ -1,16 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true
|
||||
},
|
||||
extends: 'eslint:recommended',
|
||||
parserOptions: {
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
indent: ['error', 'tab'],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'always']
|
||||
}
|
||||
};
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div :class="{preview: true}">
|
||||
<div class="preview-image" :style="{backgroundImage: 'url(' + preview.img + ')'}" />
|
||||
<div class="preview-description">
|
||||
<h3>{{ preview.title }}</h3>
|
||||
<p>{{ preview.text }}</p>
|
||||
<input :id="'accessibility-' + preview.id"
|
||||
v-model="checked"
|
||||
type="checkbox"
|
||||
class="checkbox">
|
||||
<label :for="'accessibility-' + preview.id">{{ t('accessibility', 'Enable') }} {{ preview.title.toLowerCase() }}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ItemPreview',
|
||||
props: {
|
||||
preview: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selected: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
checked: {
|
||||
get() {
|
||||
return this.selected === this.preview.id
|
||||
},
|
||||
set(checked) {
|
||||
this.$emit('select', checked ? this.preview.id : false, this.selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -1,28 +0,0 @@
|
||||
<template>
|
||||
<div :class="{preview: true}">
|
||||
<div class="preview-image" :style="{backgroundImage: 'url(' + preview.img + ')'}"></div>
|
||||
<div class="preview-description">
|
||||
<h3>{{preview.title}}</h3>
|
||||
<p>{{preview.text}}</p>
|
||||
<input type="checkbox" class="checkbox" :id="'accessibility-' + preview.id" v-model="checked" />
|
||||
<label :for="'accessibility-' + preview.id">{{t('accessibility', 'Enable')}} {{preview.title.toLowerCase()}}</label>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'itemPreview',
|
||||
props: ['preview', 'selected'],
|
||||
computed: {
|
||||
checked: {
|
||||
get() {
|
||||
return this.selected === this.preview.id;
|
||||
},
|
||||
set(checked) {
|
||||
this.$emit('select', checked ? this.preview.id : false, this.selected);
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
</script>
|
@ -1,12 +1,12 @@
|
||||
import Vue from 'vue';
|
||||
import App from './App.vue';
|
||||
import Vue from 'vue'
|
||||
import App from './Accessibility.vue'
|
||||
|
||||
/* global t */
|
||||
// bind to window
|
||||
Vue.prototype.OC = OC;
|
||||
Vue.prototype.t = t;
|
||||
Vue.prototype.OC = OC
|
||||
Vue.prototype.t = t
|
||||
|
||||
new Vue({
|
||||
export default new Vue({
|
||||
el: '#accessibility',
|
||||
render: h => h(App)
|
||||
});
|
||||
})
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because it is too large
Load Diff
@ -1,22 +0,0 @@
|
||||
module.exports = {
|
||||
env: {
|
||||
browser: true,
|
||||
es6: true
|
||||
},
|
||||
globals: {
|
||||
t: true,
|
||||
n: true,
|
||||
OC: true,
|
||||
OCA: true
|
||||
},
|
||||
extends: 'eslint:recommended',
|
||||
parserOptions: {
|
||||
sourceType: 'module'
|
||||
},
|
||||
rules: {
|
||||
indent: ['error', 'tab'],
|
||||
'linebreak-style': ['error', 'unix'],
|
||||
quotes: ['error', 'single'],
|
||||
semi: ['error', 'always']
|
||||
}
|
||||
};
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,2 +1,2 @@
|
||||
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=0)}([function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n()}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}]);
|
||||
!function(e){var n={};function t(r){if(n[r])return n[r].exports;var o=n[r]={i:r,l:!1,exports:{}};return e[r].call(o.exports,o,o.exports,t),o.l=!0,o.exports}t.m=e,t.c=n,t.d=function(e,n,r){t.o(e,n)||Object.defineProperty(e,n,{enumerable:!0,get:r})},t.r=function(e){"undefined"!=typeof Symbol&&Symbol.toStringTag&&Object.defineProperty(e,Symbol.toStringTag,{value:"Module"}),Object.defineProperty(e,"__esModule",{value:!0})},t.t=function(e,n){if(1&n&&(e=t(e)),8&n)return e;if(4&n&&"object"==typeof e&&e&&e.__esModule)return e;var r=Object.create(null);if(t.r(r),Object.defineProperty(r,"default",{enumerable:!0,value:e}),2&n&&"string"!=typeof e)for(var o in e)t.d(r,o,function(n){return e[n]}.bind(null,o));return r},t.n=function(e){var n=e&&e.__esModule?function(){return e.default}:function(){return e};return t.d(n,"a",n),n},t.o=function(e,n){return Object.prototype.hasOwnProperty.call(e,n)},t.p="/js/",t(t.s=0)}([function(e,n,r){r.p=OC.linkTo("files_sharing","js/dist/"),r.nc=btoa(OC.requestToken),window.OCP.Collaboration.registerType("file",{action:function(){return new Promise((function(e,n){OC.dialogs.filepicker(t("files_sharing","Link to a file"),(function(t){OC.Files.getClient().getFileInfo(t).then((function(n,t){e(t.id)})).fail((function(){n(new Error("Cannot get fileinfo"))}))}),!1,null,!1,OC.dialogs.FILEPICKER_TYPE_CHOOSE,"",{allowDirectoryChooser:!0})}))},typeString:t("files_sharing","Link to a file"),typeIconClass:"icon-files-dark"})}]);
|
||||
//# sourceMappingURL=collaboration.js.map
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,19 +1,21 @@
|
||||
__webpack_public_path__ = OC.linkTo('files_sharing', 'js/dist/');
|
||||
__webpack_nonce__ = btoa(OC.requestToken);
|
||||
// eslint-disable-next-line camelcase
|
||||
__webpack_public_path__ = OC.linkTo('files_sharing', 'js/dist/')
|
||||
// eslint-disable-next-line camelcase
|
||||
__webpack_nonce__ = btoa(OC.requestToken)
|
||||
|
||||
window.OCP.Collaboration.registerType('file', {
|
||||
action: () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
OC.dialogs.filepicker(t('files_sharing', 'Link to a file'), function (f) {
|
||||
const client = OC.Files.getClient();
|
||||
OC.dialogs.filepicker(t('files_sharing', 'Link to a file'), function(f) {
|
||||
const client = OC.Files.getClient()
|
||||
client.getFileInfo(f).then((status, fileInfo) => {
|
||||
resolve(fileInfo.id);
|
||||
resolve(fileInfo.id)
|
||||
}).fail(() => {
|
||||
reject();
|
||||
});
|
||||
}, false, null, false, OC.dialogs.FILEPICKER_TYPE_CHOOSE, '', { allowDirectoryChooser: true });
|
||||
});
|
||||
reject(new Error('Cannot get fileinfo'))
|
||||
})
|
||||
}, false, null, false, OC.dialogs.FILEPICKER_TYPE_CHOOSE, '', { allowDirectoryChooser: true })
|
||||
})
|
||||
},
|
||||
typeString: t('files_sharing', 'Link to a file'),
|
||||
typeIconClass: 'icon-files-dark'
|
||||
});
|
||||
})
|
||||
|
@ -1,5 +1,7 @@
|
||||
__webpack_nonce__ = btoa(OC.requestToken);
|
||||
__webpack_public_path__ = OC.linkTo('files_sharing', 'js/dist/');
|
||||
import '../js/app'
|
||||
import '../js/sharedfilelist'
|
||||
|
||||
import '../js/app';
|
||||
import '../js/sharedfilelist';
|
||||
// eslint-disable-next-line camelcase
|
||||
__webpack_nonce__ = btoa(OC.requestToken)
|
||||
// eslint-disable-next-line camelcase
|
||||
__webpack_public_path__ = OC.linkTo('files_sharing', 'js/dist/')
|
||||
|
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -0,0 +1,326 @@
|
||||
<!--
|
||||
- @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" />
|
||||
<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" /></filter></defs>
|
||||
<image x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
:filter="filterUrl"
|
||||
:xlink:href="app.preview"
|
||||
class="app-icon" />
|
||||
</svg>
|
||||
{{ app.name }}
|
||||
</h2>
|
||||
<img v-if="app.screenshot" :src="app.screenshot" width="100%">
|
||||
<div v-if="app.level === 300 || app.level === 200 || hasRating" class="app-level">
|
||||
<span v-if="app.level === 300"
|
||||
v-tooltip.auto="t('settings', 'This app is supported via your current Nextcloud subscription.')"
|
||||
class="supported icon-checkmark-color">
|
||||
{{ t('settings', 'Supported') }}</span>
|
||||
<span 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.')"
|
||||
class="official icon-checkmark">
|
||||
{{ t('settings', 'Official') }}</span>
|
||||
<AppScore v-if="hasRating" :score="app.appstoreData.ratingOverall" />
|
||||
</div>
|
||||
|
||||
<div v-if="author" class="app-author">
|
||||
{{ t('settings', 'by') }}
|
||||
<span v-for="(a, index) in author" :key="index">
|
||||
<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 v-if="licence" class="app-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})"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click="update(app.id)">
|
||||
<input v-if="app.canUnInstall"
|
||||
class="uninstall"
|
||||
type="button"
|
||||
:value="t('settings', 'Remove')"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click="remove(app.id)">
|
||||
<input v-if="app.active"
|
||||
class="enable"
|
||||
type="button"
|
||||
:value="t('settings','Disable')"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click="disable(app.id)">
|
||||
<input v-if="!app.active && (app.canInstall || app.isCompatible)"
|
||||
v-tooltip.auto="enableButtonTooltip"
|
||||
class="enable primary"
|
||||
type="button"
|
||||
:value="enableButtonText"
|
||||
:disabled="!app.canInstall || installing || loading(app.id)"
|
||||
@click="enable(app.id)">
|
||||
<input v-else-if="!app.active"
|
||||
v-tooltip.auto="forceEnableButtonTooltip"
|
||||
class="enable force"
|
||||
type="button"
|
||||
:value="forceEnableButtonText"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click="forceEnable(app.id)">
|
||||
</div>
|
||||
<div class="app-groups">
|
||||
<div v-if="app.active && canLimitToGroups(app)" class="groups-enable">
|
||||
<input :id="prefix('groups_enable', app.id)"
|
||||
v-model="groupCheckedAppsData"
|
||||
type="checkbox"
|
||||
:value="app.id"
|
||||
class="groups-enable__checkbox checkbox"
|
||||
@change="setGroupLimit">
|
||||
<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"
|
||||
: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"
|
||||
@select="addGroupLimitation"
|
||||
@remove="removeGroupLimitation"
|
||||
@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, index) in app.missingDependencies" :key="index">
|
||||
{{ dep }}
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<p class="documentation">
|
||||
<a v-if="!app.internal"
|
||||
class="appslink"
|
||||
:href="appstoreUrl"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">{{ t('settings', 'View in store') }} ↗</a>
|
||||
|
||||
<a v-if="app.website"
|
||||
class="appslink"
|
||||
:href="app.website"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">{{ t('settings', 'Visit website') }} ↗</a>
|
||||
<a v-if="app.bugs"
|
||||
class="appslink"
|
||||
:href="app.bugs"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">{{ t('settings', 'Report a bug') }} ↗</a>
|
||||
|
||||
<a v-if="app.documentation && app.documentation.user"
|
||||
class="appslink"
|
||||
:href="app.documentation.user"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">{{ t('settings', 'User documentation') }} ↗</a>
|
||||
<a v-if="app.documentation && app.documentation.admin"
|
||||
class="appslink"
|
||||
:href="app.documentation.admin"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">{{ t('settings', 'Admin documentation') }} ↗</a>
|
||||
<a v-if="app.documentation && app.documentation.developer"
|
||||
class="appslink"
|
||||
:href="app.documentation.developer"
|
||||
target="_blank"
|
||||
rel="noreferrer noopener">{{ t('settings', 'Developer documentation') }} ↗</a>
|
||||
</p>
|
||||
|
||||
<div class="app-description" v-html="renderMarkdown" />
|
||||
</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 PrefixMixin from './PrefixMixin'
|
||||
import SvgFilterMixin from './SvgFilterMixin'
|
||||
|
||||
export default {
|
||||
name: 'AppDetails',
|
||||
components: {
|
||||
Multiselect,
|
||||
AppScore
|
||||
},
|
||||
mixins: [AppManagement, PrefixMixin, SvgFilterMixin],
|
||||
props: ['category', 'app'],
|
||||
data() {
|
||||
return {
|
||||
groupCheckedAppsData: false
|
||||
}
|
||||
},
|
||||
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'
|
||||
]
|
||||
}
|
||||
)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.app.groups.length > 0) {
|
||||
this.groupCheckedAppsData = true
|
||||
}
|
||||
}
|
||||
}
|
||||
</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>
|
@ -0,0 +1,201 @@
|
||||
<!--
|
||||
- @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">
|
||||
<AppItem v-for="app in apps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
:category="category" />
|
||||
</transition-group>
|
||||
</template>
|
||||
<transition-group v-if="useBundleView"
|
||||
name="app-list"
|
||||
tag="div"
|
||||
class="apps-list-container">
|
||||
<template v-for="bundle in bundles">
|
||||
<div :key="bundle.id" class="apps-header">
|
||||
<div class="app-image" />
|
||||
<h2>{{ bundle.name }} <input type="button" :value="bundleToggleText(bundle.id)" @click="toggleBundle(bundle.id)"></h2>
|
||||
<div class="app-version" />
|
||||
<div class="app-level" />
|
||||
<div class="app-groups" />
|
||||
<div class="actions">
|
||||
|
||||
</div>
|
||||
</div>
|
||||
<AppItem v-for="app in bundleApps(bundle.id)"
|
||||
:key="bundle.id + app.id"
|
||||
:app="app"
|
||||
:category="category" />
|
||||
</template>
|
||||
</transition-group>
|
||||
<template v-if="useAppStoreView">
|
||||
<AppItem 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 />
|
||||
<td colspan="5">
|
||||
<h2>{{ t('settings', 'Results from other categories') }}</h2>
|
||||
</td>
|
||||
</div>
|
||||
<AppItem v-for="app in searchApps"
|
||||
:key="app.id"
|
||||
:app="app"
|
||||
:category="category"
|
||||
:list-view="true" />
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="search !== '' && !loading && searchApps.length === 0 && apps.length === 0" id="apps-list-empty" class="emptycontent emptycontent-search">
|
||||
<div id="app-list-empty-icon" class="icon-settings-dark" />
|
||||
<h2>{{ t('settings', 'No apps found for your version') }}</h2>
|
||||
</div>
|
||||
|
||||
<div id="searchresults" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppItem from './AppList/AppItem'
|
||||
import PrefixMixin from './PrefixMixin'
|
||||
|
||||
export default {
|
||||
name: 'AppList',
|
||||
components: {
|
||||
AppItem
|
||||
},
|
||||
mixins: [PrefixMixin],
|
||||
props: ['category', 'app', 'search'],
|
||||
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.filter(bundle => this.bundleApps(bundle.id).length > 0)
|
||||
},
|
||||
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.error(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>
|
@ -0,0 +1,179 @@
|
||||
<!--
|
||||
- @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" :class="{ selected: isSelected }" @click="showAppDetails">
|
||||
<div class="app-image app-image-icon" @click="showAppDetails">
|
||||
<div v-if="(listView && !app.preview) || (!listView && !app.screenshot)" class="icon-settings-dark" />
|
||||
|
||||
<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" /></filter></defs>
|
||||
<image x="0"
|
||||
y="0"
|
||||
width="32"
|
||||
height="32"
|
||||
preserveAspectRatio="xMinYMin meet"
|
||||
:filter="filterUrl"
|
||||
:xlink:href="app.preview"
|
||||
class="app-icon" />
|
||||
</svg>
|
||||
|
||||
<img v-if="!listView && app.screenshot" :src="app.screenshot" width="100%">
|
||||
</div>
|
||||
<div class="app-name" @click="showAppDetails">
|
||||
{{ app.name }}
|
||||
</div>
|
||||
<div v-if="!listView" class="app-summary">
|
||||
{{ app.summary }}
|
||||
</div>
|
||||
<div v-if="listView" class="app-version">
|
||||
<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 v-if="app.level === 300"
|
||||
v-tooltip.auto="t('settings', 'This app is supported via your current Nextcloud subscription.')"
|
||||
class="supported icon-checkmark-color">
|
||||
{{ t('settings', 'Supported') }}</span>
|
||||
<span 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.')"
|
||||
class="official icon-checkmark">
|
||||
{{ t('settings', 'Official') }}</span>
|
||||
<AppScore v-if="hasRating && !listView" :score="app.score" />
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<div v-if="app.error" class="warning">
|
||||
{{ app.error }}
|
||||
</div>
|
||||
<div v-if="loading(app.id)" class="icon icon-loading-small" />
|
||||
<input v-if="app.update"
|
||||
class="update primary"
|
||||
type="button"
|
||||
:value="t('settings', 'Update to {update}', {update:app.update})"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click.stop="update(app.id)">
|
||||
<input v-if="app.canUnInstall"
|
||||
class="uninstall"
|
||||
type="button"
|
||||
:value="t('settings', 'Remove')"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click.stop="remove(app.id)">
|
||||
<input v-if="app.active"
|
||||
class="enable"
|
||||
type="button"
|
||||
:value="t('settings','Disable')"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click.stop="disable(app.id)">
|
||||
<input v-if="!app.active && (app.canInstall || app.isCompatible)"
|
||||
v-tooltip.auto="enableButtonTooltip"
|
||||
class="enable"
|
||||
type="button"
|
||||
:value="enableButtonText"
|
||||
:disabled="!app.canInstall || installing || loading(app.id)"
|
||||
@click.stop="enable(app.id)">
|
||||
<input v-else-if="!app.active"
|
||||
v-tooltip.auto="forceEnableButtonTooltip"
|
||||
class="enable force"
|
||||
type="button"
|
||||
:value="forceEnableButtonText"
|
||||
:disabled="installing || loading(app.id)"
|
||||
@click.stop="forceEnable(app.id)">
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import AppScore from './AppScore'
|
||||
import AppManagement from '../AppManagement'
|
||||
import SvgFilterMixin from '../SvgFilterMixin'
|
||||
|
||||
export default {
|
||||
name: 'AppItem',
|
||||
components: {
|
||||
AppScore
|
||||
},
|
||||
mixins: [AppManagement, SvgFilterMixin],
|
||||
props: {
|
||||
app: {},
|
||||
category: {},
|
||||
listView: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
isSelected: false,
|
||||
scrolled: false
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
hasRating() {
|
||||
return this.app.appstoreData && this.app.appstoreData.ratingNumOverall > 5
|
||||
}
|
||||
},
|
||||
watch: {
|
||||
'$route.params.id': function(id) {
|
||||
this.isSelected = (this.app.id === id)
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.isSelected = (this.app.id === this.$route.params.id)
|
||||
},
|
||||
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>
|
@ -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>
|
@ -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 {
|
||||
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
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
if (this.app.groups.length > 0) {
|
||||
this.groupCheckedAppsData = true
|
||||
}
|
||||
},
|
||||
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>
|
@ -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>
|
@ -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',
|
||||
data() {
|
||||
return {
|
||||
filterId: ''
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
filterUrl() {
|
||||
return `url(#${this.filterId})`
|
||||
}
|
||||
},
|
||||
mounted() {
|
||||
this.filterId = 'invertIconApps' + Math.floor((Math.random() * 100)) + new Date().getSeconds() + new Date().getMilliseconds()
|
||||
}
|
||||
}
|
||||
</script>
|
@ -0,0 +1,553 @@
|
||||
<!--
|
||||
- @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" @scroll.passive="onScroll">
|
||||
<div id="grid-header" class="row" :class="{'sticky': scrolled && !showConfig.showNewUserForm}">
|
||||
<div id="headerAvatar" class="avatar" />
|
||||
<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 v-if="subAdminsGroups.length>0 && settings.isAdmin"
|
||||
id="headerSubAdmins"
|
||||
class="subadmins">
|
||||
{{ t('settings', 'Group admin for') }}
|
||||
</div>
|
||||
<div id="headerQuota" class="quota">
|
||||
{{ t('settings', 'Quota') }}
|
||||
</div>
|
||||
<div v-if="showConfig.showLanguages"
|
||||
id="headerLanguages"
|
||||
class="languages">
|
||||
{{ t('settings', 'Language') }}
|
||||
</div>
|
||||
<div v-if="showConfig.showStoragePath"
|
||||
class="headerStorageLocation storageLocation">
|
||||
{{ t('settings', 'Storage location') }}
|
||||
</div>
|
||||
<div v-if="showConfig.showUserBackend"
|
||||
class="headerUserBackend userBackend">
|
||||
{{ t('settings', 'User backend') }}
|
||||
</div>
|
||||
<div v-if="showConfig.showLastLogin"
|
||||
class="headerLastLogin lastLogin">
|
||||
{{ t('settings', 'Last login') }}
|
||||
</div>
|
||||
<div class="userActions" />
|
||||
</div>
|
||||
|
||||
<form v-show="showConfig.showNewUserForm"
|
||||
id="new-user"
|
||||
class="row"
|
||||
:disabled="loading.all"
|
||||
:class="{'sticky': scrolled && showConfig.showNewUserForm}"
|
||||
@submit.prevent="createUser">
|
||||
<div :class="loading.all?'icon-loading-small':'icon-add'" />
|
||||
<div class="name">
|
||||
<input id="newusername"
|
||||
ref="newusername"
|
||||
v-model="newUser.id"
|
||||
type="text"
|
||||
required
|
||||
:placeholder="settings.newUserGenerateUserID
|
||||
? t('settings', 'Will be autogenerated')
|
||||
: t('settings', 'Username')"
|
||||
name="username"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
pattern="[a-zA-Z0-9 _\.@\-']+"
|
||||
:disabled="settings.newUserGenerateUserID">
|
||||
</div>
|
||||
<div class="displayName">
|
||||
<input id="newdisplayname"
|
||||
v-model="newUser.displayName"
|
||||
type="text"
|
||||
:placeholder="t('settings', 'Display name')"
|
||||
name="displayname"
|
||||
autocomplete="off"
|
||||
autocapitalize="none"
|
||||
autocorrect="off">
|
||||
</div>
|
||||
<div class="password">
|
||||
<input id="newuserpassword"
|
||||
ref="newuserpassword"
|
||||
v-model="newUser.password"
|
||||
type="password"
|
||||
:required="newUser.mailAddress===''"
|
||||
:placeholder="t('settings', 'Password')"
|
||||
name="password"
|
||||
autocomplete="new-password"
|
||||
autocapitalize="none"
|
||||
autocorrect="off"
|
||||
:minlength="minPasswordLength">
|
||||
</div>
|
||||
<div class="mailAddress">
|
||||
<input id="newemail"
|
||||
v-model="newUser.mailAddress"
|
||||
type="email"
|
||||
:required="newUser.password==='' || 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 v-if="!settings.isAdmin"
|
||||
id="newgroups"
|
||||
type="text"
|
||||
:value="newUser.groups"
|
||||
tabindex="-1"
|
||||
: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 v-if="subAdminsGroups.length>0 && settings.isAdmin" class="subadmins">
|
||||
<Multiselect v-model="newUser.subAdminsGroups"
|
||||
:options="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 v-model="newUser.quota"
|
||||
:options="quotaOptions"
|
||||
:placeholder="t('settings', 'Select user quota')"
|
||||
label="label"
|
||||
track-by="id"
|
||||
class="multiselect-vue"
|
||||
:allow-empty="false"
|
||||
:taggable="true"
|
||||
@tag="validateQuota" />
|
||||
</div>
|
||||
<div v-if="showConfig.showLanguages" class="languages">
|
||||
<Multiselect v-model="newUser.language"
|
||||
:options="languages"
|
||||
:placeholder="t('settings', 'Default language')"
|
||||
label="name"
|
||||
track-by="code"
|
||||
class="multiselect-vue"
|
||||
:allow-empty="false"
|
||||
group-values="languages"
|
||||
group-label="label" />
|
||||
</div>
|
||||
<div v-if="showConfig.showStoragePath" class="storageLocation" />
|
||||
<div v-if="showConfig.showUserBackend" class="userBackend" />
|
||||
<div v-if="showConfig.showLastLogin" class="lastLogin" />
|
||||
<div class="userActions">
|
||||
<input id="newsubmit"
|
||||
type="submit"
|
||||
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"
|
||||
:key="key"
|
||||
:user="user"
|
||||
:settings="settings"
|
||||
:show-config="showConfig"
|
||||
:groups="groups"
|
||||
:sub-admins-groups="subAdminsGroups"
|
||||
:quota-options="quotaOptions"
|
||||
:languages="languages"
|
||||
:external-actions="externalActions" />
|
||||
<InfiniteLoading ref="infiniteLoading" @infinite="infiniteHandler">
|
||||
<div slot="spinner">
|
||||
<div class="users-icon-loading icon-loading" />
|
||||
</div>
|
||||
<div slot="no-more">
|
||||
<div class="users-list-end" />
|
||||
</div>
|
||||
<div slot="no-results">
|
||||
<div id="emptycontent">
|
||||
<div class="icon-contacts-dark" />
|
||||
<h2>{{ t('settings', 'No users in here') }}</h2>
|
||||
</div>
|
||||
</div>
|
||||
</InfiniteLoading>
|
||||
</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',
|
||||
components: {
|
||||
userRow,
|
||||
Multiselect,
|
||||
InfiniteLoading
|
||||
},
|
||||
props: {
|
||||
users: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showConfig: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
selectedGroup: {
|
||||
type: String,
|
||||
default: null
|
||||
},
|
||||
externalActions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
unlimitedQuota,
|
||||
defaultQuota,
|
||||
loading: {
|
||||
all: false,
|
||||
groups: false
|
||||
},
|
||||
scrolled: false,
|
||||
searchQuery: '',
|
||||
newUser: Object.assign({}, newUser)
|
||||
}
|
||||
},
|
||||
computed: {
|
||||
settings() {
|
||||
return this.$store.getters.getServerData
|
||||
},
|
||||
filteredUsers() {
|
||||
if (this.selectedGroup === 'disabled') {
|
||||
return this.users.filter(user => user.enabled === false)
|
||||
}
|
||||
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 [
|
||||
{
|
||||
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) {
|
||||
// if selected is the disabled group but it's empty
|
||||
this.redirectIfDisabled()
|
||||
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()
|
||||
}
|
||||
}
|
||||
},
|
||||
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)
|
||||
|
||||
/**
|
||||
* If disabled group but empty, redirect
|
||||
*/
|
||||
this.redirectIfDisabled()
|
||||
},
|
||||
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))
|
||||
this.newUser.quota = { id: quota, label: quota }
|
||||
return this.newUser.quota
|
||||
}
|
||||
// Default is unlimited
|
||||
this.newUser.quota = this.quotaOptions[0]
|
||||
return 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} gid 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]
|
||||
},
|
||||
|
||||
/**
|
||||
* If the selected group is the disabled group but the count is 0
|
||||
* redirect to the all users page.
|
||||
* * we only check for 0 because we don't have the count on ldap
|
||||
* * and we therefore set the usercount to -1 in this specific case
|
||||
*/
|
||||
redirectIfDisabled() {
|
||||
const allGroups = this.$store.getters.getGroups
|
||||
if (this.selectedGroup === 'disabled'
|
||||
&& allGroups.findIndex(group => group.id === 'disabled' && group.usercount === 0) > -1) {
|
||||
// disabled group is empty, redirection to all users
|
||||
this.$router.push({ name: 'users' })
|
||||
this.$refs.infiniteLoading.stateChanger.reset()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
@ -0,0 +1,706 @@
|
||||
<!--
|
||||
- @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 v-if="Object.keys(user).length ===1" class="row" :data-id="user.id">
|
||||
<div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}">
|
||||
<img v-if="!loading.delete && !loading.disable && !loading.wipe"
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
:src="generateAvatar(user.id, 32)"
|
||||
:srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'">
|
||||
</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 v-else
|
||||
class="row"
|
||||
:class="{'disabled': loading.delete || loading.disable}"
|
||||
:data-id="user.id">
|
||||
<div class="avatar" :class="{'icon-loading-small': loading.delete || loading.disable || loading.wipe}">
|
||||
<img v-if="!loading.delete && !loading.disable && !loading.wipe"
|
||||
alt=""
|
||||
width="32"
|
||||
height="32"
|
||||
:src="generateAvatar(user.id, 32)"
|
||||
:srcset="generateAvatar(user.id, 64)+' 2x, '+generateAvatar(user.id, 128)+' 4x'">
|
||||
</div>
|
||||
<!-- dirty hack to ellipsis on two lines -->
|
||||
<div class="name">
|
||||
{{ user.id }}
|
||||
</div>
|
||||
<form class="displayName" :class="{'icon-loading-small': loading.displayName}" @submit.prevent="updateDisplayName">
|
||||
<template v-if="user.backendCapabilities.setDisplayName">
|
||||
<input v-if="user.backendCapabilities.setDisplayName"
|
||||
:id="'displayName'+user.id+rand"
|
||||
ref="displayName"
|
||||
type="text"
|
||||
:disabled="loading.displayName||loading.all"
|
||||
:value="user.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 v-tooltip.auto="t('settings', 'The backend does not support changing the display name')" class="name">
|
||||
{{ user.displayname }}
|
||||
</div>
|
||||
</form>
|
||||
<form v-if="settings.canChangePassword && user.backendCapabilities.setPassword"
|
||||
class="password"
|
||||
:class="{'icon-loading-small': loading.password}"
|
||||
@submit.prevent="updatePassword">
|
||||
<input :id="'password'+user.id+rand"
|
||||
ref="password"
|
||||
type="password"
|
||||
required
|
||||
:disabled="loading.password||loading.all"
|
||||
:minlength="minPasswordLength"
|
||||
value=""
|
||||
:placeholder="t('settings', 'New password')"
|
||||
autocomplete="new-password"
|
||||
autocorrect="off"
|
||||
autocapitalize="off"
|
||||
spellcheck="false">
|
||||
<input type="submit" class="icon-confirm" value="">
|
||||
</form>
|
||||
<div v-else />
|
||||
<form class="mailAddress" :class="{'icon-loading-small': loading.mailAddress}" @submit.prevent="updateEmail">
|
||||
<input :id="'mailAddress'+user.id+rand"
|
||||
ref="mailAddress"
|
||||
type="email"
|
||||
:disabled="loading.mailAddress||loading.all"
|
||||
:value="user.email"
|
||||
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"
|
||||
:close-on-select="false"
|
||||
:tag-width="60"
|
||||
@tag="createGroup"
|
||||
@select="addUserGroup"
|
||||
@remove="removeUserGroup">
|
||||
<span slot="limit" v-tooltip.auto="formatGroupsTitle(userGroups)" class="multiselect__limit">+{{ userGroups.length-2 }}</span>
|
||||
<span slot="noResult">{{ t('settings', 'No results') }}</span>
|
||||
</Multiselect>
|
||||
</div>
|
||||
<div v-if="subAdminsGroups.length>0 && settings.isAdmin" class="subadmins" :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"
|
||||
:close-on-select="false"
|
||||
:tag-width="60"
|
||||
@select="addUserSubAdmin"
|
||||
@remove="removeUserSubAdmin">
|
||||
<span slot="limit" v-tooltip.auto="formatGroupsTitle(userSubAdminsGroups)" class="multiselect__limit">+{{ userSubAdminsGroups.length-2 }}</span>
|
||||
<span slot="noResult">{{ t('settings', 'No results') }}</span>
|
||||
</Multiselect>
|
||||
</div>
|
||||
<div v-tooltip.auto="usedSpace" class="quota" :class="{'icon-loading-small': loading.quota}">
|
||||
<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"
|
||||
:allow-empty="false"
|
||||
:taggable="true"
|
||||
@tag="validateQuota"
|
||||
@input="setUserQuota" />
|
||||
<progress class="quota-user-progress"
|
||||
:class="{'warn':usedQuota>80}"
|
||||
:value="usedQuota"
|
||||
max="100" />
|
||||
</div>
|
||||
<div v-if="showConfig.showLanguages"
|
||||
class="languages"
|
||||
:class="{'icon-loading-small': loading.languages}">
|
||||
<Multiselect :value="userLanguage"
|
||||
:options="languages"
|
||||
:disabled="loading.languages||loading.all"
|
||||
:placeholder="t('settings', 'No language set')"
|
||||
label="name"
|
||||
track-by="code"
|
||||
class="multiselect-vue"
|
||||
:allow-empty="false"
|
||||
group-values="languages"
|
||||
group-label="label"
|
||||
@input="setUserLanguage" />
|
||||
</div>
|
||||
<div v-if="showConfig.showStoragePath" class="storageLocation">
|
||||
{{ user.storageLocation }}
|
||||
</div>
|
||||
<div v-if="showConfig.showUserBackend" class="userBackend">
|
||||
{{ user.backend }}
|
||||
</div>
|
||||
<div v-if="showConfig.showLastLogin" v-tooltip.auto="user.lastLogin>0 ? OC.Util.formatDate(user.lastLogin) : ''" class="lastLogin">
|
||||
{{ user.lastLogin>0 ? OC.Util.relativeModifiedDate(user.lastLogin) : t('settings','Never') }}
|
||||
</div>
|
||||
<div class="userActions">
|
||||
<div v-if="OC.currentUser !== user.id && user.id !== 'admin' && !loading.all" class="toggleUserActions">
|
||||
<div v-click-outside="hideMenu" class="icon-more" @click="toggleMenu" />
|
||||
<div class="popovermenu" :class="{ 'open': openedMenu }">
|
||||
<PopoverMenu :menu="userActions" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="feedback" :style="{opacity: feedbackMessage !== '' ? 1 : 0}">
|
||||
<div class="icon-checkmark" />
|
||||
{{ 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',
|
||||
components: {
|
||||
PopoverMenu,
|
||||
Multiselect
|
||||
},
|
||||
directives: {
|
||||
ClickOutside
|
||||
},
|
||||
props: {
|
||||
user: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
settings: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
groups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
subAdminsGroups: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
quotaOptions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showConfig: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
},
|
||||
languages: {
|
||||
type: Array,
|
||||
required: true
|
||||
},
|
||||
externalActions: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
},
|
||||
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 || { 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
|
||||
}
|
||||
},
|
||||
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;
|
||||
},
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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
|
||||
*/
|
||||
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} gid Group id
|
||||
*/
|
||||
async createGroup(gid) {
|
||||
this.loading = { groups: true, subadmins: true }
|
||||
try {
|
||||
await this.$store.dispatch('addGroup', gid)
|
||||
let userid = this.user.id
|
||||
await this.$store.dispatch('addUserGroup', { userid, gid })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
this.loading = { groups: false, subadmins: false }
|
||||
}
|
||||
return this.$store.getters.getGroups[this.groups.length]
|
||||
},
|
||||
|
||||
/**
|
||||
* Add user to group
|
||||
*
|
||||
* @param {object} group Group object
|
||||
*/
|
||||
async addUserGroup(group) {
|
||||
if (group.canAdd === false) {
|
||||
return false
|
||||
}
|
||||
this.loading.groups = true
|
||||
let userid = this.user.id
|
||||
let gid = group.id
|
||||
try {
|
||||
await this.$store.dispatch('addUserGroup', { userid, gid })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
this.loading.groups = false
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove user from group
|
||||
*
|
||||
* @param {object} group Group object
|
||||
*/
|
||||
async removeUserGroup(group) {
|
||||
if (group.canRemove === false) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.loading.groups = true
|
||||
let userid = this.user.id
|
||||
let gid = group.id
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('removeUserGroup', { userid, gid })
|
||||
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
|
||||
*/
|
||||
async addUserSubAdmin(group) {
|
||||
this.loading.subadmins = true
|
||||
let userid = this.user.id
|
||||
let gid = group.id
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('addUserSubAdmin', { userid, gid })
|
||||
this.loading.subadmins = false
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Remove user from group
|
||||
*
|
||||
* @param {object} group Group object
|
||||
*/
|
||||
async removeUserSubAdmin(group) {
|
||||
this.loading.subadmins = true
|
||||
let userid = this.user.id
|
||||
let gid = group.id
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('removeUserSubAdmin', { userid, gid })
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
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}
|
||||
*/
|
||||
async setUserQuota(quota = 'none') {
|
||||
this.loading.quota = true
|
||||
// ensure we only send the preset id
|
||||
quota = quota.id ? quota.id : quota
|
||||
|
||||
try {
|
||||
await this.$store.dispatch('setUserData', {
|
||||
userid: this.user.id,
|
||||
key: 'quota',
|
||||
value: quota
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
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}
|
||||
*/
|
||||
async setUserLanguage(lang) {
|
||||
this.loading.languages = true
|
||||
// ensure we only send the preset id
|
||||
try {
|
||||
await this.$store.dispatch('setUserData', {
|
||||
userid: this.user.id,
|
||||
key: 'language',
|
||||
value: lang.code
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
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>
|
@ -1,574 +0,0 @@
|
||||
<!--
|
||||
- @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>
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue