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

View file

@ -5,14 +5,19 @@
@submit.prevent="onSubmit" @submit.prevent="onSubmit"
> >
<template #disclaimer> <template #disclaimer>
<b-alert variant="warning" show> <p class="alert alert-warning">
{{ $t('good_practices_about_admin_password') }} {{ $t('good_practices_about_admin_password') }}
</b-alert> </p>
<slot name="disclaimer" /> <slot name="disclaimer" />
<hr> <hr>
</template> </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 --> <!-- ADMIN PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.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", "backups_no": "No backup",
"begin": "Begin", "begin": "Begin",
"both": "Both", "both": "Both",
"cancel": "Cancel",
"catalog": "Catalog", "catalog": "Catalog",
"check": "Check", "check": "Check",
"close": "Close", "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.", "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", "run_first_diagnosis": "Run initial diagnosis",
"disable": "Disable", "disable": "Disable",
"disabled": "Disabled",
"dns": "DNS", "dns": "DNS",
"domain_add": "Add domain", "domain_add": "Add domain",
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns'>set my DNS correctly</a>.", "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_desc": "The default domain is the connection domain where users log in.",
"domain_default_longdesc": "This is your default domain.", "domain_default_longdesc": "This is your default domain.",
"domain_delete_longdesc": "Delete this 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_config": "DNS configuration",
"domain_dns_longdesc": "View DNS configuration", "domain_dns_longdesc": "View DNS configuration",
"domain_name": "Domain name", "domain_name": "Domain name",
@ -132,6 +135,7 @@
"domains": "Domains", "domains": "Domains",
"download": "Download", "download": "Download",
"enable": "Enable", "enable": "Enable",
"enabled": "Enabled",
"error": "Error", "error": "Error",
"error_modify_something": "You should modify something", "error_modify_something": "You should modify something",
"error_server_unexpected": "Unexpected server error", "error_server_unexpected": "Unexpected server error",
@ -149,6 +153,7 @@
"form_errors": { "form_errors": {
"alpha": "Value must be alphabetical characters only.", "alpha": "Value must be alphabetical characters only.",
"alphalownum_": "Value must be lower-case alphanumeric and underscore 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", "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", "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)", "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", "unauthorized": "Unauthorized",
"unignore": "Unignore", "unignore": "Unignore",
"uninstall": "Uninstall", "uninstall": "Uninstall",
"unknown": "Unknown",
"unmaintained": "Unmaintained", "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", "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", "upnp": "UPnP",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,9 +1,6 @@
<template> <template>
<div class="domain-info"> <view-base :queries="queries" skeleton="card-list-skeleton">
<b-card> <card :title="name" icon="globe">
<template v-slot:header>
<h2><icon iname="globe" /> {{ name }}</h2>
</template>
<!-- VISIT --> <!-- VISIT -->
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p> <p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p>
<b-button variant="success" :href="'https://' + name" target="_blank"> <b-button variant="success" :href="'https://' + name" target="_blank">
@ -13,16 +10,12 @@
<!-- DEFAULT DOMAIN --> <!-- DEFAULT DOMAIN -->
<p>{{ $t('domain_default_desc') }}</p> <p>{{ $t('domain_default_desc') }}</p>
<template v-if="isMainDomain"> <p v-if="isMainDomain" class="alert alert-info">
<p class="alert alert-info"> <icon iname="star" /> {{ $t('domain_default_longdesc') }}
<icon iname="star" /> {{ $t('domain_default_longdesc') }} </p>
</p> <b-button v-else variant="info" @click="setAsDefaultDomain">
</template> <icon iname="star" /> {{ $t('set_default') }}
<template v-else> </b-button>
<b-button variant="info" v-b-modal.default-domain-modal>
<icon iname="star" /> {{ $t('set_default') }}
</b-button>
</template>
<hr> <hr>
<!-- DNS CONFIG --> <!-- DNS CONFIG -->
@ -41,53 +34,50 @@
<!-- DELETE --> <!-- DELETE -->
<p>{{ $t('domain_delete_longdesc') }}</p> <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') }} <icon iname="trash-o" /> {{ $t('delete') }}
</b-button> </b-button>
</b-card> </card>
</view-base>
<!-- 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>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'
export default { export default {
name: 'DomainInfo', name: 'DomainInfo',
props: { props: {
name: { name: {
type: String, type: String,
required: true required: true
} }
}, },
data () {
return {
queries: [{ uri: 'domains/main', storeKey: 'main_domain' }]
}
},
computed: { computed: {
mainDomain () { ...mapGetters(['mainDomain']),
return this.$store.state.data.main_domain
},
isMainDomain () { isMainDomain () {
if (!this.mainDomain) return if (!this.mainDomain) return
return this.name === this.mainDomain return this.name === this.mainDomain
} }
}, },
methods: { methods: {
deleteDomain () { async deleteDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return
this.$store.dispatch('DELETE', this.$store.dispatch('DELETE',
{ uri: 'domains', param: this.name } { uri: 'domains', param: this.name }
).then(() => { ).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', this.$store.dispatch('PUT',
{ uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' } { uri: 'domains/main', data: { new_main_domain: this.name }, storeKey: 'main_domain' }
).then(() => { ).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) this.$store.commit('UPDATE_MAIN_DOMAIN', this.name)
}) })
} }
},
created () {
this.$store.dispatch('FETCH',
{ uri: 'domains/main', storeKey: 'main_domain' }
)
} }
} }
</script> </script>
<style lang="scss" scoped>
</style>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,13 +4,7 @@
:server-error="serverError" :server-error="serverError"
@submit="onSubmit" @submit="onSubmit"
:extra="extra" :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> </template>
<script> <script>
@ -20,7 +14,6 @@ import { validationMixin } from 'vuelidate'
import { PasswordForm } from '@/components/reusableForms' import { PasswordForm } from '@/components/reusableForms'
import { required, minLength } from '@/helpers/validators' import { required, minLength } from '@/helpers/validators'
export default { export default {
name: 'ToolAdminpw', name: 'ToolAdminpw',
@ -67,9 +60,6 @@ export default {
}, },
mixins: [validationMixin], mixins: [validationMixin],
components: { PasswordForm }
components: {
PasswordForm
}
} }
</script> </script>

View file

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

View file

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

View file

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

View file

@ -1,15 +1,14 @@
<template> <template>
<search-view <view-search
id="tool-logs"
:search.sync="search" :search.sync="search"
:items="operations" :items="operations"
:filtered-items="filteredOperations" :filtered-items="filteredOperations"
items-name="logs" items-name="logs"
:queries="queries"
@queries-response="formatLogsData"
skeleton="card-list-skeleton"
> >
<b-card no-body> <card :title="$t('logs_operation')" icon="wrench" no-body>
<template v-slot:header>
<h2><icon iname="wrench" /> {{ $t('logs_operation') }}</h2>
</template>
<b-list-group flush> <b-list-group flush>
<b-list-group-item <b-list-group-item
v-for="log in filteredOperations" :key="log.name" v-for="log in filteredOperations" :key="log.name"
@ -21,20 +20,19 @@
{{ log.description }} {{ log.description }}
</b-list-group-item> </b-list-group-item>
</b-list-group> </b-list-group>
</b-card> </card>
</search-view> </view-search>
</template> </template>
<script> <script>
import api from '@/api'
import { distanceToNow, readableDate } from '@/helpers/filters/date' import { distanceToNow, readableDate } from '@/helpers/filters/date'
import SearchView from '@/components/SearchView'
export default { export default {
name: 'ServiceList', name: 'ToolLogs',
data () { data () {
return { return {
queries: [`logs?limit=${25}&with_details`],
search: '', search: '',
operations: undefined operations: undefined
} }
@ -47,39 +45,31 @@ export default {
const operations = this.operations.filter(({ description }) => { const operations = this.operations.filter(({ description }) => {
return description.toLowerCase().includes(search) 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: { filters: {
distanceToNow, distanceToNow,
readableDate 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> </script>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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