Merge pull request #438 from YunoHost/enh-config-actions

add action
This commit is contained in:
Alexandre Aubin 2022-10-04 23:33:37 +02:00 committed by GitHub
commit f624963458
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
22 changed files with 533 additions and 504 deletions

View file

@ -1,7 +1,7 @@
<template>
<abstract-form
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }"
@submit.prevent.stop="$emit('submit', panel.id)"
@submit.prevent.stop="onApply"
>
<slot name="tab-top" />
@ -12,18 +12,22 @@
<slot name="tab-before" />
<template v-for="section in panel.sections">
<div v-if="isVisible(section.visible, section)" :key="section.id" class="mb-5">
<component
v-if="section.visible" :is="section.name ? 'section' : 'div'"
:key="section.id" class="mb-5"
>
<b-card-title v-if="section.name" title-tag="h3">
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
</b-card-title>
<template v-for="(field, fname) in section.fields">
<form-field
v-if="isVisible(field.visible, field)" :key="fname"
v-model="forms[panel.id][fname]" v-bind="field" :validation="validation[fname]"
<component
v-if="field.visible" :is="field.is" v-bind="field.props"
v-model="forms[panel.id][fname]" :validation="validation[fname]" :key="fname"
@action.stop="onAction(section.id, fname, section.fields)"
/>
</template>
</div>
</component>
</template>
<slot name="tab-after" />
@ -31,7 +35,7 @@
</template>
<script>
import { configPanelsFieldIsVisible } from '@/helpers/yunohostArguments'
import { filterObject } from '@/helpers/commons'
export default {
@ -55,8 +59,25 @@ export default {
},
methods: {
isVisible (expression, field) {
return configPanelsFieldIsVisible(expression, field, this.forms)
onApply () {
const panelId = this.panel.id
this.$emit('submit', {
id: panelId,
form: this.forms[panelId]
})
},
onAction (sectionId, actionId, actionFields) {
const panelId = this.panel.id
const actionFieldsKeys = Object.keys(actionFields)
this.$emit('submit', {
id: panelId,
form: filterObject(this.forms[panelId], ([key]) => actionFieldsKeys.includes(key)),
action: [panelId, sectionId, actionId].join('.'),
name: actionId
})
}
}
}

View file

@ -1,7 +1,7 @@
<template>
<routable-tabs
:routes="routes_"
v-bind="{ panels, forms, v: $v }"
v-bind="{ panels, forms, v: $v, ...$attrs }"
v-on="$listeners"
/>
</template>
@ -49,10 +49,3 @@ export default {
}
}
</script>
<style lang="scss" scoped>
.card-title {
margin-bottom: 1em;
border-bottom: solid 1px #aaa;
}
</style>

View file

@ -28,15 +28,18 @@
<!-- Render description -->
<template v-if="description || link">
<div class="d-flex">
<b-link v-if="link" :to="link" :href="link.href"
class="ml-auto"
<b-link
v-if="link"
:to="link" :href="link.href" class="ml-auto"
>
{{ link.text }}
</b-link>
</div>
<vue-showdown :markdown="description" flavor="github" v-if="description"
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
<vue-showdown
v-if="description"
:markdown="description" flavor="github"
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
/>
</template>
<!-- Slot available to overwrite the one above -->
@ -76,8 +79,8 @@ export default {
if ('label' in attrs) {
const defaultAttrs = {
'label-cols-md': 4,
'label-cols-lg': 2,
'label-class': 'font-weight-bold'
'label-cols-lg': 3,
'label-class': ['font-weight-bold', 'py-0']
}
if (!('label-cols' in attrs)) {
for (const attr in defaultAttrs) {

View file

@ -0,0 +1,65 @@
<template>
<b-row no-gutters class="description-row">
<b-col v-bind="cols_" class="font-weight-bold">
{{ label }}
</b-col>
<!-- FIXME not sure about rendering html -->
<b-col v-html="text" />
</b-row>
</template>
<script>
export default {
name: 'ReadOnlyField',
inheritAttrs: false,
props: {
label: { type: String, required: true },
component: { type: String, default: 'InputItem' },
value: { type: null, default: null },
cols: { type: Object, default: () => ({ md: 4, lg: 3 }) }
},
computed: {
cols_ () {
return Object.assign({ md: 4, lg: 3 }, this.cols)
},
text () {
return this.parseValue(this.value)
}
},
methods: {
parseValue (value) {
const item = this.component
if (item === 'FileItem') value = value.file ? value.file.name : null
if (item === 'CheckboxItem') value = this.$i18n.t(value ? 'yes' : 'no')
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
if (Array.isArray(value)) {
value = value.length ? value.join(this.$i18n.t('words.separator')) : null
}
if ([null, undefined, ''].includes(this.value)) value = this.$i18n.t('words.none')
return value
}
}
}
</script>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
margin: 1rem 0;
}
@include media-breakpoint-down(sm) {
flex-direction: column;
&:not(:last-of-type) {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: $border-width solid $card-border-color;
}
}
}
</style>

View file

@ -0,0 +1,40 @@
<template>
<b-button
:id="id"
:variant="type"
@click="$emit('action', $event)"
:disabled="!enabled"
class="d-block mb-3"
>
<icon :iname="icon_" class="mr-2" />
<span v-html="label" />
</b-button>
</template>
<script>
export default {
name: 'ButtonItem',
props: {
label: { type: String, default: null },
id: { type: String, default: null },
type: { type: String, default: 'success' },
icon: { type: String, default: null },
enabled: { type: [Boolean, String], default: true }
},
computed: {
icon_ () {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times'
}
return this.icon || icons[this.type]
}
}
}
</script>

View file

@ -0,0 +1,16 @@
<template>
<div>
<p v-text="label" />
</div>
</template>
<script>
export default {
name: 'DisplayTextItem',
props: {
id: { type: String, default: null },
label: { type: String, default: null }
}
}
</script>

View file

@ -1,19 +1,24 @@
<template>
<b-button-group class="w-100">
<b-button @click="clearFiles" variant="danger" v-if="!this.required && this.value !== null && !this.value._removed">
<b-button
v-if="!this.required && this.value.file !== null"
@click="clearFiles" variant="danger"
>
<span class="sr-only">{{ $t('delete') }}</span>
<icon iname="trash" />
</b-button>
<b-form-file
v-model="file"
:value="value.file"
ref="input-file"
:id="id"
v-on="$listeners"
:required="required"
:placeholder="_placeholder"
:accept="accept"
:drop-placeholder="dropPlaceholder"
:state="state"
:browse-text="$t('words.browse')"
@input="onInput"
@blur="$parent.$emit('touch', name)"
@focusout.native="$parent.$emit('touch', name)"
/>
@ -21,18 +26,14 @@
</template>
<script>
import { getFileContent } from '@/helpers/commons'
export default {
name: 'FileItem',
data () {
return {
file: this.value
}
},
props: {
id: { type: String, default: null },
value: { type: [File, null], default: null },
value: { type: Object, default: () => ({ file: null }) },
placeholder: { type: String, default: 'Choose a file or drop it here...' },
dropPlaceholder: { type: String, default: null },
accept: { type: String, default: null },
@ -43,22 +44,35 @@ export default {
computed: {
_placeholder: function () {
return (this.value === null) ? this.placeholder : this.value.name
return this.value.file === null ? this.placeholder : this.value.file.name
}
},
methods: {
clearFiles () {
const f = new File([''], this.placeholder)
f._removed = true
if (this.value && this.value.currentfile) {
this.$refs['input-file'].reset()
this.$emit('input', f)
} else {
this.$refs['input-file'].setFiles([f])
this.file = f
this.$emit('input', f)
onInput (file) {
const value = {
file,
content: '',
current: false,
removed: false
}
// Update the value with the new File and an empty content for now
this.$emit('input', value)
// Asynchronously load the File content and update the value again
getFileContent(file).then(content => {
this.$emit('input', { ...value, content })
})
},
clearFiles () {
this.$refs['input-file'].reset()
this.$emit('input', {
file: null,
content: '',
current: false,
removed: true
})
}
}
}

View file

@ -2,7 +2,6 @@
<b-input
:value="value"
:id="id"
v-on="$listeners"
:placeholder="placeholder"
:type="type"
:state="state"
@ -12,6 +11,7 @@
:step="step"
:trim="trim"
:autocomplete="autocomplete_"
v-on="$listeners"
@blur="$parent.$emit('touch', name)"
/>
</template>
@ -21,11 +21,6 @@
export default {
name: 'InputItem',
data () {
return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
}
},
props: {
value: { type: [String, Number], default: null },
id: { type: String, default: null },
@ -40,6 +35,12 @@ export default {
autocomplete: { type: String, default: null },
pattern: { type: Object, default: null },
name: { type: String, default: null }
},
data () {
return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
}
}
}
</script>

View file

@ -12,4 +12,3 @@ export default {
}
}
</script>

View file

@ -1,8 +1,10 @@
<template>
<b-alert class="d-flex" :variant="type" show>
<icon :iname="icon_" class="mr-1 mt-1" />
<vue-showdown :markdown="label" flavor="github"
tag="span" class="markdown"
<b-alert class="d-flex flex-column flex-md-row align-items-center" :variant="type" show>
<icon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" />
<vue-showdown
:markdown="label" flavor="github"
tag="span" class="markdown"
/>
</b-alert>
</template>
@ -11,29 +13,23 @@
export default {
name: 'ReadOnlyAlertItem',
data () {
const icons = {
success: 'thumbs-up',
info: 'info-circle',
warning: 'warning',
danger: 'times'
}
return {
icon_: (this.icon) ? this.icon : icons[this.type]
}
},
props: {
id: { type: String, default: null },
label: { type: String, default: null },
type: { type: String, default: null },
icon: { type: String, default: null }
},
computed: {
icon_ () {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times'
}
return this.icon || icons[this.type]
}
}
}
</script>
<style lang="scss">
.alert p:last-child {
margin-bottom: 0;
}
</style>

View file

@ -1,6 +1,6 @@
<template>
<b-form-textarea
v-model="value"
:value="value"
:id="id"
:placeholder="placeholder"
:required="required"

View file

@ -63,6 +63,19 @@ export function flattenObjectLiteral (obj, flattened = {}) {
}
/**
* Returns an new Object filtered with passed filter function.
* Each entry `[key, value]` will be forwarded to the `filter` function.
*
* @param {Object} obj - object to filter.
* @param {Function} filter - the filter function to call for each entry.
* @return {Object}
*/
export function filterObject (obj, filter) {
return Object.fromEntries(Object.entries(obj).filter((...args) => filter(...args)))
}
/**
* Returns an new array containing items that are in first array but not in the other.
*
@ -100,3 +113,26 @@ export function escapeHtml (unsafe) {
export function randint (min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
/**
* Returns a File content.
*
* @param {File} file
* @param {Object} [extraParams] - Optionnal params
* @param {Boolean} [extraParams.base64] - returns a base64 representation of the file.
* @return {Promise<String>}
*/
export function getFileContent (file, { base64 = false } = {}) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = reject
reader.onload = () => resolve(reader.result)
if (base64) {
reader.readAsDataURL(file)
} else {
reader.readAsText(file)
}
})
}

View file

@ -2,7 +2,12 @@ import i18n from '@/i18n'
import store from '@/store'
import evaluate from 'simple-evaluate'
import * as validators from '@/helpers/validators'
import { isObjectLiteral, isEmptyValue, flattenObjectLiteral } from '@/helpers/commons'
import {
isObjectLiteral,
isEmptyValue,
flattenObjectLiteral,
getFileContent
} from '@/helpers/commons'
/**
@ -49,6 +54,49 @@ export function adressToFormValue (address) {
}
/**
* Evaluate config panel string expression that can contain regular expressions.
* Expression are evaluated with the config panel form as context.
*
* @param {String} expression - A String to evaluate.
* @param {Object} forms - A nested form used in config panels.
* @return {Boolean} - expression evaluation result.
*/
export function evaluateExpression (expression, forms) {
if (!expression) return true
if (expression === '"false"') return false
const context = Object.values(forms).reduce((ctx, args) => {
Object.entries(args).forEach(([name, value]) => {
ctx[name] = isObjectLiteral(value) && 'file' in value ? value.content : value
})
return ctx
}, {})
// Allow to use match(var,regexp) function
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
for (const matched of expression.matchAll(matchRe)) {
const [fullMatch, varMatch, regExpMatch] = matched
const varName = varMatch + '__re' + matched.index
context[varName] = new RegExp(regExpMatch, 'm').test(context[varMatch])
expression = expression.replace(fullMatch, varName)
}
try {
return !!evaluate(context, expression)
} catch {
return false
}
}
// Adds a property to an Object that will dynamically returns a expression evaluation result.
function addEvaluationGetter (prop, obj, expr, ctx) {
Object.defineProperty(obj, prop, {
get: () => evaluateExpression(expr, ctx)
})
}
/**
* Format app install, actions and config panel argument into a data structure that
* will be automaticly transformed into a component on screen.
@ -62,22 +110,21 @@ export function formatYunoHostArgument (arg) {
const error = { message: null }
arg.ask = formatI18nField(arg.ask)
const field = {
component: undefined,
label: arg.ask,
props: {}
is: arg.readonly ? 'ReadOnlyField' : 'FormField',
visible: [undefined, true, '"true"'].includes(arg.visible),
props: {
label: arg.ask,
component: undefined,
props: {}
}
}
const defaultProps = ['id:name', 'placeholder:example']
const components = [
{
types: [undefined, 'string', 'path'],
types: ['string', 'path'],
name: 'InputItem',
props: defaultProps.concat(['autocomplete', 'trim', 'choices']),
callback: function () {
if (arg.choices && Object.keys(arg.choices).length) {
arg.type = 'select'
this.name = 'SelectItem'
}
}
props: defaultProps.concat(['autocomplete', 'trim', 'choices'])
},
{
types: ['email', 'url', 'date', 'time', 'color'],
@ -115,9 +162,9 @@ export function formatYunoHostArgument (arg) {
name: 'SelectItem',
props: ['id:name', 'choices'],
callback: function () {
if ((arg.type !== 'select')) {
field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
}
if (arg.type !== 'select') {
field.props.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
}
}
},
{
@ -125,9 +172,12 @@ export function formatYunoHostArgument (arg) {
name: 'FileItem',
props: defaultProps.concat(['accept']),
callback: function () {
if (value) {
value = new File([''], value)
value.currentfile = true
value = {
// in case of already defined file, we receive only the file path (not the actual file)
file: value ? new File([''], value) : null,
content: '',
current: !!value,
removed: false
}
}
},
@ -141,11 +191,13 @@ export function formatYunoHostArgument (arg) {
name: 'TagsItem',
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
callback: function () {
if (arg.choices) {
this.name = 'TagsSelectizeItem'
field.props.auto = true
field.props.itemsName = ''
field.props.label = arg.placeholder
if (arg.choices && arg.choices.length) {
this.name = 'TagsSelectizeItem'
Object.assign(field.props.props, {
auto: true,
itemsName: '',
label: arg.placeholder
})
}
if (typeof value === 'string') {
value = value.split(',')
@ -170,53 +222,67 @@ export function formatYunoHostArgument (arg) {
types: ['alert'],
name: 'ReadOnlyAlertItem',
props: ['type:style', 'label:ask', 'icon'],
readonly: true
renderSelf: true
},
{
types: ['markdown', 'display_text'],
types: ['markdown'],
name: 'MarkdownItem',
props: ['label:ask'],
readonly: true
renderSelf: true
},
{
types: ['display_text'],
name: 'DisplayTextItem',
props: ['label:ask'],
renderSelf: true
},
{
types: ['button'],
name: 'ButtonItem',
props: ['type:style', 'label:ask', 'icon', 'enabled'],
renderSelf: true
}
]
// Default type management if no one is filled
if (arg.type === undefined) {
arg.type = (arg.choices === undefined) ? 'string' : 'select'
arg.type = arg.choices && arg.choices.length ? 'select' : 'string'
}
// Search the component bind to the type
const component = components.find(element => element.types.includes(arg.type))
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
// Callback use for specific behaviour
if (component.callback) component.callback()
field.component = component.name
field.props.component = component.name
// Affect properties to the field Item
for (let prop of component.props) {
prop = prop.split(':')
const propName = prop[0]
const argName = prop.slice(-1)[0]
if (argName in arg) {
field.props[propName] = arg[argName]
field.props.props[propName] = arg[argName]
}
}
// We don't want to display a label html item as this kind or field contains
// already the text to display
if (component.readonly) delete field.label
// Required (no need for checkbox its value can't be null)
else if (field.component !== 'CheckboxItem' && arg.optional !== true) {
if (!component.renderSelf && arg.type !== 'boolean' && arg.optional !== true) {
validation.required = validators.required
}
if (arg.pattern && arg.type !== 'tags') {
validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp))
}
validation.remote = validators.helpers.withParams(error, (v) => {
const result = !error.message
error.message = null
return result
})
if (!component.renderSelf && !arg.readonly) {
// Bind a validation with what the server may respond
validation.remote = validators.helpers.withParams(error, (v) => {
const result = !error.message
error.message = null
return result
})
}
// field.props['title'] = field.pattern.error
// Default value if still `null`
if (value === null && arg.current_value) {
value = arg.current_value
@ -227,18 +293,17 @@ export function formatYunoHostArgument (arg) {
// Help message
if (arg.help) {
field.description = formatI18nField(arg.help)
field.props.description = formatI18nField(arg.help)
}
// Help message
if (arg.helpLink) {
field.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
field.props.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
}
if (arg.visible) {
field.visible = arg.visible
// Temporary value to wait visible expression to be evaluated
field.isVisible = true
if (component.renderSelf) {
field.is = field.props.component
field.props = field.props.props
}
return {
@ -256,30 +321,29 @@ export function formatYunoHostArgument (arg) {
* as v-model values, fields that can be passed to a FormField component and validations.
*
* @param {Array} args - a yunohost arg array written by a packager.
* @param {String} name - (temp) an app name to build a label field in case of manifest install args
* @param {Object|null} forms - nested form used as the expression evualuations context.
* @return {Object} an object containing all parsed values to be used in vue views.
*/
export function formatYunoHostArguments (args, name = null) {
export function formatYunoHostArguments (args, forms) {
const form = {}
const fields = {}
const validations = {}
const errors = {}
// FIXME yunohost should add the label field by default
if (name) {
args.unshift({
ask: i18n.t('label_for_manifestname', { name }),
default: name,
name: 'label'
})
}
for (const arg of args) {
const { value, field, validation, error } = formatYunoHostArgument(arg)
fields[arg.name] = field
form[arg.name] = value
if (validation) validations[arg.name] = validation
errors[arg.name] = error
if ('visible' in arg && ![false, '"false"'].includes(arg.visible)) {
addEvaluationGetter('visible', field, arg.visible, forms)
}
if ('enabled' in arg) {
addEvaluationGetter('enabled', field.props, arg.enabled, forms)
}
}
return { form, fields, validations, errors }
@ -303,11 +367,24 @@ export function formatYunoHostConfigPanels (data) {
if (name) panel.name = formatI18nField(name)
if (help) panel.help = formatI18nField(help)
for (const { id: sectionId, name, help, visible, options } of sections) {
const section = { id: sectionId, visible, isVisible: false }
if (help) section.help = formatI18nField(help)
if (name) section.name = formatI18nField(name)
const { form, fields, validations, errors } = formatYunoHostArguments(options)
for (const _section of sections) {
const section = {
id: _section.id,
isActionSection: _section.is_action_section,
visible: [undefined, true, '"true"'].includes(_section.visible)
}
if (_section.help) section.help = formatI18nField(_section.help)
if (_section.name) section.name = formatI18nField(_section.name)
if (_section.visible && ![false, '"false"'].includes(_section.visible)) {
addEvaluationGetter('visible', section, _section.visible, result.forms)
}
const {
form,
fields,
validations,
errors
} = formatYunoHostArguments(_section.options, result.forms)
// Merge all sections forms to the panel to get a unique form
Object.assign(result.forms[panelId], form)
Object.assign(result.validations[panelId], validations)
@ -323,80 +400,65 @@ export function formatYunoHostConfigPanels (data) {
}
export function configPanelsFieldIsVisible (expression, field, forms) {
if (!expression || !field) return true
const context = {}
const promises = []
for (const args of Object.values(forms)) {
for (const shortname in args) {
if (args[shortname] instanceof File) {
if (expression.includes(shortname)) {
promises.push(pFileReader(args[shortname], context, shortname, false))
}
} else {
context[shortname] = args[shortname]
}
}
}
// Allow to use match(var,regexp) function
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
let i = 0
Promise.all(promises).then(() => {
for (const matched of expression.matchAll(matchRe)) {
i++
const varName = matched[1] + '__re' + i.toString()
context[varName] = new RegExp(matched[2], 'm').test(context[matched[1]])
expression = expression.replace(matched[0], varName)
}
try {
field.isVisible = evaluate(context, expression)
} catch {
field.isVisible = false
}
})
return field.isVisible
}
export function pFileReader (file, output, key, base64 = true) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onerror = reject
fr.onload = () => {
output[key] = fr.result
if (base64) {
output[key] = fr.result.replace(/data:[^;]*;base64,/, '')
}
output[key + '[name]'] = file.name
resolve()
}
if (base64) {
fr.readAsDataURL(file)
} else {
fr.readAsText(file)
}
})
}
/**
* Format helper for a form value.
* Convert Boolean to (1|0) and concatenate adresses.
* Parse a front-end value to its API equivalent. This function returns a Promise or an
* Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those
* objects must be merged to define the final sent form.
*
* Convert Boolean to '1' (true) or '0' (false),
* Concatenate two parts adresses (subdomain or email for example) into a single string,
* Convert File to its Base64 representation or set its value to '' to ask for a removal.
*
* @param {*} value
* @return {*}
*/
export function formatFormDataValue (value) {
if (typeof value === 'boolean') {
return value ? 1 : 0
} else if (isObjectLiteral(value) && 'separator' in value) {
return Object.values(value).join('')
export function formatFormDataValue (value, key = null) {
if (Array.isArray(value)) {
return Promise.all(
value.map(value_ => formatFormDataValue(value_))
).then(resolvedValues => ({ [key]: resolvedValues }))
}
return value
let result = value
if (typeof value === 'boolean') result = value ? 1 : 0
if (isObjectLiteral(value) && 'file' in value) {
// File has to be deleted
if (value.removed) result = ''
// File has not changed (will not be sent)
else if (value.current || value.file === null) result = null
else {
return getFileContent(value.file, { base64: true }).then(content => {
return {
[key]: content.replace(/data:[^;]*;base64,/, ''),
[key + '[name]']: value.file.name
}
})
}
} else if (isObjectLiteral(value) && 'separator' in value) {
result = Object.values(value).join('')
}
// Returns a resolved Promise for non async values
return Promise.resolve(key ? { [key]: result } : result)
}
/**
* Convinient helper to properly parse a front-end form to its API equivalent.
* This parse each values asynchronously, allow to inject keys into the final form and
* make sure every async values resolves before resolving itself.
*
* @param {Object} formData
* @return {Object}
*/
function formatFormDataValues (formData) {
const promisedValues = Object.entries(formData).map(([key, value]) => {
return formatFormDataValue(value, key)
})
return Promise.all(promisedValues).then(resolvedValues => {
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {})
})
}
@ -412,38 +474,28 @@ export function formatFormDataValue (value) {
*/
export async function formatFormData (
formData,
{ extract = null, flatten = false, removeEmpty = true, removeNull = false, multipart = true } = {}
{ extract = null, flatten = false, removeEmpty = true, removeNull = false } = {}
) {
const output = {
data: {},
extracted: {}
}
const promises = []
for (const key in formData) {
const type = extract && extract.includes(key) ? 'extracted' : 'data'
const value = Array.isArray(formData[key])
? formData[key].map(item => formatFormDataValue(item))
: formatFormDataValue(formData[key])
const values = await formatFormDataValues(formData)
for (const key in values) {
const type = extract && extract.includes(key) ? 'extracted' : 'data'
const value = values[key]
if (removeEmpty && isEmptyValue(value)) {
continue
} else if (removeNull && (value === null || value === undefined)) {
} else if (removeNull && [null, undefined].includes(value)) {
continue
} else if (value instanceof File && !multipart) {
if (value.currentfile) {
continue
} else if (value._removed) {
output[type][key] = ''
continue
}
promises.push(pFileReader(value, output[type], key))
} else if (flatten && isObjectLiteral(value)) {
flattenObjectLiteral(value, output[type])
} else {
output[type][key] = value
}
}
if (promises.length) await Promise.all(promises)
const { data, extracted } = output
return extract ? { data, ...extracted } : data
}

View file

@ -378,13 +378,14 @@
"human_routes": {
"adminpw": "Change admin password",
"apps": {
"action_config": "Run action '{action}' of app '{name}' configuration",
"change_label": "Change label of '{prevName}' for '{nextName}'",
"change_url": "Change access URL of '{name}'",
"install": "Install app '{name}'",
"set_default": "Redirect '{domain}' domain root to '{name}'",
"perform_action": "Perform action '{action}' of app '{name}'",
"uninstall": "Uninstall app '{name}'",
"update_config": "Update app '{name}' configuration"
"update_config": "Update panel '{id}' of app '{name}' configuration"
},
"backups": {
"create": "Create a backup",
@ -406,13 +407,11 @@
"domains": {
"add": "Add domain '{name}'",
"delete": "Delete domain '{name}'",
"install_LE": "Install certificate for '{name}'",
"manual_renew_LE": "Renew certificate for '{name}'",
"cert_install": "Install certificate for '{name}'",
"cert_renew": "Renew certificate for '{name}'",
"push_dns_changes": "Push DNS records to registrar for '{name}'",
"regen_selfsigned": "Renew self-signed certificate for '{name}'",
"revert_to_selfsigned": "Revert to self-signed certificate for '{name}'",
"set_default": "Set '{name}' as default domain",
"update_config": "Update '{name}' configuration"
"update_config": "Update panel '{id}' of domain '{name}' configuration"
},
"firewall": {
"ports": "{action} port {port} ({protocol}, {connection})",
@ -548,37 +547,16 @@
"words": {
"browse": "Browse",
"collapse": "Collapse",
"default": "Default"
"default": "Default",
"none": "None",
"separator": ", "
},
"wrong_password": "Wrong password",
"yes": "Yes",
"yunohost_admin": "YunoHost Admin",
"certificate_alert_not_valid": "CRITICAL: Current certificate is not valid! HTTPS won't work at all!",
"certificate_alert_selfsigned": "WARNING: Current certificate is self-signed. Browsers will display a spooky warning to new visitors!",
"certificate_alert_letsencrypt_about_to_expire": "Current certificate is about to expire. It should soon be renewed automatically.",
"certificate_alert_about_to_expire": "WARNING: Current certificate is about to expire! It will NOT be renewed automatically!",
"certificate_alert_good": "Okay, current certificate looks good!",
"certificate_alert_great": "Great! You're using a valid Let's Encrypt certificate!",
"certificate_alert_unknown": "Unknown status",
"certificate_manage": "Manage SSL certificate",
"ssl_certificate": "SSL certificate",
"confirm_cert_install_LE": "Are you sure you want to install a Let's Encrypt certificate for this domain?",
"confirm_cert_regen_selfsigned": "Are you sure you want to regenerate a self-signed certificate for this domain?",
"confirm_cert_manual_renew_LE": "Are you sure you want to manually renew the Let's Encrypt certificate for this domain now?",
"confirm_cert_revert_to_selfsigned": "Are you sure you want to revert this domain to a self-signed certificate?",
"certificate": "Certificate",
"certificate_status": "Certificate status",
"certificate_authority": "Certification authority",
"validity": "Validity",
"domain_is_eligible_for_ACME": "This domain seems correctly configured to install a Let's Encrypt certificate!",
"domain_not_eligible_for_ACME": "This domain doesn't seem ready for a Let's Encrypt certificate. Please check your DNS configuration and HTTP server reachability. The 'DNS records' and 'Web' section in <a href='#/diagnosis'>the diagnosis page</a> can help you understand what is misconfigured.",
"install_letsencrypt_cert": "Install a Let's Encrypt certificate",
"manually_renew_letsencrypt_message": "Certificate will be automatically renewed during the last 15 days of validity. You can manually renew it if you want to. (Not recommended).",
"manually_renew_letsencrypt": "Manually renew now",
"regenerate_selfsigned_cert_message": "If you want, you can regenerate the self-signed certificate.",
"regenerate_selfsigned_cert": "Regenerate self-signed certificate",
"revert_to_selfsigned_cert_message": "If you really want to, you can reinstall a self-signed certificate. (Not recommended)",
"revert_to_selfsigned_cert": "Revert to a self-signed certificate",
"purge_user_data_checkbox": "Purge {name}'s data? (This will remove the content of its home and mail directories.)",
"purge_user_data_warning": "Purging user's data is not reversible. Be sure you know what you're doing!"
}

View file

@ -179,16 +179,6 @@ const routes = [
breadcrumb: ['domain-list', 'domain-info', 'domain-dns']
}
},
{
name: 'domain-cert',
path: '/domains/:name/cert-management',
component: () => import(/* webpackChunkName: "views/domain/cert" */ '@/views/domain/DomainCert'),
props: true,
meta: {
args: { trad: 'certificate' },
breadcrumb: ['domain-list', 'domain-info', 'domain-cert']
}
},
/*
APPS

View file

@ -102,6 +102,11 @@ body {
}
}
h3.card-title {
margin-bottom: 1em;
border-bottom: solid 1px #aaa;
}
// collapse icon
.not-collapsed > .icon {
transform: rotate(-90deg);
@ -165,6 +170,10 @@ body {
}
}
.alert p:last-child {
margin-bottom: 0;
}
code {
background: ghostwhite;
}

View file

@ -126,12 +126,10 @@ export default {
},
methods: {
onSubmit () {
async onSubmit () {
const domainType = this.selected
this.$emit('submit', {
domain: formatFormDataValue(this.form[domainType]),
domainType
})
const domain = await formatFormDataValue(this.form[domainType])
this.$emit('submit', { domain, domainType })
}
},

View file

@ -3,7 +3,10 @@
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton"
>
<config-panels v-if="config.panels" v-bind="config" @submit="applyConfig" />
<config-panels
v-if="config.panels" v-bind="config"
@submit="onConfigSubmit"
/>
<b-alert v-else-if="config.panels === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }}
@ -34,7 +37,7 @@ export default {
data () {
return {
queries: [
['GET', `apps/${this.id}/config-panel?full`]
['GET', `apps/${this.id}/config?full`]
],
config: {}
}
@ -49,23 +52,22 @@ export default {
}
},
async applyConfig (id_) {
const formatedData = await formatFormData(
this.config.forms[id_],
{ removeEmpty: false, removeNull: true, multipart: false }
)
async onConfigSubmit ({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
api.put(
`apps/${this.id}/config`,
{ key: id_, args: objectToParams(formatedData) },
{ key: 'apps.update_config', name: this.id }
).then(response => {
action
? `apps/${this.id}/actions/${action}`
: `apps/${this.id}/config/${id}`,
{ args: objectToParams(args) },
{ key: `apps.${action ? 'action' : 'update'}_config`, id, name: this.id }
).then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(({ id }) => id_ === id)
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id_][err.data.name].message = err.message
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
}

View file

@ -24,10 +24,9 @@
@submit.prevent="performInstall"
>
<template v-for="(field, fname) in fields">
<form-field
v-if="isVisible(field.visible, field)"
:key="fname" label-cols="0"
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
<component
v-if="field.visible" :is="field.is" v-bind="field.props"
v-model="form[fname]" :validation="$v.form[fname]" :key="fname"
/>
</template>
</card-form>
@ -47,10 +46,13 @@
<script>
import { validationMixin } from 'vuelidate'
import evaluate from 'simple-evaluate'
import api, { objectToParams } from '@/api'
import { formatYunoHostArguments, formatI18nField, formatFormData, pFileReader } from '@/helpers/yunohostArguments'
import {
formatYunoHostArguments,
formatI18nField,
formatFormData
} from '@/helpers/yunohostArguments'
export default {
name: 'AppInstall',
@ -92,10 +94,19 @@ export default {
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no')
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
const { form, fields, validations, errors } = formatYunoHostArguments(
manifest.arguments.install,
manifest.name
)
// FIXME yunohost should add the label field by default
manifest.arguments.install.unshift({
ask: this.$t('label_for_manifestname', { name: manifest.name }),
default: manifest.name,
name: 'label'
})
const {
form,
fields,
validations,
errors
} = formatYunoHostArguments(manifest.arguments.install)
this.fields = fields
this.form = form
@ -103,41 +114,6 @@ export default {
this.errors = errors
},
isVisible (expression, field) {
if (!expression || !field) return true
const context = {}
const promises = []
for (const shortname in this.form) {
if (this.form[shortname] instanceof File) {
if (expression.includes(shortname)) {
promises.push(pFileReader(this.form[shortname], context, shortname, false))
}
} else {
context[shortname] = this.form[shortname]
}
}
// Allow to use match(var,regexp) function
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
let i = 0
Promise.all(promises).then(() => {
for (const matched of expression.matchAll(matchRe)) {
i++
const varName = matched[1] + '__re' + i.toString()
context[varName] = new RegExp(matched[2], 'm').test(context[matched[1]])
expression = expression.replace(matched[0], varName)
}
try {
field.isVisible = evaluate(context, expression)
} catch (error) {
field.isVisible = false
}
})
// This value should be updated magically when vuejs will detect isVisible changed
return field.isVisible
},
async performInstall () {
if ('path' in this.form && this.form.path === '/') {
const confirmed = await this.$askConfirmation(
@ -148,7 +124,7 @@ export default {
const { data: args, label } = await formatFormData(
this.form,
{ extract: ['label'], removeEmpty: false, removeNull: true, multipart: false }
{ extract: ['label'], removeEmpty: false, removeNull: true }
)
const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }

View file

@ -1,160 +0,0 @@
<template>
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<card v-if="cert" :title="$t('certificate_status')" icon="lock">
<p :class="'alert alert-' + cert.alert.type">
<icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }}
</p>
<b-row no-gutters class="row-line">
<b-col md="4" xl="2">
<strong v-t="'certificate_authority'" />
</b-col>
<b-col>{{ cert.type }} ({{ name }})</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col md="4" xl="2">
<strong v-t="'validity'" />
</b-col>
<b-col>{{ $tc('day_validity', cert.validity) }}</b-col>
</b-row>
</card>
<card v-if="cert" :title="$t('operations')" icon="wrench">
<!-- CERT INSTALL LETSENCRYPT -->
<template v-if="actionsEnabled.installLetsencrypt">
<p>
<icon :iname="cert.acmeEligible ? 'check' : 'meh-o'" /> <span v-html="$t(`domain_${cert.acmeEligible ? 'is' : 'not'}_eligible_for_ACME`)" />
</p>
<b-button @click="callAction('install_LE')" variant="success" :disabled="!cert.acmeEligible">
<icon iname="star" /> {{ $t('install_letsencrypt_cert') }}
</b-button>
<hr>
</template>
<!-- CERT RENEW LETS-ENCRYPT -->
<template v-if="actionsEnabled.manualRenewLetsencrypt">
<p v-t="'manually_renew_letsencrypt_message'" />
<b-button @click="callAction('manual_renew_LE')" variant="warning">
<icon iname="refresh" /> {{ $t('manually_renew_letsencrypt') }}
</b-button>
<hr>
</template>
<!-- CERT REGEN SELF-SIGNED -->
<template v-if="actionsEnabled.regenSelfsigned">
<p v-t="'regenerate_selfsigned_cert_message'" />
<b-button @click="callAction('regen_selfsigned')" variant="warning">
<icon iname="refresh" /> {{ $t('regenerate_selfsigned_cert') }}
</b-button>
<hr>
</template>
<!-- CERT REPLACE WITH SELF-SIGNED -->
<template v-if="actionsEnabled.replaceWithSelfsigned">
<p v-t="'revert_to_selfsigned_cert_message'" />
<b-button @click="callAction('revert_to_selfsigned')" variant="danger">
<icon iname="exclamation-triangle" /> {{ $t('revert_to_selfsigned_cert') }}
</b-button>
<hr>
</template>
</card>
<template #skeleton>
<card-info-skeleton :item-count="2" />
<card-buttons-skeleton :item-count="2" />
</template>
</view-base>
</template>
<script>
import api from '@/api'
export default {
name: 'DomainCert',
props: {
name: { type: String, required: true }
},
data () {
return {
queries: [
['GET', `domains/${this.name}/cert?full`]
],
cert: undefined,
actionsEnabled: undefined
}
},
methods: {
formatCertAlert (code, type) {
switch (code) {
case 'critical': return { type: 'danger', trad: 'not_valid', icon: 'exclamation-circle' }
case 'warning': return { type: 'warning', trad: 'selfsigned', icon: 'exclamation-triangle' }
case 'attention':
if (type === 'lets-encrypt') {
return { type: 'warning', trad: 'letsencrypt_about_to_expire', icon: 'clock-o' }
} else {
return { type: 'danger', trad: 'about_to_expire', icon: 'clock-o' }
}
case 'good': return { type: 'success', trad: 'good', icon: 'check-circle' }
case 'great': return { type: 'success', trad: 'great', icon: 'thumbs-up' }
default: return { type: 'warning', trad: 'unknown', icon: 'question' }
}
},
onQueriesResponse (data) {
const certData = data.certificates[this.name]
const cert = {
type: certData.CA_type.verbose,
name: certData.CA_name,
validity: certData.validity,
acmeEligible: certData.ACME_eligible,
alert: this.formatCertAlert(certData.summary.code, certData.CA_type.verbose)
}
const actionsEnabled = {
installLetsencrypt: false,
manualRenewLetsencrypt: false,
regenSelfsigned: false,
replaceWithSelfsigned: false
}
switch (certData.CA_type.code) {
case 'self-signed':
actionsEnabled.installLetsencrypt = true
actionsEnabled.regenSelfsigned = true
break
case 'lets-encrypt':
actionsEnabled.manualRenewLetsencrypt = true
actionsEnabled.replaceWithSelfsigned = true
break
default:
actionsEnabled.replaceWithSelfsigned = true
}
this.cert = cert
this.actionsEnabled = actionsEnabled
},
async callAction (action) {
const confirmed = await this.$askConfirmation(this.$i18n.t(`confirm_cert_${action}`))
if (!confirmed) return
let uri = `domains/${this.name}/cert`
if (action === 'regen_selfsigned') uri += '?self_signed'
else if (action === 'manual_renew_LE') uri += '?force'
else if (action === 'revert_to_selfsigned') uri += '?self_signed&force'
api.put(
uri, {}, { key: 'domains.' + action, name: this.name }
).then(this.$refs.view.fetchQueries)
}
}
}
</script>

View file

@ -3,7 +3,10 @@
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton"
>
<config-panels v-if="config.panels" v-bind="config" @submit="applyConfig" />
<config-panels
v-if="config.panels" v-bind="config"
@submit="onConfigSubmit"
/>
</view-base>
</template>
@ -41,23 +44,27 @@ export default {
this.config = formatYunoHostConfigPanels(config)
},
async applyConfig (id_) {
const formatedData = await formatFormData(
this.config.forms[id_],
{ removeEmpty: false, removeNull: true, multipart: false }
)
async onConfigSubmit ({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
const call = action
? api.put(
`domain/${this.name}/actions/${action}`,
{ args: objectToParams(args) },
{ key: 'domains.' + name, name: this.name }
)
: api.put(
`domains/${this.name}/config/${id}`,
{ args: objectToParams(args) },
{ key: 'domains.update_config', id, name: this.name }
)
api.put(
`domains/${this.name}/config`,
{ key: id_, args: objectToParams(formatedData) },
{ key: 'domains.update_config', name: this.name }
).then(() => {
call.then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(({ id }) => id_ === id)
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id_][err.data.name].message = err.message
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
}

View file

@ -32,13 +32,6 @@
</b-button>
<hr>
<!-- SSL CERTIFICATE -->
<p>{{ $t('certificate_manage') }}</p>
<b-button variant="outline-dark" :to="{ name: 'domain-cert', param: { name } }">
<icon iname="lock" /> {{ $t('ssl_certificate') }}
</b-button>
<hr>
<!-- DELETE -->
<p>{{ $t('domain_delete_longdesc') }}</p>
<p