mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
commit
f624963458
22 changed files with 533 additions and 504 deletions
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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) {
|
||||
|
|
65
app/src/components/globals/ReadOnlyField.vue
Normal file
65
app/src/components/globals/ReadOnlyField.vue
Normal 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>
|
40
app/src/components/globals/formItems/ButtonItem.vue
Normal file
40
app/src/components/globals/formItems/ButtonItem.vue
Normal 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>
|
16
app/src/components/globals/formItems/DisplayTextItem.vue
Normal file
16
app/src/components/globals/formItems/DisplayTextItem.vue
Normal 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>
|
|
@ -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
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -12,4 +12,3 @@ export default {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<b-form-textarea
|
||||
v-model="value"
|
||||
:value="value"
|
||||
:id="id"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
|
|
|
@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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!"
|
||||
}
|
||||
|
|
|
@ -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 │
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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 })
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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 }
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -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)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Add table
Reference in a new issue