update views using skeletons and component helpers

This commit is contained in:
Axolotle 2020-12-16 12:16:43 +01:00
parent 0486865f56
commit 6f028961c0
40 changed files with 1843 additions and 2303 deletions

View file

@ -51,11 +51,9 @@ import { mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate'
import AdressInputSelect from '@/components/AdressInputSelect'
import { formatFormDataValue } from '@/helpers/yunohostArguments'
import { required, domain, dynDomain } from '@/helpers/validators'
export default {
name: 'DomainForm',
@ -139,12 +137,9 @@ export default {
},
created () {
if (this.noStore) return
this.$store.dispatch('FETCH', { uri: 'domains' }).then(() => {
if (this.dynDnsForbiden) {
this.selected = 'domain'
}
})
if (this.dynDnsForbiden) {
this.selected = 'domain'
}
},
mixins: [validationMixin],

View file

@ -5,14 +5,19 @@
@submit.prevent="onSubmit"
>
<template #disclaimer>
<b-alert variant="warning" show>
<p class="alert alert-warning">
{{ $t('good_practices_about_admin_password') }}
</b-alert>
</p>
<slot name="disclaimer" />
<hr>
</template>
<slot name="extra-fields" v-bind="{ v: $v, fields, form }" />
<slot name="extra" v-bind="{ v: $v, fields, form }">
<form-field
v-for="(value, key) in extra.fields" :key="key"
v-bind="value" v-model="$v.form.$model[key]" :validation="$v.form[key]"
/>
</slot>
<!-- ADMIN PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />

View file

@ -65,6 +65,7 @@
"backups_no": "No backup",
"begin": "Begin",
"both": "Both",
"cancel": "Cancel",
"catalog": "Catalog",
"check": "Check",
"close": "Close",
@ -115,6 +116,7 @@
"diagnosis_explanation": "The diagnosis feature will attempt to identify common issues on the different aspects of your server to make sure everything runs smoothly. The diagnosis runs automatically twice a day and an email is sent to the administrator if issues are found. Note that some tests may not be relevant if you do not want to use some specific features (for example XMPP) or may fail if you have a complex setup. In such cases, and if you know what you are doing, it is alright to ignore the corresponding issues or warnings.",
"run_first_diagnosis": "Run initial diagnosis",
"disable": "Disable",
"disabled": "Disabled",
"dns": "DNS",
"domain_add": "Add domain",
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns'>set my DNS correctly</a>.",
@ -124,6 +126,7 @@
"domain_default_desc": "The default domain is the connection domain where users log in.",
"domain_default_longdesc": "This is your default domain.",
"domain_delete_longdesc": "Delete this domain",
"domain_delete_forbidden_desc": "You cannot remove '{domain}' since it's the default domain, you need to choose another domain (or <a href='#/domains/add'>add a new one</a>) and set it as the default domain to be able to remove this one.",
"domain_dns_config": "DNS configuration",
"domain_dns_longdesc": "View DNS configuration",
"domain_name": "Domain name",
@ -132,6 +135,7 @@
"domains": "Domains",
"download": "Download",
"enable": "Enable",
"enabled": "Enabled",
"error": "Error",
"error_modify_something": "You should modify something",
"error_server_unexpected": "Unexpected server error",
@ -149,6 +153,7 @@
"form_errors": {
"alpha": "Value must be alphabetical characters only.",
"alphalownum_": "Value must be lower-case alphanumeric and underscore characters only.",
"between": "Value must be between {min} and {max}.",
"domain": "Invalid domain name: Must be lower-case alphanumeric, dot and dash characters only",
"dynDomain": "Invalid domain name: Must be lower-case alphanumeric and dash characters only",
"email": "Invalid email: must be alphanumeric and <code>_.-</code> characters only (e.g. someone@example.com, s0me-1@example.com)",
@ -370,6 +375,7 @@
"unauthorized": "Unauthorized",
"unignore": "Unignore",
"uninstall": "Uninstall",
"unknown": "Unknown",
"unmaintained": "Unmaintained",
"unmaintained_details": "This app has not been update for quite a while and the previous maintainer has gone away or does not have time to maintain this app. Feel free to check the app repository to provide your help",
"upnp": "UPnP",

View file

@ -4,10 +4,10 @@
<em v-t="'api_error.sorry'" />
<b-alert variant="info" class="mt-4" show>
<div class="alert alert-info mt-4">
<span v-html="$t('api_error.help')" />
<br>{{ $t('api_error.info') }}
</b-alert>
</div>
<h5 v-t="'error'" />
<pre><code>"{{ error.code }}" {{ error.status }}</code></pre>
@ -36,7 +36,3 @@ export default {
// FIXME add redirect if they're no error (if reload or route entered by hand)
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -3,8 +3,8 @@
<b-list-group class="menu-list">
<b-list-group-item
v-for="item in menu"
:key="item.id"
:to="{name: item.routeName}"
:key="item.routeName"
:to="{ name: item.routeName }"
>
<icon :iname="item.icon" class="lg" />
<h2>{{ $t(item.translation) }}</h2>
@ -18,17 +18,17 @@
export default {
name: 'Home',
data: () => {
data () {
return {
menu: [
{ id: 0, routeName: 'user-list', icon: 'users', translation: 'users' },
{ id: 1, routeName: 'domain-list', icon: 'globe', translation: 'domains' },
{ id: 2, routeName: 'app-list', icon: 'cubes', translation: 'applications' },
{ id: 3, routeName: 'update', icon: 'refresh', translation: 'system_update' },
{ id: 4, routeName: 'service-list', icon: 'cog', translation: 'services' },
{ id: 5, routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
{ id: 6, routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' },
{ id: 7, routeName: 'backup', icon: 'archive', translation: 'backup' }
{ routeName: 'user-list', icon: 'users', translation: 'users' },
{ routeName: 'domain-list', icon: 'globe', translation: 'domains' },
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' },
{ routeName: 'update', icon: 'refresh', translation: 'system_update' },
{ routeName: 'service-list', icon: 'cog', translation: 'services' },
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
{ routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' },
{ routeName: 'backup', icon: 'archive', translation: 'backup' }
]
}
}

View file

@ -1,6 +1,6 @@
<template>
<div class="login">
<b-alert v-if="apiError" variant="danger" show>
<b-alert v-if="apiError" variant="danger">
<icon iname="exclamation-triangle" /> {{ $t(apiError) }}
</b-alert>
@ -36,7 +36,7 @@
export default {
name: 'Login',
data: () => {
data () {
return {
disabled: false,
password: '',
@ -46,7 +46,7 @@ export default {
},
methods: {
async login () {
login () {
this.$store.dispatch('LOGIN', this.password).catch(() => {
this.isValid = false
})
@ -66,6 +66,3 @@ export default {
}
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -2,14 +2,16 @@
<div class="post-install">
<!-- START STEP -->
<template v-if="step === 'start'">
<b-alert variant="success" show>
<p class="alert alert-success">
<icon iname="thumbs-up" /> {{ $t('postinstall_intro_1') }}
</b-alert>
<b-alert variant="info" show>
</p>
<p class="alert alert-info">
<span v-t="'postinstall_intro_2'" />
<br>
<span v-html="$t('postinstall_intro_3')" />
</b-alert>
</p>
<b-button size="lg" variant="primary" @click="step = 'domain'">
{{ $t('begin') }}
</b-button>
@ -17,12 +19,9 @@
<!-- DOMAIN SETUP STEP -->
<template v-else-if="step === 'domain'">
<domain-form
:title="$t('postinstall_set_domain')" :submit-text="$t('next')"
no-store @submit="setDomain"
>
<domain-form @submit="setDomain" :title="$t('postinstall_set_domain')" :submit-text="$t('next')">
<template #disclaimer>
<b-alert variant="warning" show v-t="'postinstall_domain'" />
<p class="alert alert-warning" v-t="'postinstall_domain'" />
</template>
</domain-form>
@ -35,7 +34,7 @@
<template v-else-if="step === 'password'">
<password-form :title="$t('postinstall_set_password')" :submit-text="$t('next')" @submit="setPassword">
<template #disclaimer>
<b-alert variant="warning" show v-t="'postinstall_password'" />
<p class="alert alert-warning" v-t="'postinstall_password'" />
</template>
</password-form>
@ -46,9 +45,9 @@
<!-- POST-INSTALL SUCCESS STEP -->
<template v-else-if="step === 'login'">
<b-alert variant="success" show>
<p class="alert alert-success">
<icon iname="thumbs-up" /> {{ $t('installation_complete') }}
</b-alert>
</p>
<login-view />
</template>

View file

@ -1,7 +1,10 @@
<template>
<div class="app-actions">
<div v-if="actions">
<b-alert variant="warning" show class="mb-4">
<view-base
:queries="queries" @queries-response="formatAppActions"
ref="view" skeleton="card-form-skeleton"
>
<template v-if="actions" #default>
<b-alert variant="warning" class="mb-4">
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
</b-alert>
@ -13,9 +16,9 @@
@submit.prevent="performAction(action)" :submit-text="$t('perform')"
>
<template #disclaimer>
<b-alert
<div
v-if="action.formDisclaimer"
show variant="info" v-html="action.formDisclaimer"
class="alert alert-info" v-html="action.formDisclaimer"
/>
<b-card-text v-if="action.description" v-html="action.description" />
</template>
@ -25,13 +28,13 @@
v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]"
/>
</card-form>
</div>
</template>
<!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="actions === null" variant="warning" show>
<b-alert v-else-if="actions === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_no_actions') }}
</b-alert>
</div>
</view-base>
</template>
<script>
@ -41,7 +44,6 @@ import { validationMixin } from 'vuelidate'
import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments'
import { objectToParams } from '@/helpers/commons'
export default {
name: 'AppActions',
@ -51,6 +53,12 @@ export default {
data () {
return {
queries: [
`apps/${this.id}/actions`,
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
],
actions: undefined
}
},
@ -66,18 +74,7 @@ export default {
},
methods: {
fetchData () {
Promise.all([
api.get(`apps/${this.id}/actions`),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
])
]).then((responses) => this.setupForm(responses[0]))
},
setupForm (data) {
formatAppActions (data) {
if (!data.actions) {
this.actions = null
return
@ -102,17 +99,13 @@ export default {
const args = objectToParams(action.form ? formatFormData(action.form) : { wut: undefined })
api.put(`apps/${this.id}/actions/${action.id}`, { args }).then(response => {
this.fetchData()
this.$refs.view.fetchQueries()
}).catch(error => {
action.serverError = error.message
})
}
},
created () {
this.fetchData()
},
mixins: [validationMixin]
}
</script>

View file

@ -1,44 +1,51 @@
<template>
<div class="app-catalog" v-if="apps">
<!-- APP SEARCH -->
<b-input-group>
<b-input-group-prepend is-text>
<icon iname="search" />
</b-input-group-prepend>
<b-form-input
id="search-input" :placeholder="$t('search_for_apps')"
v-model="search" @input="setCategory"
/>
<b-input-group-append>
<b-select v-model="quality" :options="qualityOptions" @change="setCategory" />
</b-input-group-append>
</b-input-group>
<view-search
:items="apps" :filtered-items="filteredApps" items-name="apps"
:queries="queries" @queries-response="formatAppData"
>
<template #top-bar>
<div id="view-top-bar">
<!-- APP SEARCH -->
<b-input-group>
<b-input-group-prepend is-text>
<icon iname="search" />
</b-input-group-prepend>
<b-form-input
id="search-input" :placeholder="$t('search.for', { items: $tc('items.apps', 2) })"
v-model="search" @input="setCategory"
/>
<b-input-group-append>
<b-select v-model="quality" :options="qualityOptions" @change="setCategory" />
</b-input-group-append>
</b-input-group>
<!-- CATEGORY SELECT -->
<b-input-group class="mt-3">
<b-input-group-prepend is-text>
<icon iname="filter" />
</b-input-group-prepend>
<b-select v-model="category" :options="categories" />
<b-input-group-append>
<b-button variant="primary" :disabled="category === null" @click="category = null">
{{ $t('app_show_categories') }}
</b-button>
</b-input-group-append>
</b-input-group>
<!-- CATEGORY SELECT -->
<b-input-group class="mt-3">
<b-input-group-prepend is-text>
<icon iname="filter" />
</b-input-group-prepend>
<b-select v-model="category" :options="categories" />
<b-input-group-append>
<b-button variant="primary" :disabled="category === null" @click="category = null">
{{ $t('app_show_categories') }}
</b-button>
</b-input-group-append>
</b-input-group>
<!-- CATEGORIES SUBTAGS -->
<b-input-group v-if="subtags" class="mt-3 subtags">
<b-input-group-prepend is-text>
Subtags
</b-input-group-prepend>
<b-form-radio-group
id="subtags-radio" name="subtags"
v-model="subtag" :options="subtags"
buttons button-variant="outline-secondary"
/>
<b-select id="subtags-select" v-model="subtag" :options="subtags" />
</b-input-group>
<!-- CATEGORIES SUBTAGS -->
<b-input-group v-if="subtags" class="mt-3 subtags">
<b-input-group-prepend is-text>
Subtags
</b-input-group-prepend>
<b-form-radio-group
id="subtags-radio" name="subtags"
v-model="subtag" :options="subtags"
buttons button-variant="outline-secondary"
/>
<b-select id="subtags-select" v-model="subtag" :options="subtags" />
</b-input-group>
</div>
</template>
<!-- CATEGORIES CARDS -->
<b-card-group v-if="category === null" deck>
@ -56,7 +63,7 @@
</b-card-group>
<!-- APPS CARDS -->
<b-card-group v-else-if="filteredApps.length > 0" deck>
<b-card-group v-else deck>
<b-card no-body v-for="app in filteredApps" :key="app.id">
<b-card-body class="d-flex flex-column">
<b-card-title class="d-flex">
@ -90,7 +97,7 @@
<icon iname="book" /> {{ $t('readme') }}
</b-button>
<b-button v-if="app.isInstallable" :variant="app.color" @click="onAppInstallClick(app)">
<b-button v-if="app.isInstallable" :variant="app.color" @click="onInstallClick(app)">
<icon iname="plus" /> {{ $t('install') }} <icon v-if="app.color === 'danger'" class="ml-1" iname="warning" />
</b-button>
<b-button v-else :variant="app.color" disabled>
@ -100,88 +107,82 @@
</b-card>
</b-card-group>
<!-- NO APPS -->
<b-alert
v-else
variant="warning" show class="mt-4"
>
<icon iname="exclamation-triangle" /> {{ $t('app_not_found') }}
</b-alert>
<!-- INSTALL CUSTOM APP -->
<card-form
:title="$t('custom_app_install')" icon="download"
@submit.prevent="$refs['custom-app-install-modal'].show()" :submit-text="$t('install')"
:validation="$v" class="mt-5"
>
<template #disclaimer>
<b-alert variant="warning" show>
<icon iname="exclamation-triangle" /> {{ $t('confirm_install_custom_app') }}
</b-alert>
<template #bot>
<!-- INSTALL CUSTOM APP -->
<card-form
:title="$t('custom_app_install')" icon="download"
@submit.prevent="onCustomInstallClick" :submit-text="$t('install')"
:validation="$v" class="mt-5"
>
<template #disclaimer>
<div class="alert alert-warning">
<icon iname="exclamation-triangle" /> {{ $t('confirm_install_custom_app') }}
</div>
</template>
<!-- URL -->
<form-field v-bind="customInstall.field" v-model="customInstall.url" :validation="$v.customInstall.url" />
</template>
</card-form>
</card-form>
</template>
<!-- CONFIRM APP INSTALL MODAL -->
<b-modal
id="app-install-modal" centered ref="app-install-modal"
:ok-title="$t('install')" :title="$t('confirm_app_install')"
:header-bg-variant="selectedApp.color"
:header-text-variant="selectedApp.color === 'danger' ? 'light' : 'dark'"
@ok="goToAppInstallForm"
>
{{ $t('confirm_install_app_' + selectedApp.state) }}
</b-modal>
<!-- CONFIRM CUSTOM APP INSTALL MODAL -->
<b-modal
id="custom-app-install-modal" :ref="'custom-app-install-modal'" centered
:ok-title="$t('install')" :title="$t('confirm_app_install')"
header-bg-variant="danger" header-text-variant="light"
@ok="goToCustomAppInstallForm"
>
{{ $t('confirm_install_custom_app') }}
</b-modal>
</div>
<!-- CUSTOM SKELETON -->
<template #skeleton>
<b-card-group deck>
<b-card
v-for="i in 15" :key="i"
no-body style="min-height: 10rem;"
>
<div class="d-flex w-100 mt-auto">
<b-skeleton width="30px" height="30px" class="mr-2 ml-auto" />
<b-skeleton :width="randint(30, 70) + '%'" height="30px" class="mr-auto" />
</div>
<b-skeleton
v-if="randint(0, 1)"
:width="randint(30, 85) + '%'" height="24px" class="mx-auto"
/>
<b-skeleton :width="randint(30, 85) + '%'" height="24px" class="mx-auto mb-auto" />
</b-card>
</b-card-group>
</template>
</view-search>
</template>
<script>
import api from '@/api'
import { validationMixin } from 'vuelidate'
import api from '@/api'
import { required, githubLink } from '@/helpers/validators'
import { randint } from '@/helpers/commons'
export default {
name: 'AppCatalog',
data () {
return {
queries: ['appscatalog?full&with_categories'],
// Data
apps: undefined,
// Filtering options
qualityOptions: [
{ value: 'isHighQuality', text: this.$i18n.t('only_highquality_apps') },
{ value: 'isDecentQuality', text: this.$i18n.t('only_decent_quality_apps') },
{ value: 'isWorking', text: this.$i18n.t('only_working_apps') },
{ value: 'all', text: this.$i18n.t('all_apps') }
],
// Computed/filled from api data
categories: [
{ text: this.$i18n.t('app_choose_category'), value: null },
{ text: this.$i18n.t('all_apps'), value: 'all', icon: 'search' }
// The rest is filled from api data
],
apps: undefined,
// Set by user inputs
search: '',
category: null,
subtag: 'all',
search: '',
quality: 'isDecentQuality',
selectedApp: {
// Set some basic values to avoid modal errors
state: 'lowquality',
color: 'warning'
},
// Custom install form
customInstall: {
field: {
@ -199,12 +200,13 @@ export default {
computed: {
filteredApps () {
if (!this.apps || this.category === null) return
const search = this.search.toLowerCase()
if (this.quality === 'all' && this.category === 'all' && search === '') {
return this.apps
}
return this.apps.filter(app => {
const filtered = this.apps.filter(app => {
// app doesn't match quality filter
if (this.quality !== 'all' && !app[this.quality]) return false
// app doesn't match category filter
@ -220,6 +222,7 @@ export default {
if (app.searchValues.includes(search)) return true
return false
})
return filtered.length ? filtered : null
},
subtags () {
@ -246,33 +249,7 @@ export default {
},
methods: {
fetchData () {
api.get('appscatalog?full&with_categories').then((data) => {
// APPS
const apps = []
for (const key in data.apps) {
const app = data.apps[key]
if (app.state === 'notworking') continue
Object.assign(app, this.getQuality(app))
app.isInstallable = !app.installed || app.manifest.multi_instance
if (app.maintained !== 'request_adoption') {
app.maintained = app.maintained ? 'maintained' : 'orphaned'
}
app.color = this.getColor(app)
app.searchValues = [app.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ')
apps.push(app)
}
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
// CATEGORIES
data.categories.forEach(({ title, id, icon, subtags, description }) => {
this.categories.push({ text: title, value: id, icon, subtags, description })
})
})
},
getQuality (app) {
formatQuality (app) {
const filters = {
isHighQuality: false,
isDecentQuality: false,
@ -297,13 +274,37 @@ export default {
return filters
},
getColor (app) {
formatColor (app) {
if (app.isHighQuality) return 'best'
if (app.isDecentQuality) return 'success'
if (app.isWorking) return 'warning'
return 'danger'
},
formatAppData (data) {
// APPS
const apps = []
for (const key in data.apps) {
const app = data.apps[key]
if (app.state === 'notworking') continue
Object.assign(app, this.formatQuality(app))
app.isInstallable = !app.installed || app.manifest.multi_instance
if (app.maintained !== 'request_adoption') {
app.maintained = app.maintained ? 'maintained' : 'orphaned'
}
app.color = this.formatColor(app)
app.searchValues = [app.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ')
apps.push(app)
}
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
// CATEGORIES
data.categories.forEach(({ title, id, icon, subtags, description }) => {
this.categories.push({ text: title, value: id, icon, subtags, description })
})
},
setCategory () {
// allow search without selecting a category
if (this.category === null) {
@ -311,34 +312,30 @@ export default {
}
},
// INSTALL APP METHODS
onAppInstallClick (app) {
this.selectedApp = app
// INSTALL APP
async onInstallClick (app) {
if (!app.isDecentQuality) {
// Ask for confirmation
this.$refs['app-install-modal'].show()
} else {
this.goToAppInstallForm()
const state = app.color === 'danger' ? 'inprogress' : app.state
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + state))
if (!confirmed) return
}
this.$router.push({ name: 'app-install', params: { id: app.id } })
},
goToAppInstallForm () {
this.$router.push({ name: 'app-install', params: { id: this.selectedApp.id } })
},
// INSTALL CUSTOM APP
async onCustomInstallClick () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_custom_app'))
if (!confirmed) return
// INSTALL CUSTOM APP METHODS
goToCustomAppInstallForm () {
const url = this.customInstall.url
this.$router.push({
name: 'app-install-custom',
params: { id: url.endsWith('/') ? url : url + '/' }
})
}
},
},
created () {
this.fetchData()
randint
},
mixins: [validationMixin]
@ -346,18 +343,37 @@ export default {
</script>
<style lang="scss" scoped>
#search-input {
min-width: 8rem;
#view-top-bar {
margin-bottom: 2rem;
#search-input {
min-width: 8rem;
}
select {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.subtags {
#subtags-radio {
display: none
}
@include media-breakpoint-up(md) {
#subtags-radio {
display: inline-flex;
}
#subtags-select {
display: none;
}
}
}
}
select {
border-top-left-radius: 0;
border-bottom-left-radius: 0;
}
.card-deck {
.card {
border-color: $gray-400;
margin-top: 2rem;
margin-bottom: 2rem;
flex-basis: 90%;
@include media-breakpoint-up(md) {
flex-basis: 50%;
@ -402,29 +418,14 @@ select {
.btn:last-of-type {
border-right: 0;
}
}
.btn-outline-dark {
border-color: $gray-400;
.btn-outline-dark {
border-color: $gray-400;
&:hover {
border-color: $dark;
}
&:hover {
border-color: $dark;
}
}
}
.subtags {
#subtags-radio {
display: none
}
@include media-breakpoint-up(md) {
#subtags-radio {
display: inline-flex;
}
#subtags-select {
display: none;
}
}
}
</style>

View file

@ -1,10 +1,11 @@
<template>
<div class="app-config-panel">
<div v-if="panels">
<b-alert variant="warning" show class="mb-4">
<view-base :queries="queries" @queries-response="formatAppConfig" skeleton="card-form-skeleton">
<template v-if="panels" #default>
<b-alert variant="warning" class="mb-4">
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
</b-alert>
<!-- FIXME Rework with components -->
<b-form id="config-form" @submit.prevent="applyConfig">
<b-card no-body v-for="panel in panels" :key="panel.id">
<b-card-header class="d-flex align-items-center">
@ -35,13 +36,13 @@
</b-collapse>
</b-card>
</b-form>
</div>
</template>
<!-- if no config panel -->
<b-alert v-else-if="panels === null" variant="warning" show>
<b-alert v-else-if="panels === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }}
</b-alert>
</div>
</view-base>
</template>
<script>
@ -54,31 +55,23 @@ export default {
name: 'AppConfigPanel',
props: {
id: {
type: String,
required: true
}
id: { type: String, required: true }
},
data () {
return {
queries: [
`apps/${this.id}/config-panel`,
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
],
panels: undefined
}
},
methods: {
fetchData () {
Promise.all([
api.get(`apps/${this.id}/config-panel`),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
])
]).then((responses) => this.setupForm(responses[0]))
},
setupForm (data) {
formatAppConfig (data) {
if (!data.config_panel || data.config_panel.length === 0) {
this.panels = null
return
@ -121,10 +114,6 @@ export default {
console.log('ERROR', err)
})
}
},
created () {
this.fetchData()
}
}
</script>

View file

@ -1,13 +1,9 @@
<template>
<div class="app-info" v-if="info">
<view-base :queries="queries" @queries-response="formatAppData" ref="view">
<!-- BASIC INFOS -->
<b-card>
<template v-slot:header>
<h2><icon iname="info-circle" /> {{ $t('infos') }} {{ info.label }}</h2>
</template>
<card v-if="infos" :title="`${$t('infos')} — ${infos.label}`" icon="info-circle">
<b-row
v-for="(value, prop) in info" :key="prop"
v-for="(value, prop) in infos" :key="prop"
no-gutters class="row-line"
>
<b-col cols="auto" md="3">
@ -33,14 +29,10 @@
</b-button>
</b-col>
</b-row>
</b-card>
</card>
<!-- OPERATIONS -->
<b-card>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2>
</template>
<card v-if="app" :title="$t('operations')" icon="wrench">
<!-- CHANGE PERMISSIONS LABEL -->
<b-form-group :label="$t('app_manage_label_and_tiles')" label-class="font-weight-bold">
<form-field
@ -98,16 +90,13 @@
<b-input id="input-url" v-model="form.url.path" class="flex-grow-3" />
<b-input-group-append>
<b-button
variant="info" v-t="'save'"
@click="action = 'changeUrl'" v-b-modal.modal
/>
<b-button @click="changeUrl" variant="info" v-t="'save'" />
</b-input-group-append>
</b-input-group>
<b-alert v-else variant="warning" show>
<div v-else class="alert alert-warning">
<icon iname="exclamation" /> {{ $t('app_info_change_url_disabled_tooltip') }}
</b-alert>
</div>
</b-form-group>
<hr>
@ -116,14 +105,9 @@
:label="$t('app_info_default_desc', { domain: app.domain })" label-for="main-domain"
label-class="font-weight-bold" label-cols-md="4"
>
<b-input-group>
<b-button
id="main-domain" variant="success" v-b-modal.modal
@click="action = 'setAsDefaultDomain'"
>
<icon iname="star" /> {{ $t('app_make_default') }}
</b-button>
</b-input-group>
<b-button @click="setAsDefaultDomain" id="main-domain" variant="success">
<icon iname="star" /> {{ $t('app_make_default') }}
</b-button>
</b-form-group>
<hr>
@ -132,56 +116,45 @@
:label="$t('app_info_uninstall_desc')" label-for="uninstall"
label-class="font-weight-bold" label-cols-md="4"
>
<b-input-group>
<b-button
id="uninstall" variant="danger" v-b-modal.modal
@click="action = 'uninstall'"
>
<icon iname="trash-o" /> {{ $t('uninstall') }}
</b-button>
</b-input-group>
<b-button @click="uninstall" id="uninstall" variant="danger">
<icon iname="trash-o" /> {{ $t('uninstall') }}
</b-button>
</b-form-group>
</b-card>
</card>
<!-- EXPERIMENTAL (displayed if experimental feature has been enabled in web-admin options)-->
<b-card v-if="this.$store.getters.experimental">
<template v-slot:header>
<h2><icon iname="flask" /> {{ $t('experimental') }}</h2>
</template>
<card v-if="experimental" :title="$t('experimental')" icon="flask">
<!-- APP ACTIONS -->
<b-form-group label-cols-md="4" :label="$t('app_actions_label')" label-for="actions">
<b-input-group>
<b-button id="actions" variant="warning" :to="{ name: 'app-actions', params: { id } }">
<icon iname="flask" /> {{ $t('app_actions') }}
</b-button>
</b-input-group>
<b-form-group
:label="$t('app_actions_label')" label-for="actions"
label-cols-md="4" label-class="font-weight-bold"
>
<b-button id="actions" variant="warning" :to="{ name: 'app-actions', params: { id } }">
<icon iname="flask" /> {{ $t('app_actions') }}
</b-button>
</b-form-group>
<hr>
<!-- APP CONFIG PANEL -->
<b-form-group label-cols-md="4" :label="$t('app_config_panel_label')" label-for="config">
<b-input-group>
<b-button id="config" variant="warning" :to="{ name: 'app-config-panel', params: { id } }">
<icon iname="flask" /> {{ $t('app_config_panel') }}
</b-button>
</b-input-group>
<b-form-group
:label="$t('app_config_panel_label')" label-for="config"
label-cols-md="4" label-class="font-weight-bold"
>
<b-button id="config" variant="warning" :to="{ name: 'app-config-panel', params: { id } }">
<icon iname="flask" /> {{ $t('app_config_panel') }}
</b-button>
</b-form-group>
</b-card>
</card>
<!-- MODAL -->
<b-modal
v-if="action"
id="modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="actions[action].method" hide-header
>
{{ $t(actions[action].text, actions[action].name ? { name: actions[action].name } : {}) }}
</b-modal>
</div>
<template #skeleton>
<card-info-skeleton :item-count="8" />
<card-form-skeleton />
</template>
</view-base>
</template>
<script>
import { mapGetters } from 'vuex'
import { validationMixin } from 'vuelidate'
import api from '@/api'
@ -193,36 +166,27 @@ export default {
name: 'AppInfo',
props: {
id: {
type: String,
required: true
}
id: { type: String, required: true }
},
data () {
return {
info: undefined,
queries: [
`apps/${this.id}?full`,
{ uri: 'users/permissions?full', storeKey: 'permissions' },
{ uri: 'domains' }
],
infos: undefined,
app: undefined,
form: undefined,
actions: {
changeUrl: { method: this.changeUrl, text: 'confirm_app_change_url' },
setAsDefaultDomain: { method: this.setAsDefaultDomain, text: 'confirm_app_default' },
uninstall: { method: this.uninstall, text: 'confirm_uninstall', name: this.id }
},
action: undefined
form: undefined
}
},
filters: {
readableDate
},
computed: {
domains () {
return this.$store.state.data.domains
},
...mapGetters(['domains', 'experimental']),
allowedGroups () {
if (!this.app) return
return this.app.permissions[0].allowed
}
},
@ -239,67 +203,62 @@ export default {
},
methods: {
fetchData () {
Promise.all([
api.get(`apps/${this.id}?full`),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'users/permissions?full', storeKey: 'permissions' },
{ uri: 'domains' }
])
]).then(([app]) => {
const form = { labels: [] }
formatAppData (app) {
const form = { labels: [] }
const mainPermission = app.permissions[this.id + '.main']
mainPermission.name = this.id + '.main'
mainPermission.title = this.$i18n.t('permission_main')
mainPermission.tileAvailable = mainPermission.url !== null && !mainPermission.url.startsWith('re:')
form.labels.push({ label: mainPermission.label, show_tile: mainPermission.show_tile })
const mainPermission = app.permissions[this.id + '.main']
mainPermission.name = this.id + '.main'
mainPermission.title = this.$i18n.t('permission_main')
mainPermission.tileAvailable = mainPermission.url !== null && !mainPermission.url.startsWith('re:')
form.labels.push({ label: mainPermission.label, show_tile: mainPermission.show_tile })
const permissions = [mainPermission]
for (const [name, perm] of Object.entries(app.permissions)) {
if (!name.endsWith('.main')) {
permissions.push({
...perm,
name,
label: perm.sublabel,
title: humanPermissionName(name),
tileAvailable: perm.url !== null && !perm.url.startsWith('re:')
})
form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile })
}
const permissions = [mainPermission]
for (const [name, perm] of Object.entries(app.permissions)) {
if (!name.endsWith('.main')) {
permissions.push({
...perm,
name,
label: perm.sublabel,
title: humanPermissionName(name),
tileAvailable: perm.url !== null && !perm.url.startsWith('re:')
})
form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile })
}
}
this.info = {
id: this.id,
label: mainPermission.label,
description: app.description,
version: app.version,
multi_instance: this.$i18n.t(app.manifest.multi_instance ? 'yes' : 'no'),
install_time: readableDate(app.settings.install_time, true, true)
}
if (app.settings.domain) {
this.info.url = 'https://' + app.settings.domain + app.settings.path
form.url = {
domain: app.settings.domain,
path: app.settings.path.slice(1)
}
}
this.form = form
this.app = {
this.infos = {
id: this.id,
label: mainPermission.label,
description: app.description,
version: app.version,
multi_instance: this.$i18n.t(app.manifest.multi_instance ? 'yes' : 'no'),
install_time: readableDate(app.settings.install_time, true, true)
}
if (app.settings.domain) {
this.infos.url = 'https://' + app.settings.domain + app.settings.path
form.url = {
domain: app.settings.domain,
supports_change_url: app.supports_change_url,
permissions
path: app.settings.path.slice(1)
}
})
}
this.form = form
this.app = {
domain: app.settings.domain,
supports_change_url: app.supports_change_url,
permissions
}
},
changeLabel (permName, data) {
data.show_tile = data.show_tile ? 'True' : 'False'
api.put('users/permissions/' + permName, data).then(this.fetchData)
api.put('users/permissions/' + permName, data).then(this.$refs.view.fetchQueries)
},
changeUrl () {
async changeUrl () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_change_url'))
if (!confirmed) return
const { domain, path } = this.form.url
api.put(
`apps/${this.id}/changeurl`,
@ -307,21 +266,26 @@ export default {
).then(this.fetchData)
},
setAsDefaultDomain () {
api.put(`apps/${this.id}/default`).then(this.fetchData)
async setAsDefaultDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_app_default'))
if (!confirmed) return
api.put(`apps/${this.id}/default`).then(this.$refs.view.fetchQueries)
},
uninstall () {
async uninstall () {
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_uninstall', { name: this.id })
)
if (!confirmed) return
api.delete('apps/' + this.id).then(() => {
this.$router.push({ name: 'app-list' })
})
}
},
created () {
this.fetchData()
},
filters: { readableDate },
mixins: [validationMixin]
}
</script>

View file

@ -1,12 +1,8 @@
<template>
<div class="app-install">
<div v-if="infos">
<view-base :loading="loading">
<template v-if="infos">
<!-- BASIC INFOS -->
<b-card>
<template v-slot:header>
<h2><icon iname="info-circle" /> {{ $t('infos') }} {{ name }}</h2>
</template>
<card :title="`${$t('infos')} — ${name}`" icon="info-circle">
<b-row
v-for="(info, key) in infos" :key="key"
no-gutters class="row-line"
@ -19,16 +15,16 @@
<span>{{ info }}</span>
</b-col>
</b-row>
</b-card>
</card>
<!-- INSTALL FORM -->
<card-form
:title="$t('operations')" icon="wrench" :submit-text="$t('install')"
:validation="$v" :server-error="serverError"
@submit.prevent="beforeInstall"
@submit.prevent="performInstall"
>
<template v-if="formDisclaimer" #disclaimer>
<b-alert show variant="info" v-html="formDisclaimer" />
<div class="alert alert-info" v-html="formDisclaimer" />
</template>
<form-field
@ -36,30 +32,26 @@
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
/>
</card-form>
<!-- CONFIRM INSTALL DOMAIN ROOT MODAL -->
<b-modal
id="confirm-domain-root-modal" ref="confirm-domain-root-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="performInstall" hide-header
:ok-title="$t('install')"
>
{{ $t('confirm_install_domain_root', { domain: this.form.domain }) }}
</b-modal>
</div>
</template>
<!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="infos === null" variant="warning" show>
<b-alert v-else-if="infos === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_install_custom_no_manifest') }}
</b-alert>
</div>
<template #skeleton>
<card-info-skeleton />
<card-form-skeleton :cols="null" />
</template>
</view-base>
</template>
<script>
import api from '@/api'
import { validationMixin } from 'vuelidate'
import { formatYunoHostArguments, formatI18nField, formatFormData } from '@/helpers/yunohostArguments'
import api from '@/api'
import { objectToParams } from '@/helpers/commons'
import { formatYunoHostArguments, formatI18nField, formatFormData } from '@/helpers/yunohostArguments'
export default {
name: 'AppInstall',
@ -67,14 +59,12 @@ export default {
mixins: [validationMixin],
props: {
id: {
type: String,
required: true
}
id: { type: String, required: true }
},
data () {
return {
loading: true,
name: undefined,
infos: undefined,
formDisclaimer: null,
@ -106,19 +96,7 @@ export default {
return api.get('appscatalog?full').then(response => response.apps[this.id].manifest)
},
fetchData () {
const isCustom = this.$route.name === 'app-install-custom'
Promise.all([
isCustom ? this.getExternalManifest() : this.getApiManifest(),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
])
]).then((responses) => this.setupForm(responses[0]))
},
setupForm (manifest) {
formatManifestData (manifest) {
this.name = manifest.name
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
if (manifest.license === undefined || manifest.license === 'free') {
@ -137,17 +115,17 @@ export default {
this.fields = fields
this.form = form
this.validations = { form: validations }
this.loading = false
},
beforeInstall () {
async performInstall () {
if ('path' in this.form && this.form.path === '/') {
this.$refs['confirm-domain-root-modal'].show()
} else {
this.performInstall()
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_install_domain_root', { domain: this.form.domain })
)
if (!confirmed) return
}
},
performInstall () {
const { data: args, label } = formatFormData(this.form, { extract: ['label'] })
const data = { app: this.id, label, args: objectToParams(args) }
@ -160,7 +138,15 @@ export default {
},
created () {
this.fetchData()
const isCustom = this.$route.name === 'app-install-custom'
Promise.all([
isCustom ? this.getExternalManifest() : this.getApiManifest(),
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'users' }
])
]).then((responses) => this.formatManifestData(responses[0]))
}
}
</script>

View file

@ -1,10 +1,11 @@
<template>
<search-view
id="app-list"
<view-search
:search.sync="search"
items-name="installed_apps"
:items="apps"
:filtered-items="filteredApps"
items-name="installed_apps"
:queries="queries"
@queries-response="formatAppData"
>
<template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'app-catalog' }">
@ -32,18 +33,16 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</search-view>
</view-search>
</template>
<script>
import api from '@/api'
import SearchView from '@/components/SearchView'
export default {
name: 'AppList',
data () {
return {
queries: ['apps?full'],
search: '',
apps: undefined
}
@ -56,48 +55,40 @@ export default {
const match = (item) => item && item.toLowerCase().includes(search)
// Check if any value in apps (label, id, name, description) match the search query.
const filtered = this.apps.filter(app => Object.values(app).some(match))
return filtered.length > 0 ? filtered : null
return filtered.length ? filtered : null
}
},
methods: {
fetchData () {
api.get('apps?full').then(({ apps }) => {
if (apps.length === 0) {
this.apps = null
return
}
formatAppData ({ apps }) {
if (apps.length === 0) {
this.apps = null
return
}
const multiInstances = {}
this.apps = apps.map(({ id, name, description, permissions, manifest }) => {
// FIXME seems like some apps may no have a label (replace with id)
const label = permissions[id + '.main'].label
// Display the `id` of the instead of its `name` if multiple apps share the same name
if (manifest.multi_instance) {
if (!(name in multiInstances)) {
multiInstances[name] = []
}
const labels = multiInstances[name]
if (labels.includes(label)) {
name = id
}
labels.push(label)
const multiInstances = {}
this.apps = apps.map(({ id, name, description, permissions, manifest }) => {
// FIXME seems like some apps may no have a label (replace with id)
const label = permissions[id + '.main'].label
// Display the `id` of the instead of its `name` if multiple apps share the same name
if (manifest.multi_instance) {
if (!(name in multiInstances)) {
multiInstances[name] = []
}
if (label === name) {
name = null
const labels = multiInstances[name]
if (labels.includes(label)) {
name = id
}
return { id, name, description, label }
}).sort((prev, app) => {
return prev.label > app.label ? 1 : -1
})
labels.push(label)
}
if (label === name) {
name = null
}
return { id, name, description, label }
}).sort((prev, app) => {
return prev.label > app.label ? 1 : -1
})
}
},
created () {
this.fetchData()
},
components: { SearchView }
}
}
</script>

View file

@ -1,5 +1,5 @@
<template>
<div class="backup">
<div>
<b-list-group>
<b-list-group-item
v-for="{ id, name, uri } in storages" :key="id"
@ -7,11 +7,13 @@
class="d-flex justify-content-between align-items-center pr-0"
>
<div>
<h5>
<h5 class="font-weight-bold">
{{ name }}
<small>{{ id }}</small>
<small class="text-secondary">{{ id }}</small>
</h5>
<p class="mb-0">{{ uri }}</p>
<p class="m-0">
{{ uri }}
</p>
</div>
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>

View file

@ -1,162 +1,118 @@
<template>
<div class="backup-create" v-if="isReady">
<b-card no-body>
<template v-slot:header>
<h2><icon iname="archive" /> {{ $t('backup_create') }}</h2>
</template>
<view-base :queries="queries" @queries-response="formatData" skeleton="card-list-skeleton">
<!-- FIXME switch to <card-form> ? -->
<card :title="$t('backup_create')" icon="archive" no-body>
<b-form-checkbox-group
v-model="selected"
id="backup-select" name="backup-select" size="lg"
aria-describedby="backup-restore-feedback"
>
<b-list-group flush>
<!-- SYSTEM TITLE -->
<b-list-group-item class="d-flex align-items-md-center flex-column flex-md-row" variant="dark">
<div>
<h4 class="mb-0"><icon iname="cube" /> {{ $t('system') }}</h4>
</div>
<!-- SYSTEM HEADER -->
<b-list-group-item class="d-flex align-items-sm-center flex-column flex-sm-row" variant="light">
<h4 class="m-0">
<icon iname="cube" /> {{ $t('system') }}
</h4>
<div class="ml-md-auto mt-2 mt-md-0">
<div class="ml-sm-auto mt-2 mt-sm-0">
<b-button
size="sm" variant="light"
v-t="'select_all'" @click="toggleSelected(true, 'hooks')"
@click="toggleSelected(true, 'system')" v-t="'select_all'"
size="sm" variant="outline-dark"
/>
<b-button
size="sm" variant="light" class="ml-2"
v-t="'select_none'" @click="toggleSelected(false, 'hooks')"
@click="toggleSelected(false, 'system')" v-t="'select_none'"
size="sm" variant="outline-dark" class="ml-2"
/>
</div>
</b-list-group-item>
<!-- SYSTEM ITEMS -->
<b-list-group-item
v-for="(item, partName) in hooks" :key="partName"
v-for="(item, partName) in system" :key="partName"
class="d-flex justify-content-between align-items-center pr-0"
>
<div class="mr-2">
<h5>{{ item.name }} </h5>
<p class="mb-0">{{ item.description }}</p>
<h5 class="font-weight-bold">
{{ item.name }}
</h5>
<p class="m-0">
{{ item.description }}
</p>
</div>
<b-form-checkbox :value="partName" :aria-label="$t('check')" class="d-inline" />
</b-list-group-item>
<!-- APPS TITLE -->
<b-list-group-item class="d-flex align-items-md-center flex-column flex-md-row" variant="dark">
<div>
<h4 class="mb-0"><icon iname="cubes" /> {{ $t('applications') }}</h4>
</div>
<!-- APPS HEADER -->
<b-list-group-item class="d-flex align-items-sm-center flex-column flex-sm-row" variant="light">
<h4 class="m-0">
<icon iname="cubes" /> {{ $t('applications') }}
</h4>
<div class="ml-md-auto mt-2 mt-md-0">
<div class="ml-sm-auto mt-2 mt-sm-0">
<b-button
size="sm" variant="light"
v-t="'select_all'" @click="toggleSelected(true, 'apps')"
@click="toggleSelected(true, 'apps')" v-t="'select_all'"
size="sm" variant="outline-dark"
/>
<b-button
size="sm" variant="light" class="ml-2"
v-t="'select_none'" @click="toggleSelected(false, 'apps')"
@click="toggleSelected(false, 'apps')" v-t="'select_none'"
size="sm" variant="outline-dark" class="ml-2"
/>
</div>
</b-list-group-item>
<!-- APPS ITEMS -->
<b-list-group-item
v-for="(item, appName) in apps" :key="appName"
class="d-flex justify-content-between align-items-center pr-0"
>
<div class="mr-2">
<h5>{{ item.name }} <small>{{ item.id }}</small></h5>
<p class="mb-0">{{ item.description }}</p>
<h5 class="font-weight-bold">
{{ item.name }} <small class="text-secondary">{{ item.id }}</small>
</h5>
<p class="m-0">
{{ item.description }}
</p>
</div>
<b-form-checkbox :value="appName" :aria-label="$t('check')" class="d-inline"/>
<b-form-checkbox :value="appName" :aria-label="$t('check')" class="d-inline" />
</b-list-group-item>
</b-list-group>
</b-form-checkbox-group>
<!-- SUBMIT -->
<template v-slot:footer>
<div class="d-flex justify-content-end">
<b-button
@click="createBackup" variant="success"
v-t="'backup_action'" :disabled="selected.length === 0"
/>
</div>
<template #buttons>
<b-button
@click="createBackup" v-t="'backup_action'"
variant="success" :disabled="selected.length === 0"
/>
</template>
</b-card>
</div>
</card>
</view-base>
</template>
<script>
import api from '@/api'
export default {
name: 'BackupCreate',
props: {
id: {
type: String,
required: true
}
id: { type: String, required: true }
},
data () {
return {
isReady: false,
queries: ['hooks/backup', 'apps?with_backup'],
selected: [],
// api data
hooks: undefined,
system: undefined,
apps: undefined
}
},
methods: {
fetchData () {
api.getAll(['hooks/backup', 'apps?with_backup']).then(([{ hooks }, { apps }]) => {
this.hooks = this.formatHooks(hooks)
// transform app array into literal object to match hooks data structure
this.apps = apps.reduce((obj, app) => {
obj[app.id] = app
return obj
}, {})
this.selected = [...Object.keys(this.hooks), ...Object.keys(this.apps)]
this.isReady = true
})
},
toggleSelected (select, type) {
if (select) {
const toSelect = Object.keys(this[type]).filter(item => !this.selected.includes(item))
this.selected = [...this.selected, ...toSelect]
} else {
const toUnselect = Object.keys(this[type])
this.selected = this.selected.filter(selected => !toUnselect.includes(selected))
}
},
createBackup () {
const data = {
apps: [],
system: []
}
for (const item of this.selected) {
if (item in this.hooks) {
data.system = [...data.system, ...this.hooks[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup', data).then(response => {
// FIXME display ws messages
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
},
formatHooks (hooks) {
const data = {}
hooks.forEach(hook => {
@ -173,11 +129,42 @@ export default {
}
})
return data
}
},
},
created () {
this.fetchData()
formatData ({ hooks }, { apps }) {
this.system = this.formatHooks(hooks)
// transform app array into literal object to match hooks data structure
this.apps = apps.reduce((obj, app) => {
obj[app.id] = app
return obj
}, {})
this.selected = [...Object.keys(this.system), ...Object.keys(this.apps)]
},
toggleSelected (select, type) {
if (select) {
const toSelect = Object.keys(this[type]).filter(item => !this.selected.includes(item))
this.selected = [...this.selected, ...toSelect]
} else {
const toUnselect = Object.keys(this[type])
this.selected = this.selected.filter(selected => !toUnselect.includes(selected))
}
},
createBackup () {
const data = { apps: [], system: [] }
for (const item of this.selected) {
if (item in this.system) {
data.system = [...data.system, ...this.system[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup', data).then(response => {
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
}
}
}
</script>

View file

@ -1,82 +1,68 @@
<template>
<div class="backup-info" v-if="isReady">
<view-base :queries="queries" @queries-response="formatBackupData">
<!-- BACKUP INFO -->
<b-card no-body>
<b-card-header class="d-flex align-items-md-center flex-column flex-md-row">
<div>
<h2><icon iname="info-circle" /> {{ $t('infos') }}</h2>
</div>
<card :title="$t('infos')" icon="info-circle" button-unbreak="sm">
<template #header-buttons>
<!-- DOWNLOAD ARCHIVE -->
<b-button @click="downloadBackup" size="sm" variant="success">
<icon iname="download" /> {{ $t('download') }}
</b-button>
<div class="ml-md-auto mt-2 mt-md-0">
<!-- DOWNLOAD ARCHIVE -->
<b-button size="sm" variant="success" @click="downloadBackup">
<icon iname="download" /> {{ $t('download') }}
</b-button>
<!-- DELETE ARCHIVE -->
<b-button @click="deleteBackup" size="sm" variant="danger">
<icon iname="trash-o" /> {{ $t('delete') }}
</b-button>
</template>
<!-- DELETE ARCHIVE -->
<b-button
size="sm" variant="danger" id="delete-backup"
class="ml-2" v-b-modal.confirm-delete-backup
>
<icon iname="trash-o" /> {{ $t('delete') }}
</b-button>
</div>
</b-card-header>
<b-card-body>
<b-row
v-for="(value, prop) in info" :key="prop"
no-gutters class="row-line"
>
<b-col cols="5" md="3" xl="3">
<strong>{{ $t(prop === 'name' ? 'id' : prop) }}</strong>
<span class="sep" />
</b-col>
<b-col>
<span v-if="prop === 'created_at'">{{ value | readableDate }}</span>
<span v-else-if="prop === 'size'">{{ value | humanSize }}</span>
<span v-else>{{ value }}</span>
</b-col>
</b-row>
</b-card-body>
</b-card>
<b-row
v-for="(value, prop) in infos" :key="prop"
no-gutters class="row-line"
>
<b-col md="3" xl="2">
<strong>{{ $t(prop === 'name' ? 'id' : prop) }}</strong>
</b-col>
<b-col>
<span v-if="prop === 'created_at'">{{ value | readableDate }}</span>
<span v-else-if="prop === 'size'">{{ value | humanSize }}</span>
<span v-else>{{ value }}</span>
</b-col>
</b-row>
</card>
<!-- BACKUP CONTENT -->
<b-card no-body>
<b-card-header class="d-flex align-items-md-center flex-column flex-md-row">
<div>
<h2><icon iname="archive" /> {{ $t('backup_content') }}</h2>
</div>
<!-- FIXME switch to <card-form> ? -->
<card
:title="$t('backup_content')" icon="archive"
no-body button-unbreak="sm"
>
<template #header-buttons>
<b-button
size="sm" variant="outline-secondary"
@click="toggleSelected()" v-t="'select_all'"
/>
<div class="ml-md-auto mt-2 mt-md-0">
<b-button
size="sm" variant="outline-secondary"
v-t="'select_all'"
@click="toggleSelected()"
/>
<b-button
size="sm" variant="outline-secondary" class="ml-2"
v-t="'select_none'"
@click="toggleSelected(false)"
/>
</div>
</b-card-header>
<b-button
size="sm" variant="outline-secondary"
@click="toggleSelected(false)" v-t="'select_none'"
/>
</template>
<b-form-checkbox-group
v-if="hasItems" v-model="selected"
v-if="hasBackupData" v-model="selected"
id="backup-select" name="backup-select" size="lg"
aria-describedby="backup-restore-feedback"
>
<b-list-group flush>
<!-- SYSTEM PARTS -->
<b-list-group-item
v-for="(item, partName) in systemParts" :key="partName"
v-for="(item, partName) in system" :key="partName"
class="d-flex justify-content-between align-items-center pr-0"
>
<div class="mr-2">
<h5>{{ item.name }} <small v-if="item.size">({{ item.size | humanSize }})</small></h5>
<p class="mb-0">
<h5 class="font-weight-bold">
{{ item.name }} <small class="text-secondary" v-if="item.size">({{ item.size | humanSize }})</small>
</h5>
<p class="m-0">
{{ item.description }}
</p>
</div>
@ -90,8 +76,10 @@
class="d-flex justify-content-between align-items-center pr-0"
>
<div class="mr-2">
<h5>{{ item.name }} <small>{{ appName }} ({{ item.size | humanSize }})</small></h5>
<p class="mb-0">
<h5 class="font-weight-bold">
{{ item.name }} <small class="text-secondary">{{ appName }} ({{ item.size | humanSize }})</small>
</h5>
<p class="m-0">
{{ $t('version') }} {{ item.version }}
</p>
</div>
@ -101,155 +89,66 @@
</b-list-group>
<b-form-invalid-feedback id="backup-restore-feedback" :state="isValid">
<b-alert variant="danger" show class="mb-0">
<b-alert variant="danger" class="mb-0">
{{ error }}
</b-alert>
</b-form-invalid-feedback>
</b-form-checkbox-group>
<b-alert
v-else
variant="warning" class="mb-0" show
>
<div v-else class="alert alert-warning mb-0">
<icon iname="exclamation-triangle" /> {{ $t('archive_empty') }}
</b-alert>
</div>
<!-- SUBMIT -->
<template v-if="hasItems" v-slot:footer>
<div class="d-flex justify-content-end">
<b-button
v-b-modal.confirm-restore-backup form="backup-restore" variant="success"
v-t="'restore'" :disabled="selected.length === 0"
/>
</div>
<template v-if="hasBackupData" #buttons>
<b-button
@click="restoreBackup" form="backup-restore" variant="success"
v-t="'restore'" :disabled="selected.length === 0"
/>
</template>
</b-card>
</card>
<!-- RESTORE BACKUP MODAL -->
<b-modal
id="confirm-restore-backup" centered
body-bg-variant="danger" body-text-variant="light"
@ok="restoreBackup" hide-header
>
{{ $t('confirm_restore', { name }) }}
</b-modal>
<!-- DELETE BACKUP MODAL -->
<b-modal
id="confirm-delete-backup" centered
body-bg-variant="danger" body-text-variant="light"
@ok="deleteBackup" hide-header
>
{{ $t('confirm_delete', { name }) }}
</b-modal>
</div>
<template #skeleton>
<card-info-skeleton :item-count="4" />
<card-list-skeleton />
</template>
</view-base>
</template>
<script>
import api from '@/api'
import { readableDate } from '@/helpers/filters/date'
import { humanSize } from '@/helpers/filters/human'
import { isEmptyValue } from '@/helpers/commons'
export default {
name: 'BackupInfo',
props: {
id: {
type: String,
required: true
},
name: {
type: String,
required: true
}
id: { type: String, required: true },
name: { type: String, required: true }
},
data () {
return {
isReady: false,
restore: false,
queries: [`backup/archives/${this.name}?with_details`],
selected: [],
error: '',
isValid: null,
// api data
info: {
name: this.name,
created_at: undefined,
size: undefined,
path: undefined
},
infos: undefined,
apps: undefined,
systemParts: undefined
system: undefined
}
},
filters: {
readableDate,
humanSize
computed: {
hasBackupData () {
return !isEmptyValue(this.system) || !isEmptyValue(this.apps)
}
},
methods: {
fetchData () {
api.get(`backup/archives/${this.name}?with_details`).then((data) => {
this.info.created_at = data.created_at
this.info.size = data.size
this.info.path = data.path
this.hasItems = Object.keys(data.system).length !== 0 || Object.keys(data.apps).length !== 0
this.systemParts = this.formatHooks(data.system)
this.apps = data.apps
this.toggleSelected()
this.isReady = true
})
},
toggleSelected (select = true) {
if (select) {
this.selected = [
...Object.keys(this.apps),
...Object.keys(this.systemParts)
]
} else {
this.selected = []
}
},
restoreBackup () {
const data = {
apps: [],
system: [],
force: ''
}
for (const item of this.selected) {
if (item in this.systemParts) {
data.system = [...data.system, ...this.systemParts[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup/restore/' + this.name, data).then(response => {
// FIXME display ws messages
this.isValid = null
}).catch(err => {
// FIXME some errors may be sent by the websocket (yunohost api error for exemple)
this.error = err.message
this.isValid = false
})
},
deleteBackup () {
api.delete('backup/archives/' + this.name).then(() => {
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
},
downloadBackup () {
const host = this.$store.getters.host
window.open(`https://${host}/yunohost/api/backup/download/${this.name}`, '_blank')
},
formatHooks (hooks) {
const data = {}
Object.entries(hooks).forEach(([hook, { size }]) => {
@ -268,11 +167,73 @@ export default {
}
})
return data
},
formatBackupData (data) {
this.infos = {
name: this.name,
created_at: data.created_at,
size: data.size,
path: data.path
}
this.system = this.formatHooks(data.system)
this.apps = data.apps
this.toggleSelected()
},
toggleSelected (select = true) {
if (select) {
this.selected = [
...Object.keys(this.apps),
...Object.keys(this.system)
]
} else {
this.selected = []
}
},
async restoreBackup () {
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_restore', { name: this.name })
)
if (!confirmed) return
const data = { apps: [], system: [], force: '' }
for (const item of this.selected) {
if (item in this.system) {
data.system = [...data.system, ...this.system[item].value]
} else {
data.apps.push(item)
}
}
api.post('backup/restore/' + this.name, data).then(response => {
this.isValid = null
}).catch(err => {
this.error = err.message
this.isValid = false
})
},
async deleteBackup () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return
api.delete('backup/archives/' + this.name).then(() => {
this.$router.push({ name: 'backup-list', params: { id: this.id } })
})
},
downloadBackup () {
const host = this.$store.getters.host
window.open(`https://${host}/yunohost/api/backup/download/${this.name}`, '_blank')
}
},
created () {
this.fetchData()
filters: {
readableDate,
humanSize
}
}
</script>

View file

@ -1,8 +1,10 @@
<template>
<div class="backup-list">
<view-top-bar :button="{ text: $t('backup_new'), icon: 'plus', to: { name: 'backup-create' } }" />
<view-base :queries="queries" @queries-response="formatBackupList" skeleton="list-group-skeleton">
<template #top>
<top-bar :button="{ text: $t('backup_new'), icon: 'plus', to: { name: 'backup-create' } }" />
</template>
<b-alert v-if="!archives" variant="warning" show>
<b-alert v-if="!archives" variant="warning">
<icon iname="exclamation-triangle" />
{{ $t('items_verbose_count', { items: $tc('items.backups', 0) }) }}
</b-alert>
@ -15,9 +17,9 @@
class="d-flex justify-content-between align-items-center pr-0"
>
<div>
<h5>
<h5 class="font-weight-bold">
{{ created_at | distanceToNow }}
<small>{{ name }} ({{ size | humanSize }})</small>
<small class="text-secondary">{{ name }} ({{ size | humanSize }})</small>
</h5>
<p class="mb-0">
{{ path }}
@ -26,11 +28,10 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</div>
</view-base>
</template>
<script>
import api from '@/api'
import { distanceToNow, readableDate } from '@/helpers/filters/date'
import { humanSize } from '@/helpers/filters/human'
@ -38,35 +39,26 @@ export default {
name: 'BackupList',
props: {
id: {
type: String,
required: true
}
id: { type: String, required: true }
},
data () {
return {
queries: ['backup/archives?with_info'],
archives: undefined
}
},
methods: {
fetchData () {
api.get('backup/archives?with_info').then(data => {
// FIXME use archives = null if no archives
const archives = Object.entries(data.archives)
this.archives = archives.length === 0 ? null : archives.map(([name, infos]) => {
infos.name = name
return infos
}).reverse()
})
formatBackupList (data) {
const archives = Object.entries(data.archives)
this.archives = archives.length === 0 ? null : archives.map(([name, infos]) => {
infos.name = name
return infos
}).reverse()
}
},
created () {
this.fetchData()
},
filters: {
distanceToNow,
readableDate,

View file

@ -1,109 +1,107 @@
<template>
<div class="diagnosis">
<view-top-bar>
<template #group-right>
<b-button @click="shareLogs" variant="success">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</template>
</view-top-bar>
<b-alert variant="info" show>
{{ $t(reports ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
<b-button
v-if="reports === null" @click="runFullDiagnosis"
class="d-block mt-2" variant="info"
>
<icon iname="stethoscope" /> {{ $t('run_first_diagnosis') }}
<view-base
:loading="loading" ref="view"
:queries="queries" @queries-response="formatData"
>
<template #top-bar-group-right>
<b-button @click="shareLogs" variant="success">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</b-alert>
</template>
<b-alert
class="mb-5" variant="warning" show
v-t="'diagnosis_experimental_disclaimer'"
/>
<template #top>
<div class="alert alert-info">
{{ $t(reports || loading ? 'diagnosis_explanation' : 'diagnosis_first_run') }}
<b-button
v-if="reports === null" class="d-block mt-2" variant="info"
@click="runDiagnosis"
>
<icon iname="stethoscope" /> {{ $t('run_first_diagnosis') }}
</b-button>
</div>
<div v-t="'diagnosis_experimental_disclaimer'" class="alert alert-warning mb-5" />
</template>
<!-- REPORT CARD -->
<b-card no-body v-for="({ id, description, noIssues, errors, warnings, ignoreds, timestamp, items }, r) in reports" :key="id">
<card
v-for="report in reports" :key="report.id"
collapsable :collapsed="report.noIssues" button-unbreak="lg"
>
<!-- REPORT HEADER -->
<b-card-header class="d-flex align-items-md-center flex-column flex-md-row">
<div class="d-flex align-items-center">
<h2>{{ description }}</h2>
<template #header>
<h2>{{ report.description }}</h2>
<b-badge
v-if="noIssues" pill variant="success"
v-t="'everything_good'"
/>
<b-badge
v-if="errors" variant="danger" pill
v-t="{ path: 'issues', args: { count: errors } }"
/>
<b-badge v-if="warnings" variant="warning" v-t="{ path: 'warnings', args: { count: warnings } }" />
<b-badge v-if="ignoreds" v-t="{ path: 'ignored', args: { count: ignoreds } }" />
<div class="">
<b-badge v-if="report.noIssues" variant="success" v-t="'everything_good'" />
<b-badge v-if="report.errors" variant="danger" v-t="{ path: 'issues', args: { count: report.errors } }" />
<b-badge v-if="report.warnings" variant="warning" v-t="{ path: 'warnings', args: { count: report.warnings } }" />
<b-badge v-if="report.ignoreds" v-t="{ path: 'ignored', args: { count: report.ignoreds } }" />
</div>
</template>
<div class="d-flex ml-md-auto mt-2 mt-md-0">
<b-button size="sm" :variant="items ? 'info' : 'success'" @click="reRunDiagnosis(id)">
<icon iname="refresh" /> {{ $t('rerun_diagnosis') }}
</b-button>
<b-button
size="sm" variant="outline-secondary" class="ml-auto ml-md-2"
v-b-toggle="'collapse-' + id"
>
<icon iname="chevron-right" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
</div>
</b-card-header>
<template #header-buttons>
<b-button size="sm" :variant="report.items ? 'info' : 'success'" @click="runDiagnosis(report.id)">
<icon iname="refresh" /> {{ $t('rerun_diagnosis') }}
</b-button>
</template>
<!-- REPORT BODY -->
<b-collapse :id="'collapse-' + id" :visible="!noIssues">
<p class="last-time-run">
{{ $t('last_ran') }} {{ timestamp | distanceToNow(true, true) }}
</p>
<p class="last-time-run">
{{ $t('last_ran') }} {{ report.timestamp | distanceToNow(true, true) }}
</p>
<b-list-group flush>
<!-- REPORT ITEM -->
<b-list-group-item
v-for="({ status, icon, summary, ignored, issue, details, filterArgs, meta }, i) in items"
:key="i" :variant="status"
>
<div class="item-button d-flex align-items-center">
<icon :iname="icon" class="mr-1" /> <p class="mb-0 mr-2" v-html="summary" />
<b-list-group flush>
<!-- REPORT ITEM -->
<b-list-group-item
v-for="(item, i) in report.items" :key="i"
:variant="item.variant"
>
<div class="item-button d-flex align-items-center">
<icon :iname="item.icon" class="mr-1" /> <p class="mb-0 mr-2" v-html="item.summary" />
<div class="d-flex flex-column flex-lg-row ml-auto">
<b-button
v-if="ignored" size="sm"
@click="toggleIgnoreIssue(false, filterArgs, r, i)"
>
<icon iname="bell" /> <span v-t="'unignore'" />
</b-button>
<b-button
v-else-if="issue"
variant="warning" size="sm" @click="toggleIgnoreIssue(true, filterArgs, r, i)"
>
<icon iname="bell-slash" /> <span v-t="'ignore'" />
</b-button>
<b-button
v-if="details"
size="sm" variant="outline-dark" class="ml-lg-2 mt-2 mt-lg-0"
v-b-toggle="'collapse-' + id + '-item-' + i"
>
<icon iname="level-down" /> <span v-t="'details'" />
</b-button>
</div>
<div class="d-flex flex-column flex-lg-row ml-auto">
<b-button
v-if="item.ignored" size="sm"
@click="toggleIgnoreIssue(false, report, item)"
>
<icon iname="bell" /> {{ $t('unignore') }}
</b-button>
<b-button
v-else-if="item.issue" variant="warning" size="sm"
@click="toggleIgnoreIssue(true, report, item)"
>
<icon iname="bell-slash" /> {{ $t('ignore') }}
</b-button>
<b-button
v-if="item.details"
size="sm" variant="outline-dark" class="ml-lg-2 mt-2 mt-lg-0"
v-b-toggle="`collapse-${report.id}-item-${i}`"
>
<icon iname="level-down" /> {{ $t('details') }}
</b-button>
</div>
</div>
<b-collapse v-if="details" :id="'collapse-' + id + '-item-' + i">
<ul class="mt-2 pl-4">
<li v-for="(detail, index) in details" :key="index" v-html="detail" />
</ul>
</b-collapse>
</b-list-group-item>
</b-list-group>
</b-collapse>
</b-card>
</div>
<b-collapse v-if="item.details" :id="`collapse-${report.id}-item-${i}`">
<ul class="mt-2 pl-4">
<li v-for="(detail, index) in item.details" :key="index" v-html="detail" />
</ul>
</b-collapse>
</b-list-group-item>
</b-list-group>
</card>
<template #skeleton>
<card-list-skeleton />
<b-card no-body>
<template #header>
<b-skeleton width="30%" height="36px" class="m-0" />
</template>
</b-card>
<card-list-skeleton />
</template>
</view-base>
</template>
<script>
@ -115,72 +113,85 @@ export default {
data () {
return {
queries: ['diagnosis/show?full'],
loading: true,
reports: undefined
}
},
methods: {
fetchData () {
api.get('diagnosis/show?full').then((data) => {
if (data === null) {
this.reports = null
return
formatReportItem (report, item) {
let issue = false
let icon = ''
const status = item.variant = item.status.toLowerCase()
if (status === 'success') {
icon = 'check-circle'
} else if (status === 'info') {
icon = 'info-circle'
} else if (item.ignored) {
icon = status !== 'error' ? status : 'times'
item.variant = 'light'
report.ignoreds++
} else if (status === 'warning') {
icon = status
issue = true
report.warnings++
} else if (status === 'error') {
item.variant = 'danger'
icon = 'times'
issue = true
report.errors++
}
item.issue = issue
item.icon = icon
},
formatData (data) {
if (data === null) {
this.reports = null
this.loading = false
return
}
const reports = data.reports
for (const report of reports) {
report.warnings = 0
report.errors = 0
report.ignoreds = 0
for (var item of report.items) {
this.formatReportItem(report, item)
}
const reports = data.reports
for (const report of reports) {
report.warnings = 0
report.errors = 0
report.ignoreds = 0
for (var item of report.items) {
let issue = false
let icon = ''
const status = item.status = item.status.toLowerCase()
if (status === 'success') {
icon = 'check-circle'
} else if (status === 'info') {
icon = 'info-circle'
} else if (item.ignored) {
icon = status !== 'error' ? status : 'times'
item.status = 'ignored'
report.ignoreds++
} else if (status === 'warning') {
icon = status
issue = true
report.warnings++
} else if (status === 'error') {
item.status = 'danger'
icon = 'times'
issue = true
report.errors++
}
item.issue = issue
item.icon = icon
item.filterArgs = Object.entries(item.meta).reduce((filterArgs, entries) => {
filterArgs.push(entries.join('='))
return filterArgs
}, [report.id])
}
report.noIssues = report.warnings + report.errors === 0
}
this.reports = reports
})
report.noIssues = report.warnings + report.errors === 0
}
this.reports = reports
this.loading = false
},
runFullDiagnosis () {
api.post('diagnosis/run').then(this.fetchData)
runDiagnosis (id = null) {
const param = id !== null ? '?force' : ''
const data = id !== null ? { categories: [id] } : {}
api.post('diagnosis/run' + param, data).then(this.$refs.view.fetchQueries)
},
reRunDiagnosis (id) {
api.post('diagnosis/run?force', { categories: [id] }).then(this.fetchData)
},
toggleIgnoreIssue (ignore, filterArgs, reportIndex, itemIndex) {
toggleIgnoreIssue (ignore, report, item) {
const key = (ignore ? 'add' : 'remove') + '_filter'
api.post('diagnosis/ignore', { [key]: filterArgs }).then(this.fetchData)
const filterArgs = Object.entries(item.meta).reduce((filterArgs, entries) => {
filterArgs.push(entries.join('='))
return filterArgs
}, [report.id])
api.post('diagnosis/ignore', { [key]: filterArgs }).then(() => {
item.ignored = ignore
if (ignore) {
report[item.status.toLowerCase() + 's']--
} else {
report.ignoreds--
}
this.formatReportItem(report, item)
})
},
shareLogs () {
@ -191,17 +202,15 @@ export default {
},
created () {
api.post('diagnosis/run?except_if_never_ran_yet').then(this.fetchData)
api.post('diagnosis/run?except_if_never_ran_yet')
},
filters: {
distanceToNow
}
filters: { distanceToNow }
}
</script>
<style lang="scss" scoped>
.badge {
.badge + .badge {
margin-left: .5rem
}

View file

@ -1,8 +1,10 @@
<template lang="html">
<domain-form
:title="$t('domain_add')" :server-error="serverError"
@submit="onSubmit" :submit-text="$t('add')"
/>
<template>
<view-base :queries="queries" skeleton="card-form-skeleton">
<domain-form
:title="$t('domain_add')" :server-error="serverError"
@submit="onSubmit" :submit-text="$t('add')"
/>
</view-base>
</template>
<script>
@ -13,6 +15,7 @@ export default {
data () {
return {
queries: [{ uri: 'domains' }],
serverError: ''
}
},
@ -33,8 +36,6 @@ export default {
}
},
components: {
DomainForm
}
components: { DomainForm }
}
</script>

View file

@ -1,37 +1,33 @@
<template>
<div class="domain-cert" v-if="cert">
<b-card>
<template v-slot:header>
<h2><icon iname="lock" /> {{ $t('certificate_status') }}</h2>
</template>
<view-base :queries="queries" @queries-response="formatCertData" ref="view">
<card v-if="cert" :title="$t('certificate_status')" icon="lock">
<p :class="'alert alert-' + cert.alert.type">
<icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }}
</p>
<dl>
<dt v-t="'certificate_authority'" />
<dd>{{ cert.type }} ({{ name }})</dd>
<hr>
<dt v-t="'validity'" />
<dd>{{ $tc('day_validity', cert.validity) }}</dd>
</dl>
</b-card>
<b-row no-gutters class="row-line">
<b-col md="4" xl="2">
<strong v-t="'certificate_authority'" />
</b-col>
<b-col>{{ cert.type }} ({{ name }})</b-col>
</b-row>
<b-card>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2>
</template>
<b-row no-gutters class="row-line">
<b-col md="4" xl="2">
<strong v-t="'validity'" />
</b-col>
<b-col>{{ $tc('day_validity', cert.validity) }}</b-col>
</b-row>
</card>
<card v-if="cert" :title="$t('operations')" icon="wrench">
<!-- CERT INSTALL LETSENCRYPT -->
<template v-if="actionsEnabled.installLetsencrypt">
<p>
<icon :iname="cert.acmeEligible ? 'check' : 'meh-o'" /> <span v-html="$t(`domain_${cert.acmeEligible ? 'is' : 'not'}_eligible_for_ACME`)" />
</p>
<b-button
variant="success" :disabled="!cert.acmeEligible"
@click="action = 'install_LE'" v-b-modal.action-confirm-modal
>
<b-button @click="callAction('install_LE')" variant="success" :disabled="!cert.acmeEligible">
<icon iname="star" /> {{ $t('install_letsencrypt_cert') }}
</b-button>
<hr>
@ -40,7 +36,8 @@
<!-- CERT RENEW LETS-ENCRYPT -->
<template v-if="actionsEnabled.manualRenewLetsencrypt">
<p v-t="'manually_renew_letsencrypt_message'" />
<b-button variant="warning" @click="action = 'manual_renew_LE'" v-b-modal.action-confirm-modal>
<b-button @click="callAction('manual_renew_LE')" variant="warning">
<icon iname="refresh" /> {{ $t('manually_renew_letsencrypt') }}
</b-button>
<hr>
@ -49,7 +46,8 @@
<!-- CERT REGEN SELF-SIGNED -->
<template v-if="actionsEnabled.regenSelfsigned">
<p v-t="'regenerate_selfsigned_cert_message'" />
<b-button variant="warning" @click="action = 'regen_selfsigned'" v-b-modal.action-confirm-modal>
<b-button @click="callAction('regen_selfsigned')" variant="warning">
<icon iname="refresh" /> {{ $t('regenerate_selfsigned_cert') }}
</b-button>
<hr>
@ -58,23 +56,19 @@
<!-- CERT REPLACE WITH SELF-SIGNED -->
<template v-if="actionsEnabled.replaceWithSelfsigned">
<p v-t="'revert_to_selfsigned_cert_message'" />
<b-button variant="danger" @click="action = 'revert_to_selfsigned'" v-b-modal.action-confirm-modal>
<b-button @click="callAction('revert_to_selfsigned')" variant="danger">
<icon iname="exclamation-triangle" /> {{ $t('revert_to_selfsigned_cert') }}
</b-button>
<hr>
</template>
</b-card>
</card>
<!-- ACTIONS CONFIRMATION MODAL -->
<b-modal
v-if="action"
id="action-confirm-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="callAction" hide-header
>
{{ $t(`confirm_cert_${action}`) }}
</b-modal>
</div>
<template #skeleton>
<card-info-skeleton :item-count="2" />
<card-buttons-skeleton :item-count="2" />
</template>
</view-base>
</template>
<script>
@ -84,96 +78,81 @@ export default {
name: 'DomainCert',
props: {
name: {
type: String,
required: true
}
name: { type: String, required: true }
},
data () {
return {
queries: [`domains/cert-status/${this.name}?full`],
cert: undefined,
actionsEnabled: undefined,
action: undefined
actionsEnabled: undefined
}
},
methods: {
fetchData () {
// simply use the api helper since we will not store the request's result.
api.get(`domains/cert-status/${this.name}?full`).then((data) => {
const certData = data.certificates[this.name]
const cert = {
type: certData.CA_type.verbose,
name: certData.CA_name,
validity: certData.validity,
acmeEligible: certData.ACME_eligible
}
switch (certData.summary.code) {
case 'critical':
cert.alert = { type: 'danger', trad: 'not_valid', icon: 'exclamation-circle' }
break
case 'warning':
cert.alert = { type: 'warning', trad: 'selfsigned', icon: 'exclamation-triangle' }
break
case 'attention':
if (cert.type === 'lets-encrypt') {
cert.alert = { type: 'warning', trad: 'letsencrypt_about_to_expire', icon: 'clock-o' }
} else {
cert.alert = { type: 'danger', trad: 'about_to_expire', icon: 'clock-o' }
}
break
case 'good':
cert.alert = { type: 'success', trad: 'good', icon: 'check-circle' }
break
case 'great':
cert.alert = { type: 'success', trad: 'great', icon: 'thumbs-up' }
break
default:
cert.alert = { type: 'warning', trad: 'unknown', icon: 'question' }
}
const actionsEnabled = {
installLetsencrypt: false,
manualRenewLetsencrypt: false,
regenSelfsigned: false,
replaceWithSelfsigned: false
}
switch (certData.CA_type.code) {
case 'self-signed':
actionsEnabled.installLetsencrypt = true
actionsEnabled.regenSelfsigned = true
break
case 'lets-encrypt':
actionsEnabled.manualRenewLetsencrypt = true
actionsEnabled.replaceWithSelfsigned = true
break
default:
actionsEnabled.replaceWithSelfsigned = true
}
this.action = undefined
this.cert = cert
this.actionsEnabled = actionsEnabled
})
formatCertAlert (code, type) {
switch (code) {
case 'critical': return { type: 'danger', trad: 'not_valid', icon: 'exclamation-circle' }
case 'warning': return { type: 'warning', trad: 'selfsigned', icon: 'exclamation-triangle' }
case 'attention':
if (type === 'lets-encrypt') {
return { type: 'warning', trad: 'letsencrypt_about_to_expire', icon: 'clock-o' }
} else {
return { type: 'danger', trad: 'about_to_expire', icon: 'clock-o' }
}
case 'good': return { type: 'success', trad: 'good', icon: 'check-circle' }
case 'great': return { type: 'success', trad: 'great', icon: 'thumbs-up' }
default: return { type: 'warning', trad: 'unknown', icon: 'question' }
}
},
callAction () {
const action = this.action
formatCertData (data) {
const certData = data.certificates[this.name]
const cert = {
type: certData.CA_type.verbose,
name: certData.CA_name,
validity: certData.validity,
acmeEligible: certData.ACME_eligible,
alert: this.formatCertAlert(certData.summary.code, certData.CA_type.verbose)
}
const actionsEnabled = {
installLetsencrypt: false,
manualRenewLetsencrypt: false,
regenSelfsigned: false,
replaceWithSelfsigned: false
}
switch (certData.CA_type.code) {
case 'self-signed':
actionsEnabled.installLetsencrypt = true
actionsEnabled.regenSelfsigned = true
break
case 'lets-encrypt':
actionsEnabled.manualRenewLetsencrypt = true
actionsEnabled.replaceWithSelfsigned = true
break
default:
actionsEnabled.replaceWithSelfsigned = true
}
this.cert = cert
this.actionsEnabled = actionsEnabled
},
async callAction (action) {
const confirmed = await this.$askConfirmation(this.$i18n.t(`confirm_cert_${action}`))
if (!confirmed) return
let uri = 'domains/cert-install/' + this.name
if (action === 'regen_selfsigned') uri += '?self_signed'
else if (action === 'manual_renew_LE') uri += '?force'
else if (action === 'revert_to_selfsigned') uri += '?self_signed&force'
api.post(uri, {}).then(() => this.fetchData())
// FIXME trigger loading ? while posting ? while getting ?
// this.$refs.view.fallback_loading = true
api.post(uri).then(this.$refs.view.fetchQueries)
}
},
created () {
this.fetchData()
}
}
</script>

View file

@ -1,41 +1,30 @@
<template>
<div class="domain-dns">
<p class="alert alert-warning">
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }}
</p>
<b-card>
<template v-slot:header>
<h2><icon iname="globe" /> {{ $t('domain_dns_config') }}</h2>
</template>
<pre><code>{{ dnsConfig }}</code></pre>
</b-card>
</div>
<view-base :queries="queries" @queries-response="dnsConfig = $event" skeleton="card-info-skeleton">
<template #top>
<p class="alert alert-warning">
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }}
</p>
</template>
<card :title="$t('domain_dns_config')" icon="globe" no-body>
<pre class="log"><code>{{ dnsConfig }}</code></pre>
</card>
</view-base>
</template>
<script>
import api from '@/api'
export default {
name: 'DomainDns',
props: {
name: {
type: String,
required: true
}
name: { type: String, required: true }
},
data () {
return {
queries: [`domains/${this.name}/dns`],
dnsConfig: ''
}
},
created () {
// simply use the api helper since we will not store the request's result.
api.get(`domains/${this.name}/dns`).then(dnsConfig => {
this.dnsConfig = dnsConfig
})
}
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,9 +1,6 @@
<template>
<div class="domain-info">
<b-card>
<template v-slot:header>
<h2><icon iname="globe" /> {{ name }}</h2>
</template>
<view-base :queries="queries" skeleton="card-list-skeleton">
<card :title="name" icon="globe">
<!-- VISIT -->
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p>
<b-button variant="success" :href="'https://' + name" target="_blank">
@ -13,16 +10,12 @@
<!-- DEFAULT DOMAIN -->
<p>{{ $t('domain_default_desc') }}</p>
<template v-if="isMainDomain">
<p class="alert alert-info">
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
</p>
</template>
<template v-else>
<b-button variant="info" v-b-modal.default-domain-modal>
<icon iname="star" /> {{ $t('set_default') }}
</b-button>
</template>
<p v-if="isMainDomain" class="alert alert-info">
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
</p>
<b-button v-else variant="info" @click="setAsDefaultDomain">
<icon iname="star" /> {{ $t('set_default') }}
</b-button>
<hr>
<!-- DNS CONFIG -->
@ -41,53 +34,50 @@
<!-- DELETE -->
<p>{{ $t('domain_delete_longdesc') }}</p>
<b-button variant="danger" v-b-modal.delete-modal>
<p
v-if="isMainDomain" class="alert alert-danger"
v-html="$t('domain_delete_forbidden_desc', { domain: name })"
/>
<b-button v-else variant="danger" @click="deleteDomain">
<icon iname="trash-o" /> {{ $t('delete') }}
</b-button>
</b-card>
<!-- DEFAULT DOMAIN MODAL -->
<b-modal
id="default-domain-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="setAsDefaultDomain" hide-header
>
{{ $t('confirm_change_maindomain') }}
</b-modal>
<!-- DELETE MODAL -->
<b-modal
id="delete-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="deleteDomain" hide-header
>
{{ $t('confirm_delete', { name }) }}
</b-modal>
</div>
</card>
</view-base>
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'DomainInfo',
props: {
name: {
type: String,
required: true
}
},
data () {
return {
queries: [{ uri: 'domains/main', storeKey: 'main_domain' }]
}
},
computed: {
mainDomain () {
return this.$store.state.data.main_domain
},
...mapGetters(['mainDomain']),
isMainDomain () {
if (!this.mainDomain) return
return this.name === this.mainDomain
}
},
methods: {
deleteDomain () {
async deleteDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return
this.$store.dispatch('DELETE',
{ uri: 'domains', param: this.name }
).then(() => {
@ -95,22 +85,17 @@ export default {
})
},
setAsDefaultDomain () {
async setAsDefaultDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_change_maindomain'))
if (!confirmed) return
this.$store.dispatch('PUT',
{ uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' }
).then(() => {
// FIXME have to commit here since the response's is empty
// Have to commit by hand here since the response is empty
this.$store.commit('UPDATE_MAIN_DOMAIN', this.name)
})
}
},
created () {
this.$store.dispatch('FETCH',
{ uri: 'domains/main', storeKey: 'main_domain' }
)
}
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,10 +1,11 @@
<template>
<search-view
<view-search
id="domain-list"
:search.sync="search"
:items="domains"
:filtered-items="filteredDomains"
items-name="domains"
:queries="queries"
>
<template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'domain-add' }">
@ -34,19 +35,21 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</search-view>
</view-search>
</template>
<script>
import { mapGetters } from 'vuex'
import SearchView from '@/components/SearchView'
export default {
name: 'DomainList',
data () {
return {
queries: [
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
],
search: ''
}
},
@ -61,17 +64,8 @@ export default {
const domains = this.domains
.filter(name => name.toLowerCase().includes(search))
.sort(prevDomain => prevDomain === mainDomain ? -1 : 1)
return domains.length > 0 ? domains : null
return domains.length ? domains : null
}
},
created () {
this.$store.dispatch('FETCH_ALL', [
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
])
},
components: { SearchView }
}
}
</script>

View file

@ -1,4 +1,4 @@
<template lang="html">
<template>
<card-form
:title="$t('group_new')" icon="users"
:validation="$v" :server-error="serverError"
@ -11,22 +11,18 @@
<script>
import { validationMixin } from 'vuelidate'
import { required, alphalownum_ } from '@/helpers/validators'
import { required, alphalownum_ } from '@/helpers/validators'
export default {
name: 'GroupCreate',
mixins: [validationMixin],
data () {
return {
form: {
groupname: ''
},
serverError: '',
groupname: {
label: this.$i18n.t('group_name'),
description: this.$i18n.t('group_format_name_help'),
@ -55,6 +51,8 @@ export default {
this.isValid.groupname = false
})
}
}
},
mixins: [validationMixin]
}
</script>

View file

@ -1,146 +1,110 @@
<template>
<search-view
id="group-list"
<view-search
items-name="groups"
:search.sync="search"
:items="normalGroups"
:filtered-items="filteredGroups"
items-name="groups"
:queries="queries"
@queries-response="formatGroups"
skeleton="card-form-skeleton"
>
<template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'group-create' }">
<icon iname="plus" />
{{ $t('group_new') }}
<icon iname="plus" /> {{ $t('group_new') }}
</b-button>
</template>
<!-- PRIMARY GROUPS CARDS -->
<b-card
v-for="(group, name, index) in filteredGroups" :key="name"
no-body
<card
v-for="(group, name) in filteredGroups" :key="name" collapsable
:title="group.isSpecial ? $t('group_' + name) : `${$t('group')} '${name}'`" icon="group"
>
<b-card-header class="d-flex align-items-center">
<h2>
<icon iname="group" /> {{ group.isSpecial ? $t('group_' + name) : `${$t('group')} "${name}"` }}
</h2>
<template #header-buttons>
<!-- DELETE GROUP -->
<b-button
v-if="!group.isSpecial" @click="deleteGroup(name)"
size="sm" variant="danger"
>
<icon iname="trash-o" /> {{ $t('delete') }}
</b-button>
</template>
<div class="ml-auto">
<b-button v-b-toggle="'collapse-' + index" size="sm" variant="outline-secondary">
<icon iname="chevron-right" class="rotate" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
<b-row>
<b-col md="3" lg="2">
<strong>{{ $t('users') }}</strong>
</b-col>
<b-button
v-if="!group.isSpecial" v-b-modal.delete-modal
variant="danger" class="ml-2" size="sm"
@click="groupToDelete = name"
>
<icon :title="$t('delete')" iname="trash-o" /> <span class="sr-only">{{ $t('delete') }}</span>
</b-button>
</div>
</b-card-header>
<b-col>
<template v-if="group.isSpecial">
<p><icon iname="info-circle" /> {{ $t('group_explain_' + name) }}</p>
<p v-if="name === 'visitors'">
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em>
</p>
</template>
<template v-else>
<zone-selectize
:choices="group.availableMembers" :selected="group.members"
item-icon="user"
:label="$t('group_add_member')"
@change="onUserChanged({ ...$event, name })"
/>
</template>
</b-col>
</b-row>
<hr>
<b-collapse :id="'collapse-' + index" visible>
<b-card-body>
<b-row>
<b-col md="3" lg="2">
<strong>{{ $t('users') }}</strong>
</b-col>
<b-col>
<template v-if="group.isSpecial">
<p><icon iname="info-circle" /> {{ $t('group_explain_' + name) }}</p>
<p v-if="name === 'visitors'">
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em>
</p>
</template>
<template v-else>
<zone-selectize
:choices="group.availableMembers" :selected="group.members"
item-icon="user"
:label="$t('group_add_member')"
@change="onUserChanged({ ...$event, name })"
/>
</template>
</b-col>
</b-row>
<hr>
<b-row>
<b-col md="3" lg="2">
<strong>{{ $t('permissions') }}</strong>
</b-col>
<b-col>
<zone-selectize
item-icon="key-modern" item-variant="dark"
:choices="group.availablePermissions"
:selected="group.permissions"
:label="$t('group_add_permission')"
:format="formatPermission"
:removable="name === 'visitors' ? removable : null"
@change="onPermissionChanged({ ...$event, name, groupType: 'normal' })"
/>
</b-col>
</b-row>
</b-card-body>
</b-collapse>
</b-card>
<b-row>
<b-col md="3" lg="2">
<strong>{{ $t('permissions') }}</strong>
</b-col>
<b-col>
<zone-selectize
item-icon="key-modern" item-variant="dark"
:choices="group.availablePermissions"
:selected="group.permissions"
:label="$t('group_add_permission')"
:format="formatPermission"
:removable="name === 'visitors' ? removable : null"
@change="onPermissionChanged({ ...$event, name, groupType: 'normal' })"
/>
</b-col>
</b-row>
</card>
<!-- GROUP SPECIFIC CARD -->
<template #extra>
<b-card no-body v-if="userGroups">
<b-card-header class="d-flex align-items-center">
<h2>
<icon iname="group" /> {{ $t('group_specific_permissions') }}
</h2>
<card
v-if="userGroups" collapsable
:title="$t('group_specific_permissions')" icon="group"
>
<template v-for="(name, index) in userGroupsNames">
<b-row :key="name">
<b-col md="3" lg="2">
<icon iname="user" /> <strong>{{ name }}</strong>
</b-col>
<div class="ml-auto">
<b-button v-b-toggle.collapse-specific size="sm" variant="outline-secondary">
<icon iname="chevron-right" class="rotate" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
</div>
</b-card-header>
<b-collapse id="collapse-specific" visible>
<b-card-body>
<div v-for="name in userGroupsNames" :key="name">
<b-row>
<b-col md="3" lg="2">
<icon iname="user" /> <strong>{{ name }}</strong>
</b-col>
<b-col>
<zone-selectize
item-icon="key-modern" item-variant="dark"
:choices="userGroups[name].availablePermissions"
:selected="userGroups[name].permissions"
:label="$t('group_add_permission')"
:format="formatPermission"
@change="onPermissionChanged({ ...$event, name, groupType: 'user' })"
/>
</b-col>
</b-row>
<hr>
</div>
<base-selectize
v-if="availableMembers.length"
:label="$t('group_add_member')"
:choices="availableMembers"
:selected="userGroupsNames"
@selected="onSpecificUserAdded"
<b-col>
<zone-selectize
item-icon="key-modern" item-variant="dark"
:choices="userGroups[name].availablePermissions"
:selected="userGroups[name].permissions"
:label="$t('group_add_permission')"
:format="formatPermission"
@change="onPermissionChanged({ ...$event, name, groupType: 'user' })"
/>
</b-card-body>
</b-collapse>
</b-card>
</b-col>
</b-row>
<hr :key="index">
</template>
<!-- DELETE GROUP MODAL -->
<b-modal
v-if="groupToDelete" id="delete-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="deleteGroup" hide-header
>
{{ $t('confirm_delete', {name: groupToDelete }) }}
</b-modal>
</template>
</search-view>
<base-selectize
v-if="availableMembers.length"
:label="$t('group_add_member')"
:choices="availableMembers"
:selected="userGroupsNames"
@selected="onSpecificUserAdded"
/>
</card>
</view-search>
</template>
<script>
@ -148,7 +112,6 @@ import Vue from 'vue'
import api from '@/api'
import { isEmptyValue } from '@/helpers/commons'
import SearchView from '@/components/SearchView'
import ZoneSelectize from '@/components/ZoneSelectize'
import BaseSelectize from '@/components/BaseSelectize'
@ -159,11 +122,15 @@ export default {
data () {
return {
queries: [
{ uri: 'users' },
{ uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' },
{ uri: 'users/permissions?full', storeKey: 'permissions' }
],
search: '',
permissions: undefined,
normalGroups: undefined,
userGroups: undefined,
groupToDelete: undefined
userGroups: undefined
}
},
@ -199,58 +166,7 @@ export default {
},
methods: {
onPermissionChanged ({ item, index, name, groupType, action }) {
const uri = 'users/permissions/' + item
const data = { [action]: name }
const from = action === 'add' ? 'availablePermissions' : 'permissions'
const to = action === 'add' ? 'permissions' : 'availablePermissions'
api.put(uri, data).then(() => {
this[groupType + 'Groups'][name][from].splice(index, 1)
this[groupType + 'Groups'][name][to].push(item)
})
},
onUserChanged ({ item, index, name, action }) {
const uri = 'users/groups/' + name
const data = { [action]: item }
const from = action === 'add' ? 'availableMembers' : 'members'
const to = action === 'add' ? 'members' : 'availableMembers'
api.put(uri, data).then(() => {
this.normalGroups[name][from].splice(index, 1)
this.normalGroups[name][to].push(item)
})
},
onSpecificUserAdded ({ item }) {
this.userGroups[item].permissions = []
},
// FIXME Find a way to pass a filter to a component
formatPermission (name) {
return this.permissions[name].label
},
removable (name) {
return this.permissions[name].protected === false
},
deleteGroup () {
const groupname = this.groupToDelete
this.$store.dispatch('DELETE',
{ uri: 'users/groups', param: groupname, storeKey: 'groups' }
).then(() => {
Vue.delete(this.groups, groupname)
})
this.groupToDelete = undefined
}
},
created () {
this.$store.dispatch('FETCH_ALL', [
{ uri: 'users' },
{ uri: 'users/groups?full&include_primary_groups', storeKey: 'groups' },
{ uri: 'users/permissions?full', storeKey: 'permissions' }
]).then(([users, allGroups, permissions]) => {
formatGroups (users, allGroups, permissions) {
// Do not use computed properties to get values from the store here to avoid auto
// updates while modifying values.
const normalGroups = {}
@ -289,12 +205,57 @@ export default {
this.permissions = permissions
this.normalGroups = normalGroups
this.userGroups = userGroups
})
this.userGroups = isEmptyValue(userGroups) ? null : userGroups
},
onPermissionChanged ({ item, index, name, groupType, action }) {
const uri = 'users/permissions/' + item
const data = { [action]: name }
const from = action === 'add' ? 'availablePermissions' : 'permissions'
const to = action === 'add' ? 'permissions' : 'availablePermissions'
api.put(uri, data).then(() => {
this[groupType + 'Groups'][name][from].splice(index, 1)
this[groupType + 'Groups'][name][to].push(item)
})
},
onUserChanged ({ item, index, name, action }) {
const uri = 'users/groups/' + name
const data = { [action]: item }
const from = action === 'add' ? 'availableMembers' : 'members'
const to = action === 'add' ? 'members' : 'availableMembers'
api.put(uri, data).then(() => {
this.normalGroups[name][from].splice(index, 1)
this.normalGroups[name][to].push(item)
})
},
onSpecificUserAdded ({ item }) {
this.userGroups[item].permissions = []
},
// FIXME Find a way to pass a filter to a component
formatPermission (name) {
return this.permissions[name].label
},
removable (name) {
return this.permissions[name].protected === false
},
async deleteGroup (name) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name }))
if (!confirmed) return
this.$store.dispatch('DELETE',
{ uri: 'users/groups', param: name, storeKey: 'groups' }
).then(() => {
Vue.delete(this.normalGroups, name)
})
}
},
components: {
SearchView,
ZoneSelectize,
BaseSelectize
}

View file

@ -1,91 +1,71 @@
<template>
<div class="service-info">
<view-base
:queries="queries" @queries-response="formatServiceData"
ref="view" skeleton="card-info-skeleton"
>
<!-- INFO CARD -->
<b-card>
<template v-slot:header>
<div class="d-sm-flex">
<h2><icon iname="info-circle" /> {{ name }}</h2>
<div class="ml-auto mt-2 mt-sm-0">
<template v-if="status === 'running'">
<b-button variant="warning" @click="action = 'restart'" v-b-modal.action-confirm-modal>
<icon iname="refresh" /> {{ $t('restart') }}
</b-button>
<b-button
v-if="!critical" variant="danger" class="ml-2"
@click="action = 'stop'" v-b-modal.action-confirm-modal
>
<icon iname="warning" /> {{ $t('stop') }}
</b-button>
</template>
<b-button
v-else
variant="success" @click="action = 'start'" v-b-modal.action-confirm-modal
>
<icon iname="play" /> {{ $t('start') }}
</b-button>
</div>
</div>
<card :title="name" icon="info-circle" button-unbreak="sm">
<template #header-buttons>
<template v-if="infos.status === 'running'">
<!-- RESTART SERVICE -->
<b-button @click="updateService('restart')" variant="warning">
<icon iname="refresh" /> {{ $t('restart') }}
</b-button>
<!-- STOP SERVICE -->
<b-button v-if="!isCritical" @click="updateService('stop')" variant="danger">
<icon iname="warning" /> {{ $t('stop') }}
</b-button>
</template>
<!-- START SERVICE -->
<b-button v-else @click="updateService('start')" variant="success">
<icon iname="play" /> {{ $t('start') }}
</b-button>
</template>
<b-row no-gutters class="row-line">
<b-col cols="auto" md="3"><strong v-t="'description'" /></b-col>
<b-col>{{ description }}</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col cols="auto" md="3"><strong v-t="'status'" /></b-col>
<b-row
v-for="(value, key) in infos" :key="key"
no-gutters class="row-line"
>
<b-col md="3" xl="2">
<strong>{{ $t(key === 'start_on_boot' ? 'service_' + key : key) }}</strong>
</b-col>
<b-col>
<span :class="status === 'running' ? 'text-success' : 'text-danger'">
<icon :iname="status === 'running' ? 'check-circle' : 'times'" />
{{ $t(status) }}
<template v-if="key === 'status'">
<span :class="value === 'running' ? 'text-success' : 'text-danger'">
<icon :iname="value === 'running' ? 'check-circle' : 'times'" />
{{ $t(value) }}
</span>
{{ $t('since') }} {{ uptime | distanceToNow }}
</template>
<span v-else-if="key === 'start_on_boot'" :class="value === 'enabled' ? 'text-success' : 'text-danger'">
{{ $t(value) }}
</span>
{{ $t('since') }} {{ last_state_change | distanceToNow }}
<span v-else v-t="value" />
</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col cols="auto" md="3"><strong v-t="'service_start_on_boot'" /></b-col>
<b-col>
<span :class="start_on_boot === 'enabled' ? 'text-success' : 'text-danger'">
{{ $t(start_on_boot) }}
</span>
</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col cols="auto" md="3"><strong v-t="'configuration'" /></b-col>
<b-col>
<span :class="{ 'text-success': configuration === 'valid', 'text-danger': configuration === 'broken' }">
{{ $t(configuration) }}
</span>
</b-col>
</b-row>
</b-card>
</card>
<!-- LOGS CARD -->
<b-card>
<template v-slot:header>
<div class="d-sm-flex justify-content-sm-between">
<h2><icon iname="book" /> {{ $t('logs') }}</h2>
<b-button variant="success" @click="shareLogs" class="mt-2 mt-sm-0">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</div>
<card :title="$t('logs')" icon="book" button-unbreak="sm">
<template #header-buttons>
<b-button variant="success" @click="shareLogs">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</template>
<div class="w-100" v-for="{ filename, content} in logs" :key="filename">
<h3>{{ filename }}</h3>
<pre class="bg-light p-3"><code>{{ content }}</code></pre>
</div>
</b-card>
<template v-for="({ filename, content }, i) in logs">
<h3 :key="i + '-filename'">
{{ filename }}
</h3>
<!-- ACTIONS CONFIRMATION MODAL -->
<b-modal
v-if="action"
id="action-confirm-modal" centered
body-bg-variant="danger" body-text-variant="light"
@ok="updateService" hide-header
>
{{ $t(`confirm_service_${action}`, { name }) }}
</b-modal>
</div>
<pre :key="i + '-content'" class="log"><code>{{ content }}</code></pre>
</template>
</card>
</view-base>
</template>
<script>
@ -96,68 +76,58 @@ export default {
name: 'ServiceInfo',
props: {
name: {
type: String,
required: true
}
name: { type: String, required: true }
},
data () {
return {
queries: [
'services/' + this.name,
`services/${this.name}/log?number=50`
],
// Service data
status: undefined,
description: '',
configuration: '',
last_state_change: 0,
start_on_boot: undefined,
infos: undefined,
uptime: undefined,
isCritical: undefined,
logs: undefined,
// Modal action
action: undefined,
critical: undefined
action: undefined
}
},
filters: {
distanceToNow
},
computed: {
},
methods: {
fetchData () {
// simply use the api helper since we will not store the request's result.
api.getAll([
'services/' + this.name,
`services/${this.name}/log?number=50`
]).then(([service, logs]) => {
this.critical = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(this.name)
if (service.last_state_change === 'unknown') {
service.last_state_change = 0
}
for (const key in service) {
this[key] = service[key]
}
this.logs = Object.keys(logs).sort((prev, curr) => {
if (prev === 'journalctl') return -1
else if (curr === 'journalctl') return 1
else if (prev < curr) return -1
else return 1
}).map(filename => ({ content: logs[filename].join('\n'), filename }))
})
formatServiceData (
// eslint-disable-next-line
{ status, description, start_on_boot, last_state_change, configuration },
logs
) {
this.isCritical = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(this.name)
// eslint-disable-next-line
this.uptime = last_state_change === 'unknown' ? 0 : last_state_change
this.infos = { description, status, start_on_boot, configuration }
this.logs = Object.keys(logs).sort((prev, curr) => {
if (prev === 'journalctl') return -1
else if (curr === 'journalctl') return 1
else if (prev < curr) return -1
else return 1
}).map(filename => ({ content: logs[filename].join('\n'), filename }))
},
updateService () {
if (!['start', 'restart', 'stop'].includes(this.action)) return
const method = this.action === 'stop' ? 'delete' : 'put'
const uri = this.action === 'restart'
async updateService (action) {
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_service_' + action, { name: this.name })
)
if (!confirmed) return
if (!['start', 'restart', 'stop'].includes(action)) return
const method = action === 'stop' ? 'delete' : 'put'
const uri = action === 'restart'
? `services/${this.name}/restart`
: 'services/' + this.name
// FIXME API doesn't return anything to the PUT so => json err
api[method](uri).then(() => {
this.fetchData()
})
api[method](uri).then(this.$refs.view.fetchQueries)
},
shareLogs () {
@ -178,11 +148,16 @@ export default {
}
},
created () {
this.fetchData()
}
filters: { distanceToNow }
}
</script>
<style lang="scss" scoped>
h3 {
margin-bottom: 1rem;
&:not(:first-of-type) {
margin-top: 2rem;
}
}
</style>

View file

@ -1,24 +1,25 @@
<template>
<search-view
<view-search
id="service-list"
:search.sync="search"
:items="services"
:filtered-items="filteredServices"
items-name="services"
:queries="queries"
@queries-response="formatServices"
>
<b-list-group v-if="filteredServices">
<b-list-group>
<b-list-group-item
v-for="{ name, description, status, last_state_change } in filteredServices"
:key="name || service"
v-for="{ name, description, status, last_state_change } in filteredServices" :key="name"
:to="{ name: 'service-info', params: { name }}"
class="d-flex justify-content-between align-items-center pr-0"
>
<div class="w-100">
<div>
<h5 class="font-weight-bold">
{{ name }}
<small class="text-secondary">{{ description }}</small>
</h5>
<p class="mb-0">
<p class="m-0">
<span :class="status === 'running' ? 'text-success' : 'text-danger'">
<icon :iname="status === 'running' ? 'check-circle' : 'times'" />
{{ $t(status) }}
@ -26,22 +27,22 @@
{{ $t('since') }} {{ last_state_change | distanceToNow }}
</p>
</div>
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</search-view>
</view-search>
</template>
<script>
import api from '@/api'
import { distanceToNow } from '@/helpers/filters/date'
import SearchView from '@/components/SearchView'
export default {
name: 'ServiceList',
data () {
return {
queries: ['services'],
search: '',
services: undefined
}
@ -54,41 +55,31 @@ export default {
const services = this.services.filter(({ name }) => {
return name.toLowerCase().includes(search)
})
return services.length > 0 ? services : null
return services.length ? services : null
}
},
methods: {
fetchData () {
// simply use the api helper since we will not store the request's result.
api.get('services').then(servicesData => {
this.services = Object.keys(servicesData).sort().map(name => {
const service = servicesData[name]
if (service.last_state_change === 'unknown') {
service.last_state_change = 0
}
return { ...service, name }
})
formatServices (services) {
this.services = Object.keys(services).sort().map(name => {
const service = services[name]
if (service.last_state_change === 'unknown') {
service.last_state_change = 0
}
return { ...service, name }
})
}
},
created () {
this.fetchData()
},
components: { SearchView },
filters: {
distanceToNow
}
filters: { distanceToNow }
}
</script>
<style lang="scss" scoped>
@include media-breakpoint-down(sm) {
@include media-breakpoint-down(md) {
h5 small {
display: block;
margin-top: .25rem;
}
}
</style>

View file

@ -4,13 +4,7 @@
:server-error="serverError"
@submit="onSubmit"
:extra="extra"
>
<template #extra-fields="{ v, fields, form }">
<!-- CURRENT ADMIN PASSWORD -->
<form-field v-bind="fields.currentPassword" v-model="form.currentPassword" :validation="v.form.currentPassword" />
<hr>
</template>
</password-form>
/>
</template>
<script>
@ -20,7 +14,6 @@ import { validationMixin } from 'vuelidate'
import { PasswordForm } from '@/components/reusableForms'
import { required, minLength } from '@/helpers/validators'
export default {
name: 'ToolAdminpw',
@ -67,9 +60,6 @@ export default {
},
mixins: [validationMixin],
components: {
PasswordForm
}
components: { PasswordForm }
}
</script>

View file

@ -1,30 +1,30 @@
<template>
<div class="tool-log">
<view-base
:queries="queries" @queries-response="formatFirewallData"
ref="view" skeleton="card-form-skeleton"
>
<!-- PORTS -->
<b-card>
<template v-slot:header>
<h2><icon iname="shield" /> {{ $t('ports') }}</h2>
</template>
<card :title="$t('ports')" icon="shield">
<div v-for="(items, protocol) in protocols" :key="protocol">
<h5>{{ $t(protocol) }}</h5>
<b-table
:fields="fields" :items="items"
small striped responsive="true"
small striped responsive
>
<!-- PORT CELL -->
<template v-slot:cell(port)="data">
<template #cell(port)="data">
{{ data.value }}
</template>
<!-- CONNECTIONS CELL -->
<template v-slot:cell()="data">
<template #cell()="data">
<b-checkbox
v-if="data.field.key !== 'uPnP'"
class="on-off-switch"
v-model="data.value"
switch
@change="onToggle(protocol, data.field.key, data.item.port, data.index, $event)"
@change="onTablePortToggling(data.item.port, protocol, data.field.key, data.index, $event)"
>
<span :class="'btn btn-sm py-0 btn-' + (data.value ? 'danger' : 'success')">
{{ $t(data.value ? 'close' : 'open') }}
@ -39,108 +39,69 @@
</template>
</b-table>
</div>
</b-card>
</card>
<!-- OPERATIONS -->
<b-card>
<template v-slot:header>
<h2><icon iname="cogs" /> {{ $t('operations') }}</h2>
</template>
<b-form
id="port-form" inline class="d-flex justify-content-between"
@submit.prevent="onFormSubmit"
>
<b-input-group :prepend="$t('action')">
<b-select
id="input-action"
v-model="form.action" :options="actionChoices"
/>
</b-input-group>
<card-form
:title="$t('operations')" icon="cogs"
:validation="$v" :server-error="serverError"
@submit.prevent="onFormPortToggling"
inline form-classes="d-flex justify-content-between align-items-start"
>
<b-input-group :prepend="$t('action')">
<b-select v-model="form.action" :options="actionChoices" />
</b-input-group>
<form-field :validation="$v.form.port">
<b-input-group :prepend="$t('port')">
<b-input
id="input-port" placeholder="0"
type="number" min="0" max="65535"
v-model.number="form.port"
<input-item
id="input-port" placeholder="0" type="number"
v-model="form.port"
/>
</b-input-group>
</form-field>
<b-input-group :prepend="$t('connection')">
<b-select
id="input-connection"
v-model="form.connection" :options="connectionChoices"
/>
</b-input-group>
<b-input-group :prepend="$t('connection')">
<b-select v-model="form.connection" :options="connectionChoices" id="input-connection" />
</b-input-group>
<b-input-group :prepend="$t('protocol')">
<b-select
id="input-protocol"
v-model="form.protocol" :options="protocolChoices"
/>
</b-input-group>
</b-form>
<template v-slot:footer>
<b-button type="submit" form="port-form" variant="success">
{{ $t('save') }}
</b-button>
</template>
</b-card>
<b-input-group :prepend="$t('protocol')">
<b-select v-model="form.protocol" :options="protocolChoices" id="input-protocol" />
</b-input-group>
</card-form>
<!-- UPnP -->
<b-card :body-text-variant="upnpEnabled ? 'success' : 'danger'">
<template v-slot:header>
<h2><icon iname="exchange" /> {{ $t('upnp') }}</h2>
</template>
<card :title="$t('upnp')" icon="exchange" :body-text-variant="upnpEnabled ? 'success' : 'danger'">
{{ $t(upnpEnabled ? 'upnp_enabled' : 'upnp_disabled' ) }}
<b-form-invalid-feedback :state="upnpError !== '' ? false : null">
{{ upnpError }}
</b-form-invalid-feedback>
<template v-slot:footer>
<b-button
:variant="!upnpEnabled ? 'success' : 'danger'"
v-b-modal.toggle-upnp-modal
>
<template #buttons>
<b-button @click="toggleUpnp" :variant="!upnpEnabled ? 'success' : 'danger'">
{{ $t(!upnpEnabled ? 'enable' : 'disable' ) }}
</b-button>
</template>
</b-card>
<!-- TOGGLE PORT CONFIRM MODAL -->
<b-modal
no-close-on-backdrop centered hide-header
body-bg-variant="danger" body-text-variant="light"
@ok="togglePort(portToToggle)" ref="modal"
@cancel="onCancel"
>
{{ portToToggle ? $t('confirm_firewall_' + portToToggle.action, portToToggle) : '' }}
</b-modal>
<!-- TOGGLE UPNP CONFIRM MODAL -->
<b-modal
id="toggle-upnp-modal"
no-close-on-backdrop centered hide-header
body-bg-variant="danger" body-text-variant="light"
@ok="toggleUpnp(!upnpEnabled)"
>
{{ $t('confirm_upnp_' + (upnpEnabled ? 'disable' : 'enable')) }}
</b-modal>
</div>
</card>
</view-base>
</template>
<script>
import { validationMixin } from 'vuelidate'
import api from '@/api'
import { required, integer, between } from '@/helpers/validators'
export default {
name: 'ToolFirewall',
data () {
return {
// Tables data
queries: ['/firewall?raw'],
serverError: '',
// Ports tables data
fields: [
{ key: 'port', label: this.$i18n.t('port') },
{ key: 'ipv4', label: this.$i18n.t('ipv4') },
@ -150,7 +111,7 @@ export default {
protocols: undefined,
portToToggle: undefined,
// Form data
// Ports form data
actionChoices: [
{ value: 'open', text: this.$i18n.t('open') },
{ value: 'close', text: this.$i18n.t('close') }
@ -177,88 +138,93 @@ export default {
}
},
validations: {
form: {
port: { number: required, integer, between: between(0, 65535) }
}
},
methods: {
fetchData () {
api.get('/firewall?raw').then(data => {
const ports = Object.values(data).reduce((ports, protocols) => {
for (const type of ['TCP', 'UDP']) {
for (const port of protocols[type]) {
ports[type].add(port)
}
formatFirewallData (data) {
const ports = Object.values(data).reduce((ports, protocols) => {
for (const type of ['TCP', 'UDP']) {
for (const port of protocols[type]) {
ports[type].add(port)
}
return ports
}, { TCP: new Set(), UDP: new Set() })
const tables = {
TCP: [],
UDP: []
}
for (const protocol of ['TCP', 'UDP']) {
for (const port of ports[protocol]) {
const row = { port }
for (const connection of ['ipv4', 'ipv6', 'uPnP']) {
row[connection] = data[connection][protocol].includes(port)
}
tables[protocol].push(row)
return ports
}, { TCP: new Set(), UDP: new Set() })
const tables = {
TCP: [],
UDP: []
}
for (const protocol of ['TCP', 'UDP']) {
for (const port of ports[protocol]) {
const row = { port }
for (const connection of ['ipv4', 'ipv6', 'uPnP']) {
row[connection] = data[connection][protocol].includes(port)
}
tables[protocol].sort((a, b) => a.port < b.port ? -1 : 1)
tables[protocol].push(row)
}
tables[protocol].sort((a, b) => a.port < b.port ? -1 : 1)
}
this.protocols = tables
this.upnpEnabled = data.uPnP.enabled
this.protocols = tables
this.upnpEnabled = data.uPnP.enabled
},
togglePort ({ action, port, protocol, connection }) {
return new Promise((resolve, reject) => {
this.$askConfirmation(
this.$i18n.t('confirm_firewall_' + action, { port, protocol, connection })
).then(confirmed => {
if (confirmed) {
const method = action === 'open' ? 'post' : 'delete'
api[method](`/firewall/port?${connection}_only`, { port, protocol }).then(() => {
resolve(confirmed)
}).catch(error => {
reject(error)
})
} else {
resolve(confirmed)
}
})
})
},
togglePort ({ port, protocol, connection, action, index }) {
const method = action === 'open' ? 'post' : 'delete'
api[method](`/firewall/port?${connection}_only`, { port, protocol }).then(() => {
if (index === -1) this.fetchData()
this.portToToggle = undefined
}).catch((err) => {
console.log(err)
})
},
async toggleUpnp (value) {
const action = this.upnpEnabled ? 'disable' : 'enable'
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_upnp_' + action))
if (!confirmed) return
toggleUpnp (value) {
api.get('firewall/upnp?action=' + (value ? 'enable' : 'disable')).then(r => {
api.get('firewall/upnp?action=' + action).then(() => {
// FIXME Couldn't test when it works.
this.fetchData()
this.$refs.view.fetchQueries()
}).catch(err => {
this.upnpError = err.message
})
},
onCancel () {
const { protocol, index, connection, value } = this.portToToggle
if (index > -1) {
this.$set(this.protocols[protocol][index], connection, !value)
}
this.portToToggle = undefined
},
onToggle (protocol, connection, port, index, value) {
onTablePortToggling (port, protocol, connection, index, value) {
this.$set(this.protocols[protocol][index], connection, value)
this.portToToggle = {
protocol, connection, port, action: value ? 'open' : 'close', index, value
}
this.$refs.modal.show()
const action = value ? 'open' : 'close'
this.togglePort({ action, port, protocol, connection }).then(toggled => {
// Revert change on cancel
if (!toggled) {
this.$set(this.protocols[protocol][index], connection, !value)
}
})
},
onFormSubmit (e) {
// IMPROVEMENT: could check if ports are already opened for known ports (tricky with protocol='Both')
this.portToToggle = {
...this.form,
value: this.form.action === 'open',
// set index to -1 to trigger `this.fetchData` at modal `@ok`
index: -1
}
this.$refs.modal.show()
onFormPortToggling (e) {
this.togglePort(this.form).then(toggled => {
if (toggled) this.$refs.view.fetchQueries()
})
}
},
created () {
this.fetchData()
}
mixins: [validationMixin]
}
</script>
@ -293,16 +259,17 @@ export default {
}
}
form {
::v-deep form {
margin-bottom: -1rem;
.input-group {
margin-bottom: 1rem
& > * {
margin-bottom: 1rem;
}
@include media-breakpoint-down(xs) {
fieldset {
width: 100%;
}
}
}
.card-footer {
display: flex;
justify-content: flex-end;
}
</style>

View file

@ -1,38 +1,33 @@
<!-- FIXME make a component shared with Home.vue ? -->
<template>
<div class="tools-menu">
<b-list-group class="menu-list">
<b-list-group-item
v-for="item in menu"
:key="item.id"
:to="{name: item.routeName}"
>
<icon :iname="item.icon" class="lg" />
<h2>{{ $t(item.translation) }}</h2>
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</div>
<b-list-group class="menu-list">
<b-list-group-item
v-for="item in menu"
:key="item.routeName"
:to="{name: item.routeName}"
>
<icon :iname="item.icon" class="lg" />
<h2>{{ $t(item.translation) }}</h2>
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</template>
<script>
export default {
name: 'ToolList',
data: () => {
data () {
return {
menu: [
{ id: 0, routeName: 'tool-logs', icon: 'book', translation: 'logs' },
{ id: 1, routeName: 'tool-migrations', icon: 'share', translation: 'migrations' },
{ id: 2, routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
{ id: 3, routeName: 'tool-adminpw', icon: 'key-modern', translation: 'tools_adminpw' },
{ id: 4, routeName: 'tool-webadmin', icon: 'cog', translation: 'tools_webadmin_settings' },
{ id: 5, routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' }
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
{ routeName: 'tool-migrations', icon: 'share', translation: 'migrations' },
{ routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
{ routeName: 'tool-adminpw', icon: 'key-modern', translation: 'tools_adminpw' },
{ routeName: 'tool-webadmin', icon: 'cog', translation: 'tools_webadmin_settings' },
{ routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' }
]
}
}
}
</script>
<style lang="scss" scoped>
</style>

View file

@ -1,20 +1,21 @@
<template>
<div class="tool-log">
<view-base
:queries="queries" @queries-response="formatLogData"
ref="view" skeleton="card-info-skeleton"
>
<!-- INFO CARD -->
<b-card>
<template v-slot:header>
<h2><icon iname="info-circle" /> {{ description }}</h2>
</template>
<card :title="description" icon="info-circle">
<b-row
v-for="(value, prop) in info" :key="prop"
no-gutters class="row-line"
>
<b-col cols="auto" md="3">
<b-col md="3" xl="2">
<strong>{{ $t('logs_' + prop) }}</strong>
</b-col>
<b-col>
<span v-if="prop.endsWith('_at')">{{ value | readableDate }}</span>
<div v-else-if="prop === 'suboperations'">
<div v-for="operation in value" :key="operation.name">
<icon v-if="!operation.success" iname="times" class="text-danger" />
@ -23,44 +24,39 @@
</b-link>
</div>
</div>
<span v-else>{{ value }}</span>
</b-col>
</b-row>
</b-card>
</card>
<b-alert
v-if="info.error" variant="danger" show
class="my-5"
>
<icon iname="exclamation-circle" /> <span v-html="$t('operation_failed_explanation')" />
</b-alert>
<div v-if="info.error" class="alert alert-danger my-5">
<icon iname="exclamation-circle" /> {{ $t('operation_failed_explanation') }}
</div>
<!-- LOGS CARD -->
<b-card class="log">
<template v-slot:header>
<div class="d-sm-flex justify-content-sm-between">
<h2><icon iname="file-text" /> {{ $t('logs') }}</h2>
<b-button @click="shareLogs" variant="success">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</div>
<card :title="$t('logs')" icon="file-text" no-body>
<template #header-buttons>
<b-button @click="shareLogs" variant="success">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</template>
<b-button
v-if="moreLogsAvailable"
variant="white" class="w-100 rounded-0"
@click="fetchData"
@click="$ref.view.fetchQueries()"
>
<icon iname="plus" /> {{ $t('logs_more') }}
</b-button>
<pre><code v-html="logs" /></pre>
<pre class="log"><code v-html="logs" /></pre>
<b-button @click="shareLogs" variant="success" class="w-100 rounded-0">
<icon iname="cloud-upload" /> {{ $t('logs_share_with_yunopaste') }}
</b-button>
</b-card>
</div>
</card>
</view-base>
</template>
<script>
@ -72,64 +68,60 @@ export default {
name: 'ToolLog',
props: {
name: {
type: String,
required: true
}
name: { type: String, required: true }
},
data () {
return {
// Log data
description: '',
description: undefined,
info: {},
logs: '',
logs: undefined,
// Logs line display
numberOfLines: 25,
moreLogsAvailable: false
}
},
filters: {
readableDate
},
methods: {
fetchData () {
computed: {
queries () {
const queryString = objectToParams({
path: this.name,
filter_irrelevant: '',
with_suboperations: '',
number: this.numberOfLines
})
return ['logs/display?' + queryString]
}
},
api.get('logs/display?' + queryString).then(log => {
if (log.logs.length === this.numberOfLines) {
this.moreLogsAvailable = true
this.numberOfLines *= 10
} else {
this.moreLogsAvailable = false
}
this.description = log.description
methods: {
formatLogData (log) {
if (log.logs.length === this.numberOfLines) {
this.moreLogsAvailable = true
this.numberOfLines *= 10
} else {
this.moreLogsAvailable = false
}
this.description = log.description
const levels = ['ERROR', 'WARNING', 'SUCCESS', 'INFO']
this.logs = log.logs.map(line => {
for (const level of levels) {
if (line.includes(level + ' -')) {
return `<span class="alert-${level === 'ERROR'
? 'danger'
: level.toLowerCase()}">${line}</span>`
}
const levels = ['ERROR', 'WARNING', 'SUCCESS', 'INFO']
this.logs = log.logs.map(line => {
for (const level of levels) {
if (line.includes(level + ' -')) {
return `<span class="alert-${level === 'ERROR'
? 'danger'
: level.toLowerCase()}">${line}</span>`
}
return line
}).join('\n')
const { started_at, ended_at, error, success, suboperations } = log.metadata
const info = { path: log.log_path, started_at, ended_at }
if (!success) info.error = error
if (suboperations) info.suboperations = suboperations
this.info = info
})
}
return line
}).join('\n')
// eslint-disable-next-line
const { started_at, ended_at, error, success, suboperations } = log.metadata
const info = { path: log.log_path, started_at, ended_at }
if (!success) info.error = error
if (suboperations && suboperations.length) info.suboperations = suboperations
this.info = info
},
shareLogs () {
@ -139,8 +131,6 @@ export default {
}
},
created () {
this.fetchData()
}
filters: { readableDate }
}
</script>

View file

@ -1,15 +1,14 @@
<template>
<search-view
id="tool-logs"
<view-search
:search.sync="search"
:items="operations"
:filtered-items="filteredOperations"
items-name="logs"
:queries="queries"
@queries-response="formatLogsData"
skeleton="card-list-skeleton"
>
<b-card no-body>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('logs_operation') }}</h2>
</template>
<card :title="$t('logs_operation')" icon="wrench" no-body>
<b-list-group flush>
<b-list-group-item
v-for="log in filteredOperations" :key="log.name"
@ -21,20 +20,19 @@
{{ log.description }}
</b-list-group-item>
</b-list-group>
</b-card>
</search-view>
</card>
</view-search>
</template>
<script>
import api from '@/api'
import { distanceToNow, readableDate } from '@/helpers/filters/date'
import SearchView from '@/components/SearchView'
export default {
name: 'ServiceList',
name: 'ToolLogs',
data () {
return {
queries: [`logs?limit=${25}&with_details`],
search: '',
operations: undefined
}
@ -47,39 +45,31 @@ export default {
const operations = this.operations.filter(({ description }) => {
return description.toLowerCase().includes(search)
})
return operations.length > 0 ? operations : null
return operations.length ? operations : null
}
},
methods: {
formatLogsData ({ operation }) {
operation.forEach((log, index) => {
if (log.success === '?') {
operation[index].icon = 'question'
operation[index].class = 'warning'
} else if (log.success) {
operation[index].icon = 'check'
operation[index].class = 'success'
} else {
operation[index].icon = 'close'
operation[index].class = 'danger'
}
})
this.operations = operation
}
},
filters: {
distanceToNow,
readableDate
},
methods: {
fetchData () {
api.get(`logs?limit=${25}&with_details`).then(({ operation }) => {
operation.forEach((log, index) => {
if (log.success === '?') {
operation[index].icon = 'question'
operation[index].class = 'warning'
} else if (log.success) {
operation[index].icon = 'check'
operation[index].class = 'success'
} else {
operation[index].icon = 'close'
operation[index].class = 'danger'
}
})
this.operations = operation
})
}
},
created () {
this.fetchData()
},
components: { SearchView }
}
}
</script>

View file

@ -1,37 +1,28 @@
<template>
<div class="tool-log">
<view-base :queries="queries" @queries-response="formatMigrationsData" ref="view">
<!-- PENDING MIGRATIONS -->
<b-card no-body>
<b-card-header class="d-flex align-items-center">
<h2>
<icon iname="cogs" /> {{ $t('migrations_pending') }}
</h2>
<card :title="$t('migrations_pending')" icon="cogs" no-body>
<template #header-buttons v-if="pending">
<b-button size="sm" variant="success" @click="runMigrations">
<icon iname="play" /> {{ $t('run') }}
</b-button>
</template>
<div class="ml-auto" v-if="pending && pending.length">
<b-button size="sm" variant="success" @click="runMigrations">
<icon iname="play" /> {{ $t('run') }}
</b-button>
</div>
</b-card-header>
<b-card-body v-if="pending && !pending.length">
<b-card-body v-if="pending === null">
<span class="text-success">
<icon iname="check-circle" /> {{ $t('migrations_no_pending') }}
</span>
</b-card-body>
<b-list-group flush v-else-if="pending">
<b-list-group v-else-if="pending" flush>
<b-list-group-item
v-for="{ number, description, id, disclaimer } in pending" :key="number"
>
<div class="d-flex align-items-center">
{{ number }}. {{ description }}
<div class="ml-auto" v-if="pending && pending.length">
<b-button
@click="skipId = id" v-b-modal.skip-modal
size="sm" variant="warning"
>
<div class="ml-auto">
<b-button @click="skipMigration(id)" size="sm" variant="warning">
<icon iname="close" /> {{ $t('skip') }}
</b-button>
</div>
@ -58,84 +49,67 @@
</template>
</b-list-group-item>
</b-list-group>
</b-card>
</card>
<!-- DONE MIGRATIONS -->
<b-card no-body>
<b-card-header class="d-flex align-items-center">
<h2><icon iname="cogs" /> {{ $t('migrations_done') }}</h2>
<div class="ml-auto">
<b-button v-b-toggle.collapse-done size="sm" variant="outline-secondary">
<icon iname="chevron-right" /><span class="sr-only">{{ $t('words.collapse') }}</span>
</b-button>
</div>
</b-card-header>
<b-collapse id="collapse-done">
<b-card-body v-if="done && !done.length">
<span class="text-success">
<icon iname="check-circle" /> {{ $t('migrations_no_done') }}
</span>
</b-card-body>
<b-list-group flush v-else-if="done">
<b-list-group-item
v-for="{ number, description } in done" :key="number"
>
{{ number }}. {{ description }}
</b-list-group-item>
</b-list-group>
</b-collapse>
</b-card>
<!-- SKIP MIGRATION CONFIRMATION MODAL -->
<b-modal
id="skip-modal" centered
body-bg-variant="warning"
@ok="skipMigration" hide-header
<card
:title="$t('migrations_done')" icon="cogs"
collapsable collapsed no-body
>
{{ $t('confirm_migrations_skip') }}
</b-modal>
</div>
<b-card-body v-if="done === null">
<span class="text-success">
<icon iname="check-circle" /> {{ $t('migrations_no_done') }}
</span>
</b-card-body>
<b-list-group flush v-else-if="done">
<b-list-group-item v-for="{ number, description } in done" :key="number">
{{ number }}. {{ description }}
</b-list-group-item>
</b-list-group>
</card>
<template #skeleton>
<card-list-skeleton :item-count="3" />
<b-card no-body>
<template #header>
<b-skeleton width="30%" height="36px" class="m-0" />
</template>
</b-card>
</template>
</view-base>
</template>
<script>
import api from '@/api'
// FIXME not tested with pending migrations (disclaimer and stuff)
export default {
name: 'ToolMigrations',
props: {
},
data () {
return {
queries: [
'migrations?pending',
'migrations?done'
],
pending: undefined,
done: undefined,
skipId: undefined,
checked: {}
}
},
methods: {
fetchData () {
api.getAll([
'migrations?pending',
'migrations?done'
]).then(([{ migrations: pending }, { migrations: done }]) => {
this.done = done.reverse()
pending.forEach(migration => {
if (migration.disclaimer) {
migration.disclaimer = migration.disclaimer.replace('\n', '<br>')
this.$set(this.checked, migration.id, null)
}
})
// FIXME change to pending
this.pending = pending.reverse()
formatMigrationsData ({ migrations: pending }, { migrations: done }) {
this.done = done.length ? done.reverse() : null
pending.forEach(migration => {
if (migration.disclaimer) {
migration.disclaimer = migration.disclaimer.replace('\n', '<br>')
this.$set(this.checked, migration.id, null)
}
})
// FIXME change to pending
this.pending = pending.length ? pending.reverse() : null
},
runMigrations () {
@ -147,17 +121,20 @@ export default {
}
// Check that every migration's disclaimer has been checked.
if (Object.values(this.checked).every(value => value === true)) {
api.post('migrations/migrate', { accept_disclaimer: true }).then(this.fetchData)
api.post('migrations/migrate', { accept_disclaimer: true }).then(() => {
this.$refs.view.fetchQueries()
})
}
},
skipMigration () {
api.post('/migrations/migrate', { skip: true, targets: this.skipId }).then(this.fetchData)
}
},
async skipMigration (id) {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_migrations_skip'))
if (!confirmed) return
created () {
this.fetchData()
api.post('/migrations/migrate', { skip: true, targets: id }).then(() => {
this.$refs.view.fetchQueries()
})
}
}
}
</script>

View file

@ -1,31 +1,25 @@
<template>
<div class="tool-power">
<div>
<div v-if="inProcess">
<b-alert variant="info" show v-t="'tools_' + action + '_done'" />
<b-alert variant="info" v-t="'tools_' + action + '_done'" />
<b-alert variant="warning" show>
<icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" /> {{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }}
<b-alert variant="warning">
<icon :iname="action === 'reboot' ? 'refresh' : 'power-off'" />
{{ $t(action === 'reboot' ? 'tools_rebooting' : 'tools_shuttingdown') }}
</b-alert>
<template v-if="canReconnect">
<b-alert variant="success" show v-t="'tools_power_up'" />
<b-alert variant="success" v-t="'tools_power_up'" />
<login-view />
</template>
</div>
<b-card v-else>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('operations') }}</h2>
</template>
<card v-else :title="$t('operations')" icon="wrench">
<!-- REBOOT -->
<b-form-group
label-cols="5" label-cols-md="4" label-cols-lg="3"
:label="$t('tools_reboot')" label-for="reboot"
>
<b-button
variant="danger" id="reboot" v-b-modal.confirm-action
@click="action = 'reboot'"
>
<b-button @click="triggerAction('reboot')" variant="danger" id="reboot">
<icon iname="refresh" /> {{ $t('tools_reboot_btn') }}
</b-button>
</b-form-group>
@ -36,23 +30,11 @@
label-cols="5" label-cols-md="4" label-cols-lg="3"
:label="$t('tools_shutdown')" label-for="shutdown"
>
<b-button
variant="danger" id="shutdown" v-b-modal.confirm-action
@click="action = 'shutdown'"
>
<b-button @click="triggerAction('shutdown')" variant="danger" id="shutdown">
<icon iname="power-off" /> {{ $t('tools_shutdown_btn') }}
</b-button>
</b-form-group>
<!-- REBOOT/SHUTDOWN CONFIRM MODAL -->
<b-modal
centered hide-header
id="confirm-action" body-bg-variant="danger" body-text-variant="light"
@ok="triggerAction(action)"
>
{{ $t('confirm_reboot_action_' + action) }}
</b-modal>
</b-card>
</card>
</div>
</template>
@ -72,7 +54,13 @@ export default {
},
methods: {
triggerAction (action) {
async triggerAction (action) {
const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_reboot_action_' + action)
)
if (!confirmed) return
this.action = action
api.put(action + '?force').then(() => {
// Use 'RESET_CONNECTED' and not 'DISCONNECT' else user will be redirect to login
this.$store.dispatch('RESET_CONNECTED')
@ -100,8 +88,6 @@ export default {
}
},
components: {
LoginView
}
components: { LoginView }
}
</script>

View file

@ -1,12 +1,7 @@
<template>
<card-form
:title="$t('tools_webadmin_settings')" icon="cog"
no-footer
>
<card-form :title="$t('tools_webadmin_settings')" icon="cog" no-footer>
<template v-for="(field, fname) in fields">
<form-field
v-bind="field" v-model="self[fname]" :key="fname"
/>
<form-field v-bind="field" v-model="self[fname]" :key="fname" />
<hr :key="fname + 'hr'">
</template>
</card-form>

View file

@ -1,30 +1,18 @@
<template>
<div class="system-update">
<!-- FIXME add perform update button ? -->
<!-- <div class="actions">
<div class="buttons ml-auto">
<b-button variant="success" @click="performUpdate">
<icon iname="refresh" /> {{ $t('system_update') }}
</b-button>
</div>
</div> -->
<view-base :loading="loading" skeleton="card-list-skeleton">
<!-- MIGRATIONS WARN -->
<b-alert variant="warning" :show="migrationsNotDone">
<icon iname="exclamation-triangle" /> <span v-html="$t('pending_migrations')" />
</b-alert>
<!-- SYSTEM UPGRADE -->
<b-card no-body>
<template v-slot:header>
<h2><icon iname="server" /> {{ $t('system') }}</h2>
</template>
<card :title="$t('system')" icon="server" no-body>
<b-list-group v-if="system" flush>
<b-list-group-item
v-for="{ name, current_version, new_version } in system" :key="name"
>
<h5 class="m-0">{{ name }} <small>({{ $t('from_to', [current_version, new_version]) }})</small></h5>
<b-list-group-item v-for="{ name, current_version, new_version } in system" :key="name">
<h5 class="m-0">
{{ name }}
<small>({{ $t('from_to', [current_version, new_version]) }})</small>
</h5>
</b-list-group-item>
</b-list-group>
@ -32,34 +20,29 @@
<span class="text-success"><icon iname="check-circle" /> {{ $t('system_packages_nothing') }}</span>
</b-card-body>
<template v-if="system" v-slot:footer>
<div class="d-flex justify-content-end">
<b-button
v-b-modal.confirm-upgrade variant="success"
v-t="'system_upgrade_all_packages_btn'"
@click="action = ['system']"
/>
</div>
<template #buttons v-if="system">
<b-button
variant="success" v-t="'system_upgrade_all_packages_btn'"
@click="performUpgrade({ type: 'system' })"
/>
</template>
</b-card>
</card>
<!-- APPS UPGRADE -->
<b-card no-body>
<template v-slot:header>
<h2><icon iname="cubes" /> {{ $t('applications') }}</h2>
</template>
<card :title="$t('applications')" icon="cubes" no-body>
<b-list-group v-if="apps" flush>
<b-list-group-item
v-for="{ label, id, current_version, new_version } in apps" :key="id"
class="d-flex justify-content-between align-items-center"
>
<h5 class="m-0">{{ label }} <small>({{ id }}) {{ $t('from_to', [current_version, new_version]) }}</small></h5>
<h5 class="m-0">
{{ label }}
<small>({{ id }}) {{ $t('from_to', [current_version, new_version]) }}</small>
</h5>
<b-button
v-b-modal.confirm-upgrade variant="success" size="sm"
v-t="'system_upgrade_btn'"
@click="action = ['specific_app', id]"
variant="success" size="sm" v-t="'system_upgrade_btn'"
@click="performUpgrade({ type: 'specific_app', id })"
/>
</b-list-group-item>
</b-list-group>
@ -68,27 +51,14 @@
<span class="text-success"><icon iname="check-circle" /> {{ $t('system_apps_nothing') }}</span>
</b-card-body>
<template v-if="apps" v-slot:footer>
<div class="d-flex justify-content-end">
<b-button
v-b-modal.confirm-upgrade variant="success"
v-t="'system_upgrade_all_applications_btn'"
@click="action = ['apps']"
/>
</div>
<template #buttons v-if="apps">
<b-button
variant="success" v-t="'system_upgrade_all_applications_btn'"
@click="performUpgrade({ type: 'apps' })"
/>
</template>
</b-card>
<!-- UPGRADE CONFIRM MODAL -->
<b-modal
v-if="action"
id="confirm-upgrade" centered
body-bg-variant="danger" body-text-variant="light"
@ok="performUpgrade" hide-header
>
{{ $t('confirm_update_' + action[0], action[1] ? { app: action[1] } : {}) }}
</b-modal>
</div>
</card>
</view-base>
</template>
<script>
@ -99,9 +69,8 @@ export default {
data () {
return {
action: undefined,
app: undefined,
// api data
loading: true,
// API data
migrationsNotDone: undefined,
system: undefined,
apps: undefined
@ -109,21 +78,11 @@ export default {
},
methods: {
async fetchData () {
api.get('migrations?pending').then(({ migrations }) => {
this.migrationsNotDone = migrations.length !== 0
})
},
async performUpgrade ({ type, id = null }) {
const confirmMsg = this.$i18n.t('confirm_update_' + type, id ? { app: id } : {})
const confirmed = await this.$askConfirmation(confirmMsg)
if (!confirmed) return
performUpdate () {
api.put('update').then(({ apps, system }) => {
this.apps = apps.length ? apps : null
this.system = system.length ? system : null
})
},
performUpgrade () {
const [type, id] = this.action
const uri = type === 'specific_app'
? 'upgrade/apps?app=' + id
: 'upgrade?' + type
@ -135,9 +94,17 @@ export default {
},
created () {
// FIXME Do not perform directly the update ?
this.performUpdate()
this.fetchData()
// Since we need to query a `PUT` method, we won't use ViewBase's `queries` prop and
// its automatic loading handling.
Promise.all([
api.get('migrations?pending'),
api.put('update')
]).then(([{ migrations }, { apps, system }]) => {
this.migrationsNotDone = migrations.length !== 0
this.apps = apps.length ? apps : null
this.system = system.length ? system : null
this.loading = false
})
}
}
</script>

View file

@ -1,60 +1,62 @@
<template>
<card-form
:title="$t('users_new')" icon="user-plus"
:validation="$v" :server-error="serverError"
@submit.prevent="onSubmit"
>
<!-- USER NAME -->
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
<!-- USER FULLNAME -->
<form-field
v-bind="fields.fullname" :validation="$v.form.fullname"
<view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
<card-form
:title="$t('users_new')" icon="user-plus"
:validation="$v" :server-error="serverError"
@submit.prevent="onSubmit"
>
<template #default="{ self }">
<b-input-group>
<template v-for="fname in ['firstname', 'lastname']">
<b-input-group-prepend :key="fname + 'prepend'">
<b-input-group-text :id="fname + '-label'" tag="label">
{{ self[fname].label }}
<!-- USER NAME -->
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
<!-- USER FULLNAME -->
<form-field
v-bind="fields.fullname" :validation="$v.form.fullname"
>
<template #default="{ self }">
<b-input-group>
<template v-for="fname in ['firstname', 'lastname']">
<b-input-group-prepend :key="fname + 'prepend'">
<b-input-group-text :id="fname + '-label'" tag="label">
{{ self[fname].label }}
</b-input-group-text>
</b-input-group-prepend>
<input-item
v-bind="self[fname]" v-model="form.fullname[fname]" :key="fname + 'input'"
:name="self[fname].id" :aria-labelledby="fname + '-label'"
/>
</template>
</b-input-group>
</template>
</form-field>
<hr>
<!-- USER MAIL DOMAIN -->
<form-field v-bind="fields.domain" :validation="$v.form.domain">
<template #default="{ self }">
<b-input-group>
<b-input-group-append>
<b-input-group-text id="local-part" tag="label" class="border-right-0">
{{ form.username }}@
</b-input-group-text>
</b-input-group-prepend>
</b-input-group-append>
<input-item
v-bind="self[fname]" v-model="form.fullname[fname]" :key="fname + 'input'"
:name="self[fname].id" :aria-labelledby="fname + '-label'"
<select-item
aria-labelledby="local-part" aria-describedby="mail__BV_description_"
v-model="form.domain" v-bind="self"
/>
</template>
</b-input-group>
</template>
</form-field>
<hr>
</b-input-group>
</template>
</form-field>
<hr>
<!-- USER MAIL DOMAIN -->
<form-field v-bind="fields.domain" :validation="$v.form.domain">
<template #default="{ self }">
<b-input-group>
<b-input-group-append>
<b-input-group-text id="local-part" tag="label" class="border-right-0">
{{ form.username }}@
</b-input-group-text>
</b-input-group-append>
<!-- USER PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
<select-item
aria-labelledby="local-part" aria-describedby="mail__BV_description_"
v-model="form.domain" v-bind="self"
/>
</b-input-group>
</template>
</form-field>
<hr>
<!-- USER PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
<!-- USER PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form>
<!-- USER PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form>
</view-base>
</template>
<script>
@ -66,14 +68,17 @@ import {
alphalownum_, unique, required, minLength, name, sameAs
} from '@/helpers/validators'
export default {
name: 'UserCreate',
mixins: [validationMixin],
data () {
return {
queries: [
{ uri: 'users' },
{ uri: 'domains' },
{ uri: 'domains/main', storeKey: 'main_domain' }
],
form: {
username: '',
fullname: {
@ -162,6 +167,11 @@ export default {
},
methods: {
onQueriesResponse () {
this.fields.domain.props.choices = this.domainsAsChoices
this.form.domain = this.mainDomain
},
onSubmit () {
const data = formatFormData(this.form, { flatten: true })
this.$store.dispatch(
@ -174,14 +184,7 @@ export default {
}
},
created () {
this.$store.dispatch('FETCH_ALL',
[{ uri: 'domains' }, { uri: 'users' }, { uri: 'domains/main', storeKey: 'main_domain' }]
).then(([domains]) => {
this.fields.domain.props.choices = this.domainsAsChoices
this.form.domain = this.mainDomain
})
}
mixins: [validationMixin]
}
</script>

View file

@ -1,108 +1,110 @@
<template lang="html">
<card-form
:title="$t('user_username_edit', { name })" icon="user"
:validation="$v" :server-error="serverError"
@submit.prevent="onSubmit"
>
<!-- USERNAME (disabled) -->
<form-field v-bind="fields.username" />
<template>
<view-base :queries="queries" @queries-response="onQueriesResponse" skeleton="card-form-skeleton">
<card-form
:title="$t('user_username_edit', { name })" icon="user"
:validation="$v" :server-error="serverError"
@submit.prevent="onSubmit"
>
<!-- USERNAME (disabled) -->
<form-field v-bind="fields.username" />
<!-- USER FULLNAME (FIXME quite a mess, but will be removed)-->
<form-field v-bind="fields.fullname" :validation="$v.form.fullname">
<template #default="{ self }">
<b-input-group>
<template v-for="name_ in ['firstname', 'lastname']">
<b-input-group-prepend :key="name_ + 'prepend'">
<b-input-group-text :id="name_ + '-label'" tag="label">
{{ self[name_].label }}
</b-input-group-text>
</b-input-group-prepend>
<!-- USER FULLNAME (FIXME quite a mess, but will be removed)-->
<form-field v-bind="fields.fullname" :validation="$v.form.fullname">
<template #default="{ self }">
<b-input-group>
<template v-for="name_ in ['firstname', 'lastname']">
<b-input-group-prepend :key="name_ + 'prepend'">
<b-input-group-text :id="name_ + '-label'" tag="label">
{{ self[name_].label }}
</b-input-group-text>
</b-input-group-prepend>
<input-item
v-bind="self[name_]" v-model.trim="form.fullname[name_]" :key="name_ + 'input'"
:name="self[name_].id" :aria-labelledby="name_ + '-label'"
:state="$v.form.fullname[name_].$invalid && $v.form.fullname.$anyDirty ? false : null"
/>
</template>
</b-input-group>
</template>
</form-field>
<hr>
<input-item
v-bind="self[name_]" v-model.trim="form.fullname[name_]" :key="name_ + 'input'"
:name="self[name_].id" :aria-labelledby="name_ + '-label'"
:state="$v.form.fullname[name_].$invalid && $v.form.fullname.$anyDirty ? false : null"
/>
</template>
</b-input-group>
</template>
</form-field>
<hr>
<!-- USER EMAIL -->
<form-field v-bind="fields.mail" :validation="$v.form.mail">
<template #default="{ self }">
<adress-input-select v-bind="self" v-model="form.mail" />
</template>
</form-field>
<!-- USER EMAIL -->
<form-field v-bind="fields.mail" :validation="$v.form.mail">
<template #default="{ self }">
<adress-input-select v-bind="self" v-model="form.mail" />
</template>
</form-field>
<!-- MAILBOX QUOTA -->
<form-field v-bind="fields.mailbox_quota" :validation="$v.form.mailbox_quota">
<template #default="{ self }">
<b-input-group append="M">
<input-item v-bind="self" v-model="form.mailbox_quota" />
</b-input-group>
</template>
</form-field>
<hr>
<!-- MAILBOX QUOTA -->
<form-field v-bind="fields.mailbox_quota" :validation="$v.form.mailbox_quota">
<template #default="{ self }">
<b-input-group append="M">
<input-item v-bind="self" v-model="form.mailbox_quota" />
</b-input-group>
</template>
</form-field>
<hr>
<!-- MAIL ALIASES -->
<form-field :label="$t('user_emailaliases')" id="mail-aliases">
<div
v-for="(mail, i) in form.mail_aliases" :key="i"
class="mail-list"
>
<form-field
v-bind="fields.mail_aliases"
:id="'mail_aliases' + i"
:validation="$v.form.mail_aliases.$each[i]"
<!-- MAIL ALIASES -->
<form-field :label="$t('user_emailaliases')" id="mail-aliases">
<div
v-for="(mail, i) in form.mail_aliases" :key="i"
class="mail-list"
>
<template #default="{ self }">
<adress-input-select v-bind="self" v-model="form.mail_aliases[i]" />
</template>
</form-field>
<form-field
v-bind="fields.mail_aliases"
:id="'mail_aliases' + i"
:validation="$v.form.mail_aliases.$each[i]"
>
<template #default="{ self }">
<adress-input-select v-bind="self" v-model="form.mail_aliases[i]" />
</template>
</form-field>
<b-button variant="danger" @click="removeEmailField('aliases', i)">
<icon :title="$t('delete')" iname="trash-o" />
<span class="sr-only">{{ $t('delete') }}</span>
<b-button variant="danger" @click="removeEmailField('aliases', i)">
<icon :title="$t('delete')" iname="trash-o" />
<span class="sr-only">{{ $t('delete') }}</span>
</b-button>
</div>
<b-button variant="success" @click="addEmailField('aliases')">
<icon iname="plus" /> {{ $t('user_emailaliases_add') }}
</b-button>
</div>
</form-field>
<b-button variant="success" @click="addEmailField('aliases')">
<icon iname="plus" /> {{ $t('user_emailaliases_add') }}
</b-button>
</form-field>
<!-- MAIL FORWARD -->
<form-field :label="$t('user_emailforward')" id="mail-forward">
<div
v-for="(mail, i) in form.mail_forward" :key="i"
class="mail-list"
>
<form-field
v-bind="fields.mail_forward" v-model="form.mail_forward[i]"
:id="'mail-forward' + i"
:validation="$v.form.mail_forward.$each[i]"
/>
<!-- MAIL FORWARD -->
<form-field :label="$t('user_emailforward')" id="mail-forward">
<div
v-for="(mail, i) in form.mail_forward" :key="i"
class="mail-list"
>
<form-field
v-bind="fields.mail_forward" v-model="form.mail_forward[i]"
:id="'mail-forward' + i"
:validation="$v.form.mail_forward.$each[i]"
/>
<b-button variant="danger" @click="removeEmailField('forward', i)">
<icon :title="$t('delete')" iname="trash-o" />
<span class="sr-only">{{ $t('delete') }}</span>
</b-button>
</div>
<b-button variant="danger" @click="removeEmailField('forward', i)">
<icon :title="$t('delete')" iname="trash-o" />
<span class="sr-only">{{ $t('delete') }}</span>
<b-button variant="success" @click="addEmailField('forward')">
<icon iname="plus" /> {{ $t('user_emailforward_add') }}
</b-button>
</div>
</form-field>
<hr>
<b-button variant="success" @click="addEmailField('forward')">
<icon iname="plus" /> {{ $t('user_emailforward_add') }}
</b-button>
</form-field>
<hr>
<!-- USER PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
<!-- USER PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
<!-- USER PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form>
<!-- USER PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form>
</view-base>
</template>
<script>
@ -127,7 +129,11 @@ export default {
data () {
return {
ready: false,
queries: [
{ uri: 'users', param: this.name, storeKey: 'users_details' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
],
form: {
fullname: { firstname: '', lastname: '' },
@ -236,6 +242,27 @@ export default {
},
methods: {
onQueriesResponse (user) {
this.fields.mail.props.choices = this.domainsAsChoices
this.fields.mail_aliases.props.choices = this.domainsAsChoices
this.form.fullname = {
// Copy value to avoid refering to the stored user data
firstname: user.firstname.valueOf(),
lastname: user.lastname.valueOf()
}
this.form.mail = adressToFormValue(user.mail)
if (user['mail-aliases']) {
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
}
if (user['mail-forward']) {
this.form.mail_forward = user['mail-forward'].slice() // Copy value
}
if (user['mailbox-quota'].limit !== 'No quota') {
this.form.mailbox_quota = sizeToM(user['mailbox-quota'].limit)
}
},
onSubmit () {
const formData = formatFormData(this.form, { flatten: true })
const user = this.user(this.name)
@ -280,8 +307,9 @@ export default {
? { localPart: '', separator: '@', domain: this.mainDomain }
: ''
)
// Focus last input after rendering update
this.$nextTick(() => {
const inputs = document.querySelectorAll(`#mail-${type} input`)
const inputs = this.$el.querySelectorAll(`#mail-${type} input`)
inputs[inputs.length - 1].focus()
})
},
@ -291,39 +319,8 @@ export default {
}
},
created () {
this.$store.dispatch('FETCH_ALL', [
{ uri: 'users', param: this.name, storeKey: 'users_details' },
{ uri: 'domains/main', storeKey: 'main_domain' },
{ uri: 'domains' }
]).then(([user, mainDomain, domains]) => {
this.fields.mail.props.choices = this.domainsAsChoices
this.fields.mail_aliases.props.choices = this.domainsAsChoices
this.form.fullname = {
// Copy value to avoid refering to the stored user data
firstname: user.firstname.valueOf(),
lastname: user.lastname.valueOf()
}
this.form.mail = adressToFormValue(user.mail)
if (user['mail-aliases']) {
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
}
if (user['mail-forward']) {
this.form.mail_forward = user['mail-forward'].slice() // Copy value
}
if (user['mailbox-quota'].limit !== 'No quota') {
this.form.mailbox_quota = sizeToM(user['mailbox-quota'].limit)
}
this.ready = true
})
},
mixins: [validationMixin],
components: {
AdressInputSelect
}
components: { AdressInputSelect }
}
</script>

View file

@ -1,123 +1,105 @@
<template>
<div class="user">
<b-card :class="{skeleton: !user}">
<template v-slot:header>
<h2>{{ user ? user.fullname : '' }}</h2>
</template>
<div class="d-flex align-items-center">
<view-base :queries="queries" skeleton="card-info-skeleton">
<card v-if="user" :title="user.fullname" icon="user">
<div class="d-flex align-items-center flex-column flex-md-row">
<icon iname="user" class="fa-fw" />
<div class="w-100">
<template v-if="user">
<b-row>
<b-col><strong>{{ $t('user_username') }}</strong></b-col>
<b-col>{{ user.username }}</b-col>
</b-row>
<b-row>
<b-col><strong>{{ $t('user_username') }}</strong></b-col>
<b-col>{{ user.username }}</b-col>
</b-row>
<b-row>
<b-col><strong>{{ $t('user_email') }}</strong></b-col>
<b-col class="font-italic">
{{ user.mail }}
</b-col>
</b-row>
<b-row>
<b-col><strong>{{ $t('user_email') }}</strong></b-col>
<b-col class="font-italic">
{{ user.mail }}
</b-col>
</b-row>
<b-row>
<b-col><strong>{{ $t('user_mailbox_quota') }}</strong></b-col>
<b-col>{{ user['mailbox-quota'].limit }}</b-col>
</b-row>
<b-row>
<b-col><strong>{{ $t('user_mailbox_quota') }}</strong></b-col>
<b-col>{{ user['mailbox-quota'].limit }}</b-col>
</b-row>
<b-row>
<b-col><strong>{{ $t('user_mailbox_use') }}</strong></b-col>
<b-col>{{ user['mailbox-quota'].use }}</b-col>
</b-row>
<b-row>
<b-col><strong>{{ $t('user_mailbox_use') }}</strong></b-col>
<b-col>{{ user['mailbox-quota'].use }}</b-col>
</b-row>
<b-row v-for="(trad, mailType) in {'mail-aliases': 'user_emailaliases', 'mail-forward': 'user_emailforward'}" :key="mailType">
<b-col><strong>{{ $t(trad) }}</strong></b-col>
<b-row v-for="(trad, mailType) in {'mail-aliases': 'user_emailaliases', 'mail-forward': 'user_emailforward'}" :key="mailType">
<b-col><strong>{{ $t(trad) }}</strong></b-col>
<b-col v-if="user[mailType]">
<ul v-if="user[mailType].length > 1">
<li v-for="(alias, index) in user[mailType]" :key="index">
{{ alias }}
</li>
</ul>
<b-col v-if="user[mailType]">
<ul v-if="user[mailType].length > 1">
<li v-for="(alias, index) in user[mailType]" :key="index">
{{ alias }}
</li>
</ul>
<template v-else-if="user[mailType][0]">
{{ user[mailType][0] }}
</template>
</b-col>
</b-row>
</template>
<!-- skeleton -->
<template v-else>
<b-row v-for="(n, index) in 6" :key="index">
<b-col>
<strong class="rounded" />
</b-col>
<b-col>
<span v-if="n <= 4" class="rounded" />
</b-col>
</b-row>
</template>
<template v-else-if="user[mailType][0]">
{{ user[mailType][0] }}
</template>
</b-col>
</b-row>
</div>
</div>
<template v-slot:footer>
<div class="d-flex d-flex justify-content-end">
<b-button :to="user ? {name: 'user-edit', params: {user: user}} : null"
:variant="user ? 'info' : 'dark'"
>
{{ user ? $t('user_username_edit', {name: user.username}) : '' }}
</b-button>
<b-button :variant="user ? 'danger' : 'dark'" class="ml-2" v-b-modal.delete-modal>
{{ user ? $t('delete') : '' }}
</b-button>
</div>
<template #buttons>
<b-button :to="{ name: 'user-edit', params: { user } }" :variant="user ? 'info' : 'dark'">
<icon iname="edit" />
{{ user ? $t('user_username_edit', {name: user.username}) : '' }}
</b-button>
<b-button v-b-modal.delete-modal :variant="user ? 'danger' : 'dark'">
<icon iname="trash-o" />
{{ user ? $t('delete') : '' }}
</b-button>
</template>
</b-card>
</card>
<b-modal
v-if="user" id="delete-modal" centered
header-bg-variant="danger" header-text-variant="light"
:title="$t('confirm_delete', {name: user.username })"
@ok="deleteUser"
v-if="user"
id="delete-modal" :title="$t('confirm_delete', { name: user.username })" @ok="deleteUser"
header-bg-variant="warning" body-class="" body-bg-variant=""
>
<b-form-group>
<template v-slot:description>
<b-alert variant="warning" show>
<icon iname="exclamation-triangle" /> {{ $t('purge_user_data_warning') }}
</b-alert>
</template>
<b-form-checkbox v-model="purge" class="mb-3">
{{ $t('purge_user_data_checkbox', {name: user.username}) }}
<b-form-checkbox v-model="purge">
{{ $t('purge_user_data_checkbox', { name: user.username }) }}
</b-form-checkbox>
<template #description>
<div class="alert alert-warning">
<icon iname="exclamation-triangle" /> {{ $t('purge_user_data_warning') }}
</div>
</template>
</b-form-group>
</b-modal>
</div>
</view-base>
</template>
<script>
export default {
name: 'UserInfo',
props: {
name: {
type: String,
required: true
}
name: { type: String, required: true }
},
data () {
return {
queries: [{ uri: 'users', param: this.name, storeKey: 'users_details' }],
purge: false
}
},
computed: {
user () {
return this.$store.state.data.users_details[this.name]
return this.$store.getters.user(this.name)
}
},
methods: {
deleteUser () {
const data = this.purge ? { purge: '' } : {}
@ -127,23 +109,11 @@ export default {
this.$router.push({ name: 'user-list' })
})
}
},
created () {
this.$store.dispatch('FETCH',
{ uri: 'users', param: this.name, storeKey: 'users_details' }
)
}
}
</script>
<style lang="scss" scoped>
.card-body > div {
flex-direction: column;
@include media-breakpoint-up(md) {
flex-direction: row;
}
}
.icon.fa-user {
font-size: 10rem;
padding-right: 3rem;
@ -171,30 +141,4 @@ ul {
list-style: none;
}
}
.skeleton {
opacity: 0.5;
h2 {
height: #{2 * 1.2}rem;
}
.col {
& > * {
display: block;
background-color: $skeleton-color;
height: 1.5rem;
max-width: 8rem;
}
strong {
max-width: 12rem;
}
}
button {
height: calc(2.25rem + 2px);
width: 7rem;
}
}
</style>

View file

@ -1,10 +1,10 @@
<template>
<search-view
id="user-list"
<view-search
:search.sync="search"
:items="users"
:filtered-items="filteredUsers"
items-name="users"
:queries="queries"
>
<template #top-bar-buttons>
<b-button variant="info" :to="{ name: 'group-list' }">
@ -36,19 +36,18 @@
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
</b-list-group-item>
</b-list-group>
</search-view>
</view-search>
</template>
<script>
import { mapGetters } from 'vuex'
import SearchView from '@/components/SearchView'
export default {
name: 'UserList',
data () {
return {
queries: [{ uri: 'users' }],
search: ''
}
},
@ -64,12 +63,6 @@ export default {
})
return filtered.length === 0 ? null : filtered
}
},
created () {
this.$store.dispatch('FETCH', { uri: 'users' })
},
components: { SearchView }
}
}
</script>