Merge pull request #468 from YunoHost/11.1

11.1
This commit is contained in:
Alexandre Aubin 2022-10-24 17:57:56 +02:00 committed by GitHub
commit 027c2640fc
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
60 changed files with 2118 additions and 1269 deletions

View file

@ -7,7 +7,12 @@
:to="{ name: 'home' }" :disabled="waiting" :to="{ name: 'home' }" :disabled="waiting"
exact exact-active-class="active" exact exact-active-class="active"
> >
<img alt="Yunohost logo" src="./assets/logo.png"> <span v-if="theme">
<img alt="YunoHost logo" src="./assets/logo_light.png" width="40">
</span>
<span v-else>
<img alt="YunoHost logo" src="./assets/logo_dark.png" width="40">
</span>
</b-navbar-brand> </b-navbar-brand>
<b-navbar-nav class="ml-auto"> <b-navbar-nav class="ml-auto">
@ -93,7 +98,8 @@ export default {
'routerKey', 'routerKey',
'transitions', 'transitions',
'transitionName', 'transitionName',
'waiting' 'waiting',
'theme'
]) ])
}, },
@ -153,6 +159,8 @@ export default {
if (today.getDate() === 31 && today.getMonth() + 1 === 10) { if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
this.$store.commit('SET_SPINNER', 'spookycat') this.$store.commit('SET_SPINNER', 'spookycat')
} }
document.documentElement.setAttribute('dark-theme', localStorage.getItem('theme')) // updates the data-theme attribute
} }
} }
</script> </script>

View file

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,7 +1,8 @@
<template> <template>
<abstract-form <abstract-form
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }" v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }"
@submit.prevent.stop="$emit('submit', panel.id)" @submit.prevent.stop="onApply"
:no-footer="!panel.hasApplyButton"
> >
<slot name="tab-top" /> <slot name="tab-top" />
@ -12,18 +13,24 @@
<slot name="tab-before" /> <slot name="tab-before" />
<template v-for="section in panel.sections"> <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="panel-section"
>
<b-card-title v-if="section.name" title-tag="h3"> <b-card-title v-if="section.name" title-tag="h3">
{{ section.name }} <small v-if="section.help">{{ section.help }}</small> {{ section.name }} <small v-if="section.help">{{ section.help }}</small>
</b-card-title> </b-card-title>
<template v-for="(field, fname) in section.fields"> <template v-for="(field, fname) in section.fields">
<form-field <component
v-if="isVisible(field.visible, field)" :key="fname" v-if="field.visible" :is="field.is" v-bind="field.props"
v-model="forms[panel.id][fname]" v-bind="field" :validation="validation[fname]" v-model="forms[panel.id][fname]" :validation="validation[fname]" :key="fname"
@action.stop="onAction(section.id, fname, section.fields)"
/> />
</template> </template>
</div> </component>
</template> </template>
<slot name="tab-after" /> <slot name="tab-after" />
@ -31,7 +38,7 @@
</template> </template>
<script> <script>
import { configPanelsFieldIsVisible } from '@/helpers/yunohostArguments' import { filterObject } from '@/helpers/commons'
export default { export default {
@ -55,8 +62,25 @@ export default {
}, },
methods: { methods: {
isVisible (expression, field) { onApply () {
return configPanelsFieldIsVisible(expression, field, this.forms) 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
})
} }
} }
} }
@ -65,6 +89,9 @@ export default {
<style lang="scss" scoped> <style lang="scss" scoped>
.card-title { .card-title {
margin-bottom: 1em; margin-bottom: 1em;
border-bottom: solid 1px #aaa; border-bottom: solid $border-width $gray-500;
}
::v-deep .panel-section:not(:last-child) {
margin-bottom: 3rem;
} }
</style> </style>

View file

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

View file

@ -0,0 +1,112 @@
<template>
<b-list-group :flush="flush" :style="{ '--depth': tree.depth }">
<template v-for="(node, i) in tree.children">
<b-list-group-item
:key="node.id"
class="list-group-item-action" :class="getClasses(node, i)"
@click="$router.push(node.data.to)"
>
<slot name="default" v-bind="node" />
<b-button
v-if="node.children"
size="xs" variant="outline-secondary"
:aria-expanded="node.data.opened ? 'true' : 'false'" :aria-controls="'collapse-' + node.id"
:class="node.data.opened ? 'not-collapsed' : 'collapsed'" class="ml-2"
@click.stop="node.data.opened = !node.data.opened"
>
<span class="sr-only">{{ toggleText }}</span>
<icon iname="chevron-right" />
</b-button>
</b-list-group-item>
<b-collapse
v-if="node.children" :key="'collapse-' + node.id"
v-model="node.data.opened" :id="'collapse-' + node.id"
>
<recursive-list-group
:tree="node"
:last="last !== undefined ? last : i === tree.children.length - 1" flush
>
<!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT -->
<template slot="default" slot-scope="scope">
<slot name="default" v-bind="scope" />
</template>
</recursive-list-group>
</b-collapse>
</template>
</b-list-group>
</template>
<script>
export default {
name: 'RecursiveListGroup',
props: {
tree: { type: Object, required: true },
flush: { type: Boolean, default: false },
last: { type: Boolean, default: undefined },
toggleText: { type: String, default: null }
},
methods: {
getClasses (node, i) {
const children = node.height > 0
const opened = children && node.data.opened
const last = this.last !== false && (!children || !opened) && i === this.tree.children.length - 1
return { collapsible: children, uncollapsible: !children, opened, last }
}
}
}
</script>
<style lang="scss" scoped>
.list-group {
.collapse {
&:not(.show) + .list-group-item {
border-end-start-radius: $border-radius;
}
&.show + .list-group-item {
border-start-start-radius: $border-radius;
}
+ .list-group-item {
border-block-start-width: 1px !important;
}
}
&-item {
&-action {
cursor: pointer;
display: flex;
justify-content: space-between;
align-items: center;
width: unset;
}
&.collapsible.opened {
border-end-start-radius: $border-radius;
}
&.collapsible:not(.opened, .last) {
border-block-end-width: 0;
}
&.last {
border-block-end-width: $list-group-border-width;
border-end-start-radius: $border-radius;
}
}
&-flush .list-group-item {
margin-inline-start: calc(1rem * var(--depth));
border-inline-end: $list-group-border-width solid $list-group-border-color;
border-inline-start: $list-group-border-width solid $list-group-border-color;
text-decoration: none;
background-color: $list-group-hover-bg;
@include hover-focus() {
background-color: darken($list-group-hover-bg, 3%);
}
}
}
</style>

View file

@ -13,7 +13,11 @@
</b-card-header> </b-card-header>
<!-- Bind extra props to the child view and forward child events to parent --> <!-- Bind extra props to the child view and forward child events to parent -->
<router-view v-bind="$attrs" v-on="$listeners" /> <router-view v-bind="$attrs" v-on="$listeners">
<slot name="tab-top" slot="tab-top"></slot>
<slot name="tab-before" slot="tab-before"></slot>
<slot name="tab-after" slot="tab-after"></slot>
</router-view>
</b-card> </b-card>
</template> </template>

View file

@ -6,6 +6,7 @@
<component :is="titleTag" class="custom-header-title"> <component :is="titleTag" class="custom-header-title">
<icon v-if="icon" :iname="icon" class="mr-2" />{{ title }} <icon v-if="icon" :iname="icon" class="mr-2" />{{ title }}
</component> </component>
<slot name="header-next" />
</slot> </slot>
<div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }"> <div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }">

View file

@ -0,0 +1,60 @@
<template>
<b-row no-gutters class="description-row">
<b-col v-bind="cols_">
<slot name="term">
<strong>{{ term }}</strong>
</slot>
</b-col>
<b-col>
<slot name="default">
{{ details }}
</slot>
</b-col>
</b-row>
</template>
<script>
export default {
name: 'DescriptionRow',
props: {
term: { type: String, default: null },
details: { type: String, default: null },
cols: { type: Object, default: () => ({ md: 4, xl: 3 }) }
},
computed: {
cols_ () {
return Object.assign({ md: 4, xl: 3 }, this.cols)
}
}
}
</script>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
margin: .25rem 0;
&:hover {
background-color: rgba($black, 0.05);
border-radius: 0.2rem;
}
}
@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;
}
}
.col {
display: flex;
align-self: start;
}
}
</style>

View file

@ -0,0 +1,63 @@
<template>
<span class="explain-what">
<slot name="default" />
<span class="explain-what-popover-container">
<b-button
:id="id" href="#"
variant="light"
>
<icon iname="question" />
<span class="sr-only">{{ $t('details_about', { subject: title }) }}</span>
</b-button>
<b-popover
placement="auto"
:target="id" triggers="click" custom-class="explain-what-popover"
:variant="variant" :title="title"
>
<span v-html="content" />
</b-popover>
</span>
</span>
</template>
<script>
export default {
name: 'ExplainWhat',
props: {
id: { type: String, required: true },
title: { type: String, required: true },
content: { type: String, required: true },
variant: { type: String, default: 'info' }
},
computed: {
cols_ () {
return Object.assign({ md: 4, xl: 3 }, this.cols)
}
}
}
</script>
<style lang="scss" scoped>
.explain-what {
line-height: 1.2;
.btn {
padding: 0;
margin-left: .1rem;
border-radius: 50rem;
line-height: inherit;
font-size: inherit;
}
&-popover {
background-color: $white;
border-width: 2px;
::v-deep .popover-body {
color: $dark;
}
}
}
</style>

View file

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

View file

@ -1,12 +1,13 @@
<template> <template>
<span :class="'icon fa fa-' + iname" aria-hidden="true" /> <span :class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']" aria-hidden="true" />
</template> </template>
<script> <script>
export default { export default {
name: 'Icon', name: 'Icon',
props: { props: {
iname: { type: String, required: true } iname: { type: String, required: true },
variant: { type: String, default: null }
} }
} }
</script> </script>
@ -26,5 +27,21 @@ export default {
&.fs-sm { &.fs-sm {
font-size: 1rem; font-size: 1rem;
} }
&.variant {
font-size: .8rem;
width: 1.35rem;
min-width: 1.35rem;
height: 1.35rem;
line-height: 165%;
border-radius: 50rem;
@each $color, $value in $theme-colors {
&.#{$color} {
background-color: $value;
color: color-yiq($value);
}
}
}
} }
</style> </style>

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> <template>
<b-button-group class="w-100"> <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" /> <icon iname="trash" />
</b-button> </b-button>
<b-form-file <b-form-file
v-model="file" :value="value.file"
ref="input-file" ref="input-file"
:id="id" :id="id"
v-on="$listeners"
:required="required" :required="required"
:placeholder="_placeholder" :placeholder="_placeholder"
:accept="accept" :accept="accept"
:drop-placeholder="dropPlaceholder" :drop-placeholder="dropPlaceholder"
:state="state" :state="state"
:browse-text="$t('words.browse')" :browse-text="$t('words.browse')"
@input="onInput"
@blur="$parent.$emit('touch', name)" @blur="$parent.$emit('touch', name)"
@focusout.native="$parent.$emit('touch', name)" @focusout.native="$parent.$emit('touch', name)"
/> />
@ -21,18 +26,14 @@
</template> </template>
<script> <script>
import { getFileContent } from '@/helpers/commons'
export default { export default {
name: 'FileItem', name: 'FileItem',
data () {
return {
file: this.value
}
},
props: { props: {
id: { type: String, default: null }, 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...' }, placeholder: { type: String, default: 'Choose a file or drop it here...' },
dropPlaceholder: { type: String, default: null }, dropPlaceholder: { type: String, default: null },
accept: { type: String, default: null }, accept: { type: String, default: null },
@ -43,22 +44,35 @@ export default {
computed: { computed: {
_placeholder: function () { _placeholder: function () {
return (this.value === null) ? this.placeholder : this.value.name return this.value.file === null ? this.placeholder : this.value.file.name
} }
}, },
methods: { methods: {
clearFiles () { onInput (file) {
const f = new File([''], this.placeholder) const value = {
f._removed = true file,
if (this.value && this.value.currentfile) { content: '',
this.$refs['input-file'].reset() current: false,
this.$emit('input', f) removed: false
} else {
this.$refs['input-file'].setFiles([f])
this.file = f
this.$emit('input', f)
} }
// 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 <b-input
:value="value" :value="value"
:id="id" :id="id"
v-on="$listeners"
:placeholder="placeholder" :placeholder="placeholder"
:type="type" :type="type"
:state="state" :state="state"
@ -12,6 +11,7 @@
:step="step" :step="step"
:trim="trim" :trim="trim"
:autocomplete="autocomplete_" :autocomplete="autocomplete_"
v-on="$listeners"
@blur="$parent.$emit('touch', name)" @blur="$parent.$emit('touch', name)"
/> />
</template> </template>
@ -21,11 +21,6 @@
export default { export default {
name: 'InputItem', name: 'InputItem',
data () {
return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
}
},
props: { props: {
value: { type: [String, Number], default: null }, value: { type: [String, Number], default: null },
id: { type: String, default: null }, id: { type: String, default: null },
@ -40,6 +35,12 @@ export default {
autocomplete: { type: String, default: null }, autocomplete: { type: String, default: null },
pattern: { type: Object, default: null }, pattern: { type: Object, default: null },
name: { type: String, default: null } name: { type: String, default: null }
},
data () {
return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
}
} }
} }
</script> </script>

View file

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

View file

@ -1,7 +1,9 @@
<template> <template>
<b-alert class="d-flex" :variant="type" show> <b-alert class="d-flex flex-column flex-md-row align-items-center" :variant="type" show>
<icon :iname="icon_" class="mr-1 mt-1" /> <icon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" />
<vue-showdown :markdown="label" flavor="github"
<vue-showdown
:markdown="label" flavor="github"
tag="span" class="markdown" tag="span" class="markdown"
/> />
</b-alert> </b-alert>
@ -11,29 +13,23 @@
export default { export default {
name: 'ReadOnlyAlertItem', 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: { props: {
id: { type: String, default: null }, id: { type: String, default: null },
label: { type: String, default: null }, label: { type: String, default: null },
type: { type: String, default: null }, type: { type: String, default: null },
icon: { 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> </script>
<style lang="scss">
.alert p:last-child {
margin-bottom: 0;
}
</style>

View file

@ -12,7 +12,6 @@
@remove="onRemoveTag({ option: tag, removeTag })" @remove="onRemoveTag({ option: tag, removeTag })"
:title="tag" :title="tag"
:disabled="disabled || disabledItems.includes(tag)" :disabled="disabled || disabledItems.includes(tag)"
variant="light"
class="border border-dark mb-2" class="border border-dark mb-2"
> >
<icon v-if="tagIcon" :iname="tagIcon" /> {{ tag }} <icon v-if="tagIcon" :iname="tagIcon" /> {{ tag }}
@ -151,7 +150,7 @@ export default {
padding-top: .5rem; padding-top: .5rem;
position: sticky; position: sticky;
top: 0; top: 0;
background-color: white; background-color: $white;
} }
} }
</style> </style>

View file

@ -1,6 +1,6 @@
<template> <template>
<b-form-textarea <b-form-textarea
v-model="value" :value="value"
:id="id" :id="id"
:placeholder="placeholder" :placeholder="placeholder"
:required="required" :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. * 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) { export function randint (min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min 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

@ -0,0 +1,209 @@
/**
* A Node that can have a parent and children.
*/
export class Node {
constructor (data) {
this.data = data
this.depth = 0
this.height = 0
this.parent = null
// this.id = null
// this.children = null
}
/**
* Invokes the specified `callback` for this node and each descendant in pre-order
* traversal, such that a given node is only visited after all of its ancestors
* have already been visited.
* The specified function is passed the current descendant, the zero-based traversal
* index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachBefore.js.
*
* @param {function} callback
* @return {Object}
*/
eachBefore (callback) {
const nodes = []
let index = -1
let node = this
while (node) {
callback(node, ++index, this)
if (node.children) {
nodes.push(...node.children)
}
node = nodes.pop()
}
return this
}
/**
* Invokes the specified `callback` for this node and each descendant in post-order
* traversal, such that a given node s only visited after all of its descendants
* have already been visited
* The specified function is passed the current descendant, the zero-based traversal
* index, and this node.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/eachAfter.js.
*
* @param {function} callback
* @return {Object}
*/
eachAfter (callback) {
const nodes = []
const next = []
let node = this
while (node) {
next.push(node)
if (node.children) {
nodes.push(...node.children)
}
node = nodes.pop()
}
let index = 0
for (let i = next.length - 1; i >= 0; i--) {
callback(next[i], index++, this)
}
return this
}
/**
* Returns a deep copied and filtered tree of itself.
* Specified filter function is passed each nodes in post-order traversal and must
* return `true` or `false` like a regular filter function.
*
* @param {Function} callback - filter callback function to invoke on each nodes
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node}
*/
filter (callback) {
// Duplicates this tree and iter on nodes from leaves to root (post-order traversal)
return hierarchy(this).eachAfter((node, i) => {
// Since we create a new hierarchy from another, nodes's `data` contains the
// whole dupplicated node. Overwrite node's `data` by node's original `data`.
node.data = node.data.data
if (node.children) {
// Removed flagged children
node.children = node.children.filter(child => !child.remove)
if (!node.children.length) delete node.children
}
// Perform filter callback on non-root nodes
const match = node.data ? callback(node, i, this) : true
// Flag node if there's no match in node nor in its children
if (!match && !node.children) {
node.remove = true
}
})
}
}
/**
* Generates a new hierarchy from the specified tabular `dataset`.
* The specified `dataset` must be an array of objects that contains at least a
* `name` property and an optional `parent` property referencing its parent `name`.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/stratify.js#L16.
*
* @param {Array} dataset
* @param {Object} args
* @param {String} [args.idKey='name'] - the key name where we can find the node identity.
* @param {String} [args.parentIdKey='name'] - the key name where we can find the parent identity.
* @return {Node}
*/
export function stratify (dataset, { idKey = 'name', parentIdKey = 'parent' } = {}) {
const root = new Node(null, true)
root.children = []
const nodesMap = new Map()
// Creates all nodes that will be arranged in a hierarchy
const nodes = dataset.map(d => {
const node = new Node(d)
node.id = d[idKey]
nodesMap.set(node.id, node)
if (d[parentIdKey]) {
node.parent = d[parentIdKey]
}
return node
})
// Build a hierarchy from nodes
nodes.forEach((node, i) => {
const parentId = node.parent
if (parentId) {
const parent = nodesMap.get(parentId)
if (!parent) throw new Error('Missing parent node: ' + parentId)
if (parent.children) parent.children.push(node)
else parent.children = [node]
node.parent = parent
} else {
node.parent = root
root.children.push(node)
}
})
root.eachBefore(node => {
// Compute node depth
if (node.parent) {
node.depth = node.parent.depth + 1
// Remove parent key if parent is root (node with no data)
if (!node.parent.data) delete node.parent
}
computeNodeHeight(node)
})
return root
}
/**
* Constructs a root node from the specified hierarchical `data`.
* The specified `data` must be an object representing the root node and its children.
* If given a `Node` object this will return a deep copy of it.
* Code taken and adapted from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L15.
*
* @param {Node|Object} data - object representing a root node (a simple { id, children } object or a `Node`)
* @return {Node}
*/
export function hierarchy (data) {
const root = new Node(data)
const nodes = []
let node = root
while (node) {
if (node.data.children) {
node.children = node.data.children.map(child_ => {
const child = new Node(child_)
child.id = child_.id
child.parent = node === root ? null : node
child.depth = node.depth + 1
nodes.push(child)
return child
})
}
node = nodes.pop()
}
root.eachBefore(computeNodeHeight)
return root
}
/**
* Compute the node height by iterating on parents
* Code taken from d3.js https://github.com/d3/d3-hierarchy/blob/main/src/hierarchy/index.js#L62.
*
* @param {Node} node
*/
function computeNodeHeight (node) {
let height = 0
do {
node.height = height
node = node.parent
} while (node && node.height < ++height)
}

View file

@ -2,9 +2,22 @@ import i18n from '@/i18n'
import store from '@/store' import store from '@/store'
import evaluate from 'simple-evaluate' import evaluate from 'simple-evaluate'
import * as validators from '@/helpers/validators' import * as validators from '@/helpers/validators'
import { isObjectLiteral, isEmptyValue, flattenObjectLiteral } from '@/helpers/commons' import {
isObjectLiteral,
isEmptyValue,
flattenObjectLiteral,
getFileContent
} from '@/helpers/commons'
const NO_VALUE_FIELDS = [
'ReadOnlyField',
'ReadOnlyAlertItem',
'MarkdownItem',
'DisplayTextItem',
'ButtonItem'
]
/** /**
* Tries to find a translation corresponding to the user's locale/fallback locale in a * Tries to find a translation corresponding to the user's locale/fallback locale in a
* Yunohost argument or simply return the string if it's not an object literal. * Yunohost argument or simply return the string if it's not an object literal.
@ -49,6 +62,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 * Format app install, actions and config panel argument into a data structure that
* will be automaticly transformed into a component on screen. * will be automaticly transformed into a component on screen.
@ -62,22 +118,21 @@ export function formatYunoHostArgument (arg) {
const error = { message: null } const error = { message: null }
arg.ask = formatI18nField(arg.ask) arg.ask = formatI18nField(arg.ask)
const field = { const field = {
component: undefined, is: arg.readonly ? 'ReadOnlyField' : 'FormField',
visible: [undefined, true, '"true"'].includes(arg.visible),
props: {
label: arg.ask, label: arg.ask,
component: undefined,
props: {} props: {}
} }
}
const defaultProps = ['id:name', 'placeholder:example'] const defaultProps = ['id:name', 'placeholder:example']
const components = [ const components = [
{ {
types: [undefined, 'string', 'path'], types: ['string', 'path'],
name: 'InputItem', name: 'InputItem',
props: defaultProps.concat(['autocomplete', 'trim', 'choices']), props: defaultProps.concat(['autocomplete', 'trim', 'choices'])
callback: function () {
if (arg.choices && Object.keys(arg.choices).length) {
arg.type = 'select'
this.name = 'SelectItem'
}
}
}, },
{ {
types: ['email', 'url', 'date', 'time', 'color'], types: ['email', 'url', 'date', 'time', 'color'],
@ -90,7 +145,7 @@ export function formatYunoHostArgument (arg) {
props: defaultProps.concat(['type', 'autocomplete', 'trim']), props: defaultProps.concat(['type', 'autocomplete', 'trim']),
callback: function () { callback: function () {
if (!arg.help) { if (!arg.help) {
arg.help = 'good_practices_about_admin_password' arg.help = i18n.t('good_practices_about_admin_password')
} }
arg.example = '••••••••••••' arg.example = '••••••••••••'
validation.passwordLenght = validators.minLength(8) validation.passwordLenght = validators.minLength(8)
@ -111,12 +166,12 @@ export function formatYunoHostArgument (arg) {
} }
}, },
{ {
types: ['select', 'user', 'domain', 'app'], types: ['select', 'user', 'domain', 'app', 'group'],
name: 'SelectItem', name: 'SelectItem',
props: ['id:name', 'choices'], props: ['id:name', 'choices'],
callback: function () { callback: function () {
if ((arg.type !== 'select')) { if (arg.type !== 'select') {
field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) } field.props.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
} }
} }
}, },
@ -125,9 +180,12 @@ export function formatYunoHostArgument (arg) {
name: 'FileItem', name: 'FileItem',
props: defaultProps.concat(['accept']), props: defaultProps.concat(['accept']),
callback: function () { callback: function () {
if (value) { value = {
value = new File([''], value) // in case of already defined file, we receive only the file path (not the actual file)
value.currentfile = true file: value ? new File([''], value) : null,
content: '',
current: !!value,
removed: false
} }
} }
}, },
@ -141,11 +199,13 @@ export function formatYunoHostArgument (arg) {
name: 'TagsItem', name: 'TagsItem',
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']), props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
callback: function () { callback: function () {
if (arg.choices) { if (arg.choices && arg.choices.length) {
this.name = 'TagsSelectizeItem' this.name = 'TagsSelectizeItem'
field.props.auto = true Object.assign(field.props.props, {
field.props.itemsName = '' auto: true,
field.props.label = arg.placeholder itemsName: '',
label: arg.placeholder
})
} }
if (typeof value === 'string') { if (typeof value === 'string') {
value = value.split(',') value = value.split(',')
@ -170,53 +230,67 @@ export function formatYunoHostArgument (arg) {
types: ['alert'], types: ['alert'],
name: 'ReadOnlyAlertItem', name: 'ReadOnlyAlertItem',
props: ['type:style', 'label:ask', 'icon'], props: ['type:style', 'label:ask', 'icon'],
readonly: true renderSelf: true
}, },
{ {
types: ['markdown', 'display_text'], types: ['markdown'],
name: 'MarkdownItem', name: 'MarkdownItem',
props: ['label:ask'], 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 // Default type management if no one is filled
if (arg.type === undefined) { 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 // Search the component bind to the type
const component = components.find(element => element.types.includes(arg.type)) const component = components.find(element => element.types.includes(arg.type))
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type) if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
// Callback use for specific behaviour // Callback use for specific behaviour
if (component.callback) component.callback() if (component.callback) component.callback()
field.component = component.name field.props.component = component.name
// Affect properties to the field Item // Affect properties to the field Item
for (let prop of component.props) { for (let prop of component.props) {
prop = prop.split(':') prop = prop.split(':')
const propName = prop[0] const propName = prop[0]
const argName = prop.slice(-1)[0] const argName = prop.slice(-1)[0]
if (argName in arg) { 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) // 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 validation.required = validators.required
} }
if (arg.pattern && arg.type !== 'tags') { if (arg.pattern && arg.type !== 'tags') {
validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp)) validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp))
} }
if (!component.renderSelf && !arg.readonly) {
// Bind a validation with what the server may respond
validation.remote = validators.helpers.withParams(error, (v) => { validation.remote = validators.helpers.withParams(error, (v) => {
const result = !error.message const result = !error.message
error.message = null error.message = null
return result return result
}) })
}
// field.props['title'] = field.pattern.error
// Default value if still `null` // Default value if still `null`
if (value === null && arg.current_value) { if (value === null && arg.current_value) {
value = arg.current_value value = arg.current_value
@ -227,18 +301,17 @@ export function formatYunoHostArgument (arg) {
// Help message // Help message
if (arg.help) { if (arg.help) {
field.description = formatI18nField(arg.help) field.props.description = formatI18nField(arg.help)
} }
// Help message // Help message
if (arg.helpLink) { 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) { if (component.renderSelf) {
field.visible = arg.visible field.is = field.props.component
// Temporary value to wait visible expression to be evaluated field.props = field.props.props
field.isVisible = true
} }
return { return {
@ -256,30 +329,29 @@ export function formatYunoHostArgument (arg) {
* as v-model values, fields that can be passed to a FormField component and validations. * 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 {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. * @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 form = {}
const fields = {} const fields = {}
const validations = {} const validations = {}
const errors = {} 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) { for (const arg of args) {
const { value, field, validation, error } = formatYunoHostArgument(arg) const { value, field, validation, error } = formatYunoHostArgument(arg)
fields[arg.name] = field fields[arg.name] = field
form[arg.name] = value form[arg.name] = value
if (validation) validations[arg.name] = validation if (validation) validations[arg.name] = validation
errors[arg.name] = error 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 } return { form, fields, validations, errors }
@ -295,7 +367,7 @@ export function formatYunoHostConfigPanels (data) {
} }
for (const { id: panelId, name, help, sections } of data.panels) { for (const { id: panelId, name, help, sections } of data.panels) {
const panel = { id: panelId, sections: [], serverError: '' } const panel = { id: panelId, sections: [], serverError: '', hasApplyButton: false }
result.forms[panelId] = {} result.forms[panelId] = {}
result.validations[panelId] = {} result.validations[panelId] = {}
result.errors[panelId] = {} result.errors[panelId] = {}
@ -303,17 +375,34 @@ export function formatYunoHostConfigPanels (data) {
if (name) panel.name = formatI18nField(name) if (name) panel.name = formatI18nField(name)
if (help) panel.help = formatI18nField(help) if (help) panel.help = formatI18nField(help)
for (const { id: sectionId, name, help, visible, options } of sections) { for (const _section of sections) {
const section = { id: sectionId, visible, isVisible: false } const section = {
if (help) section.help = formatI18nField(help) id: _section.id,
if (name) section.name = formatI18nField(name) isActionSection: _section.is_action_section,
const { form, fields, validations, errors } = formatYunoHostArguments(options) 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 // Merge all sections forms to the panel to get a unique form
Object.assign(result.forms[panelId], form) Object.assign(result.forms[panelId], form)
Object.assign(result.validations[panelId], validations) Object.assign(result.validations[panelId], validations)
Object.assign(result.errors[panelId], errors) Object.assign(result.errors[panelId], errors)
section.fields = fields section.fields = fields
panel.sections.push(section) panel.sections.push(section)
if (!section.isActionSection && Object.values(fields).some((field) => !NO_VALUE_FIELDS.includes(field.is))) {
panel.hasApplyButton = true
}
} }
result.panels.push(panel) result.panels.push(panel)
@ -323,80 +412,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. * Parse a front-end value to its API equivalent. This function returns a Promise or an
* Convert Boolean to (1|0) and concatenate adresses. * 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 * @param {*} value
* @return {*} * @return {*}
*/ */
export function formatFormDataValue (value) { export function formatFormDataValue (value, key = null) {
if (typeof value === 'boolean') { if (Array.isArray(value)) {
return value ? 1 : 0 return Promise.all(
} else if (isObjectLiteral(value) && 'separator' in value) { value.map(value_ => formatFormDataValue(value_))
return Object.values(value).join('') ).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 +486,28 @@ export function formatFormDataValue (value) {
*/ */
export async function formatFormData ( export async function formatFormData (
formData, formData,
{ extract = null, flatten = false, removeEmpty = true, removeNull = false, multipart = true } = {} { extract = null, flatten = false, removeEmpty = true, removeNull = false } = {}
) { ) {
const output = { const output = {
data: {}, data: {},
extracted: {} 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)) { if (removeEmpty && isEmptyValue(value)) {
continue continue
} else if (removeNull && (value === null || value === undefined)) { } else if (removeNull && [null, undefined].includes(value)) {
continue 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)) { } else if (flatten && isObjectLiteral(value)) {
flattenObjectLiteral(value, output[type]) flattenObjectLiteral(value, output[type])
} else { } else {
output[type][key] = value output[type][key] = value
} }
} }
if (promises.length) await Promise.all(promises)
const { data, extracted } = output const { data, extracted } = output
return extract ? { data, ...extracted } : data return extract ? { data, ...extracted } : data
} }

View file

@ -80,7 +80,7 @@ function initDefaultLocales () {
store.dispatch('UPDATE_LOCALE', locale) store.dispatch('UPDATE_LOCALE', locale)
store.dispatch('UPDATE_FALLBACKLOCALE', fallbackLocale || 'en') store.dispatch('UPDATE_FALLBACKLOCALE', fallbackLocale || 'en')
loadLocaleMessages('en') return loadLocaleMessages('en')
} }
export { export {

View file

@ -54,8 +54,6 @@
"api_not_found": "Seems like the web-admin tried to query something that doesn't exist.", "api_not_found": "Seems like the web-admin tried to query something that doesn't exist.",
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?", "api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?",
"api_waiting": "Waiting for the server's response...", "api_waiting": "Waiting for the server's response...",
"app_actions": "Actions",
"app_actions_label": "Perform actions",
"app_choose_category": "Choose a category", "app_choose_category": "Choose a category",
"app_config_panel": "Config panel", "app_config_panel": "Config panel",
"app_config_panel_label": "Configure this app", "app_config_panel_label": "Configure this app",
@ -69,18 +67,14 @@
"app_install_parameters": "Install settings", "app_install_parameters": "Install settings",
"app_manage_label_and_tiles": "Manage label and tiles", "app_manage_label_and_tiles": "Manage label and tiles",
"app_make_default": "Make default", "app_make_default": "Make default",
"app_no_actions": "This application doesn't have any actions",
"app_show_categories": "Show categories", "app_show_categories": "Show categories",
"app_state_broken": "broken",
"app_state_broken_explanation": "This application is currently broken and not installable according to YunoHost's automatic tests",
"app_state_inprogress": "not yet working", "app_state_inprogress": "not yet working",
"app_state_inprogress_explanation": "This maintainer of this app declared that this application is not ready yet for production use. BE CAREFUL!", "app_state_inprogress_explanation": "This maintainer of this app declared that this application is not ready yet for production use. BE CAREFUL!",
"app_state_notworking": "not working",
"app_state_notworking_explanation": "This maintainer of this app declared it as 'not working'. IT WILL BREAK YOUR SYSTEM!",
"app_state_lowquality": "low quality", "app_state_lowquality": "low quality",
"app_state_lowquality_explanation": "This app may be functional, but may still contain issues, or is not fully integrated with YunoHost, or it does not respect the good practices.", "app_state_lowquality_explanation": "This app may be functional, but may still contain issues, or is not fully integrated with YunoHost, or it does not respect the good practices.",
"app_state_highquality": "high quality",
"app_state_highquality_explanation": "This app is well-integrated with YunoHost since at least a year.", "app_state_highquality_explanation": "This app is well-integrated with YunoHost since at least a year.",
"app_state_working": "working",
"app_state_working_explanation": "The maintainer of this app declared it as 'working'. It means that it should be functional (c.f. application level) but is not necessarily peer-reviewed, it may still contain issues or is not fully integrated with YunoHost.",
"applications": "Applications", "applications": "Applications",
"archive_empty": "Empty archive", "archive_empty": "Empty archive",
"backup": "Backup", "backup": "Backup",
@ -110,6 +104,7 @@
"confirm_install_custom_app": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?", "confirm_install_custom_app": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_install_domain_root": "Are you sure you want to install this application on '/'? You will not be able to install any other app on {domain}", "confirm_install_domain_root": "Are you sure you want to install this application on '/'? You will not be able to install any other app on {domain}",
"confirm_app_install": "Are you sure you want to install this application?", "confirm_app_install": "Are you sure you want to install this application?",
"confirm_install_app_broken": "WARNING! This application is broken according to YunoHost's automatic tests and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_install_app_lowquality": "Warning: this application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available.", "confirm_install_app_lowquality": "Warning: this application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available.",
"confirm_install_app_inprogress": "WARNING! This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?", "confirm_install_app_inprogress": "WARNING! This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
"confirm_migrations_skip": "Skipping migrations is not recommended. Are you sure you want to do that?", "confirm_migrations_skip": "Skipping migrations is not recommended. Are you sure you want to do that?",
@ -134,6 +129,7 @@
"delete": "Delete", "delete": "Delete",
"description": "Description", "description": "Description",
"details": "Details", "details": "Details",
"details_about": "Show more details about {subject}",
"domain_dns_conf_is_just_a_recommendation": "This page shows you the *recommended* configuration. It does *not* configure the DNS for you. It is your responsibility to configure your DNS zone in your DNS registrar according to this recommendation.", "domain_dns_conf_is_just_a_recommendation": "This page shows you the *recommended* configuration. It does *not* configure the DNS for you. It is your responsibility to configure your DNS zone in your DNS registrar according to this recommendation.",
"diagnosis": "Diagnosis", "diagnosis": "Diagnosis",
"diagnosis_first_run": "The diagnosis feature will attempt to identify common issues on the different aspects of your server to make sure everything runs smoothly. Please do not panic if you see a bunch of errors right after setting up your server: it is precisely meant to help you to identify issues and guide you to fix them. The diagnosis will also run automatically twice a day and an email is sent to the administrator if issues are found.", "diagnosis_first_run": "The diagnosis feature will attempt to identify common issues on the different aspects of your server to make sure everything runs smoothly. Please do not panic if you see a bunch of errors right after setting up your server: it is precisely meant to help you to identify issues and guide you to fix them. The diagnosis will also run automatically twice a day and an email is sent to the administrator if issues are found.",
@ -143,6 +139,14 @@
"disabled": "Disabled", "disabled": "Disabled",
"dns": "DNS", "dns": "DNS",
"domain": { "domain": {
"cert": {
"types": {
"selfsigned": "Self-signed",
"letsencrypt": "Let's Encrypt",
"other": "Other/Unknown"
},
"valid_for": "valid for {days}"
},
"config": { "config": {
"edit": "Edit domain configuration", "edit": "Edit domain configuration",
"title": "Domain configuration" "title": "Domain configuration"
@ -152,6 +156,13 @@
"auto_config_ignored": "ignored, won't be changed by YunoHost unless you check the overwrite option", "auto_config_ignored": "ignored, won't be changed by YunoHost unless you check the overwrite option",
"auto_config_ok": "Automatic configuration seems to be OK!", "auto_config_ok": "Automatic configuration seems to be OK!",
"auto_config_zone": "Current DNS zone", "auto_config_zone": "Current DNS zone",
"methods": {
"auto": "Automatic",
"handled_in_parent": "Handled in parent domain",
"manual": "Manual",
"none": "None",
"semi_auto": "Semi-automatic"
},
"edit": "Edit DNS configuration", "edit": "Edit DNS configuration",
"info": "The automatic DNS records configuration is an experimental feature. <br>Consider saving your current DNS zone from your DNS registrar's interface before pushing records from here.", "info": "The automatic DNS records configuration is an experimental feature. <br>Consider saving your current DNS zone from your DNS registrar's interface before pushing records from here.",
"manual_config": "Suggested DNS records for manual configuration", "manual_config": "Suggested DNS records for manual configuration",
@ -159,7 +170,20 @@
"push_force": "Overwrite existing records", "push_force": "Overwrite existing records",
"push_force_confirm": "Are you sure you want to force push all suggested dns records? Be aware that it may overwrite manually or important default records set by you or your registrar.", "push_force_confirm": "Are you sure you want to force push all suggested dns records? Be aware that it may overwrite manually or important default records set by you or your registrar.",
"push_force_warning": "It looks like some DNS records that YunoHost would have set are already in the registrar configuration. You can use the overwrite option if you know what you are doing." "push_force_warning": "It looks like some DNS records that YunoHost would have set are already in the registrar configuration. You can use the overwrite option if you know what you are doing."
} },
"explain": {
"main_domain": "The main domain is the domain from which users can connect to the portal (via \"{domain}/yunohost/sso\").<br>Therefore, it is not possible to delete it.<br>If you want to delete \"{domain}\", you will first have to choose or add another domain and set it as the main domain."
},
"info": {
"apps_on_domain": "Apps installed on domain",
"certificate_authority": "SSL Certification authority",
"registrar": "Registrar"
},
"see_parent_domain": "See parent domain",
"types": {
"main_domain": "Main domain"
},
"toggle_subdomains": "Toggle subdomains"
}, },
"domain_add": "Add domain", "domain_add": "Add domain",
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns_config' target='_blank'>set my DNS correctly</a>.", "domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns_config' target='_blank'>set my DNS correctly</a>.",
@ -177,8 +201,6 @@
"domain_dns_push_managed_in_parent_domain": "The automatic DNS records feature is managed in the parent domain <a href='#/domains/{parent_domain}/dns'>{parent_domain}</a>.", "domain_dns_push_managed_in_parent_domain": "The automatic DNS records feature is managed in the parent domain <a href='#/domains/{parent_domain}/dns'>{parent_domain}</a>.",
"domain_dns_push_not_applicable": "The automatic DNS records feature is not applicable to domain {domain},<br> You should manually configure your DNS records following the <a href='https://yunohost.org/dns'>documentation</a> and the suggested configuration below.", "domain_dns_push_not_applicable": "The automatic DNS records feature is not applicable to domain {domain},<br> You should manually configure your DNS records following the <a href='https://yunohost.org/dns'>documentation</a> and the suggested configuration below.",
"domain_name": "Domain name", "domain_name": "Domain name",
"domain_visit": "Visit",
"domain_visit_url": "Visit {url}",
"domains": "Domains", "domains": "Domains",
"download": "Download", "download": "Download",
"enable": "Enable", "enable": "Enable",
@ -227,14 +249,16 @@
"group_name": "Group name", "group_name": "Group name",
"group_all_users": "All users", "group_all_users": "All users",
"group_visitors": "Visitors", "group_visitors": "Visitors",
"group_admins": "Admins",
"group_format_name_help": "You can use alpha-numeric chars and underscore", "group_format_name_help": "You can use alpha-numeric chars and underscore",
"group_add_member": "Add a user", "group_add_member": "Add a user",
"group_add_permission": "Add a permission", "group_add_permission": "Add a permission",
"group_new": "New group", "group_new": "New group",
"group_explain_admins": "This is a special group corresponding to admin users. Users in this group can access YunoHost's webadmin, connect to the server with SSH and use the `sudo` command. You should only add people you absolutely trust in this group!",
"group_explain_all_users": "This is a special group containing all users accounts on the server", "group_explain_all_users": "This is a special group containing all users accounts on the server",
"group_explain_visitors": "This is a special group representing anonymous visitors", "group_explain_visitors": "This is a special group representing anonymous visitors",
"group_explain_visitors_needed_for_external_client": "Be careful that you need to keep some applications allowed to visitors if you intend to use them with external clients. For example, this is the case for Nextcloud if you intend to use a synchronization client on your smartphone or desktop computer.", "group_explain_visitors_needed_for_external_client": "Be careful that you need to keep some applications allowed to visitors if you intend to use them with external clients. For example, this is the case for Nextcloud if you intend to use a synchronization client on your smartphone or desktop computer.",
"group_specific_permissions": "User specific permissions", "group_specific_permissions": "Individual user permissions",
"groups_and_permissions": "Groups and permissions", "groups_and_permissions": "Groups and permissions",
"groups_and_permissions_manage": "Manage groups and permissions", "groups_and_permissions_manage": "Manage groups and permissions",
"permissions": "Permissions", "permissions": "Permissions",
@ -288,7 +312,8 @@
"items_verbose_count": "There are {items}. | There is 1 {items}. | There are {items}.", "items_verbose_count": "There are {items}. | There is 1 {items}. | There are {items}.",
"items_verbose_items_left": "There are {items} left. | There is 1 {items} left. | There are {items} left.", "items_verbose_items_left": "There are {items} left. | There is 1 {items} left. | There are {items} left.",
"label": "Label", "label": "Label",
"label_for_manifestname": "Label for {name} (name displayed in the user portal)", "label_for_manifestname": "Label for {name}",
"label_for_manifestname_help": "This is the name displayed in the user portal. This can be changed later.",
"last_ran": "Last time ran:", "last_ran": "Last time ran:",
"license": "License", "license": "License",
"local_archives": "Local archives", "local_archives": "Local archives",
@ -300,6 +325,7 @@
"manage_apps": "Manage apps", "manage_apps": "Manage apps",
"manage_domains": "Manage domains", "manage_domains": "Manage domains",
"manage_users": "Manage users", "manage_users": "Manage users",
"manage_groups": "Manage groups",
"migrations": "Migrations", "migrations": "Migrations",
"migrations_pending": "Pending migrations", "migrations_pending": "Pending migrations",
"migrations_done": "Previous migrations", "migrations_done": "Previous migrations",
@ -327,9 +353,10 @@
"path": "Path", "path": "Path",
"perform": "Perform", "perform": "Perform",
"placeholder": { "placeholder": {
"username": "johndoe", "username": "samsmith",
"firstname": "John", "fullname": "Sam Smith",
"lastname": "Doe", "firstname": "Sam",
"lastname": "Smith",
"groupname": "My group name", "groupname": "My group name",
"domain": "my-domain.com", "domain": "my-domain.com",
"file": "Browse a file or drag and drop it" "file": "Browse a file or drag and drop it"
@ -359,15 +386,17 @@
"ports": "Ports", "ports": "Ports",
"postinstall": { "postinstall": {
"force": "Force the post-install", "force": "Force the post-install",
"title": "Postinstall" "title": "Postinstall",
"user": {
"title": "Create first admin user",
"first_user_help": "This user will be granted admin privileges and will be allowed to connect to this administration interface as well as directly to the server via SSH.\nAs it is a regular user, you will also be able to connect to the user portal (SSO) with its credentials.\nOnce the post-installation is complete, you will be able to create other admin users by adding them into the 'admins' group."
}
}, },
"postinstall_domain": "This is the first domain name linked to your YunoHost server, but also the one which will be used by your server's users to access the authentication portal. Accordingly, it will be visible by everyone, so choose it carefully.", "postinstall_domain": "This is the first domain name linked to your YunoHost server, but also the one which will be used by your server's users to access the authentication portal. Accordingly, it will be visible by everyone, so choose it carefully.",
"postinstall_intro_1": "Congratulations! YunoHost has been successfully installed.", "postinstall_intro_1": "Congratulations! YunoHost has been successfully installed.",
"postinstall_intro_2": "Two more configuration steps are required to activate you server's services.", "postinstall_intro_2": "Two more configuration steps are required to activate you server's services.",
"postinstall_intro_3": "You can obtain more information by visiting the <a href='//yunohost.org/en/install/hardware:vps_debian#fa-cog-proceed-with-the-initial-configuration' target='_blank'>appropriate documentation page</a>", "postinstall_intro_3": "You can obtain more information by visiting the <a href='//yunohost.org/en/install/hardware:vps_debian#fa-cog-proceed-with-the-initial-configuration' target='_blank'>appropriate documentation page</a>",
"postinstall_password": "This password will be used to manage everything on your server. Take the time to choose it wisely.",
"postinstall_set_domain": "Set main domain", "postinstall_set_domain": "Set main domain",
"postinstall_set_password": "Set administration password",
"previous": "Previous", "previous": "Previous",
"protocol": "Protocol", "protocol": "Protocol",
"readme": "Readme", "readme": "Readme",
@ -376,15 +405,14 @@
"restart": "Restart", "restart": "Restart",
"retry": "Retry", "retry": "Retry",
"human_routes": { "human_routes": {
"adminpw": "Change admin password",
"apps": { "apps": {
"action_config": "Run action '{action}' of app '{name}' configuration",
"change_label": "Change label of '{prevName}' for '{nextName}'", "change_label": "Change label of '{prevName}' for '{nextName}'",
"change_url": "Change access URL of '{name}'", "change_url": "Change access URL of '{name}'",
"install": "Install app '{name}'", "install": "Install app '{name}'",
"set_default": "Redirect '{domain}' domain root to '{name}'", "set_default": "Redirect '{domain}' domain root to '{name}'",
"perform_action": "Perform action '{action}' of app '{name}'",
"uninstall": "Uninstall app '{name}'", "uninstall": "Uninstall app '{name}'",
"update_config": "Update app '{name}' configuration" "update_config": "Update panel '{id}' of app '{name}' configuration"
}, },
"backups": { "backups": {
"create": "Create a backup", "create": "Create a backup",
@ -406,13 +434,11 @@
"domains": { "domains": {
"add": "Add domain '{name}'", "add": "Add domain '{name}'",
"delete": "Delete domain '{name}'", "delete": "Delete domain '{name}'",
"install_LE": "Install certificate for '{name}'", "cert_install": "Install certificate for '{name}'",
"manual_renew_LE": "Renew certificate for '{name}'", "cert_renew": "Renew certificate for '{name}'",
"push_dns_changes": "Push DNS records to registrar 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", "set_default": "Set '{name}' as default domain",
"update_config": "Update '{name}' configuration" "update_config": "Update panel '{id}' of domain '{name}' configuration"
}, },
"firewall": { "firewall": {
"ports": "{action} port {port} ({protocol}, {connection})", "ports": "{action} port {port} ({protocol}, {connection})",
@ -452,6 +478,9 @@
"create": "Create user '{name}'", "create": "Create user '{name}'",
"delete": "Delete user '{name}'", "delete": "Delete user '{name}'",
"update": "Update user '{name}'" "update": "Update user '{name}'"
},
"settings": {
"update": "Update '{panel}' global settings"
} }
}, },
"run": "Run", "run": "Run",
@ -483,9 +512,6 @@
"text_selection_is_disabled": "Text selection is disabled. If you want to share this log, please share the *full* log with the 'Share with Yunopaste' button.<br/><small>Or if you really really want to select text, press these keys: ↓↓↑↑.</small>", "text_selection_is_disabled": "Text selection is disabled. If you want to share this log, please share the *full* log with the 'Share with Yunopaste' button.<br/><small>Or if you really really want to select text, press these keys: ↓↓↑↑.</small>",
"tip_about_user_email": "Users are created with an associated email address (and XMPP account) with the format username@domain.tld. Additional email aliases and email forwards can later be added by the admin and the user.", "tip_about_user_email": "Users are created with an associated email address (and XMPP account) with the format username@domain.tld. Additional email aliases and email forwards can later be added by the admin and the user.",
"tools": "Tools", "tools": "Tools",
"tools_adminpw": "Change administration password",
"tools_adminpw_current": "Current password",
"tools_adminpw_current_placeholder": "Enter your current password",
"tools_reboot": "Reboot your server", "tools_reboot": "Reboot your server",
"tools_reboot_btn": "Reboot", "tools_reboot_btn": "Reboot",
"tools_shutdown": "Shutdown your server", "tools_shutdown": "Shutdown your server",
@ -499,8 +525,10 @@
"cache_description": "Consider disabling the cache if you plan on working with the CLI while also navigating in this web-admin.", "cache_description": "Consider disabling the cache if you plan on working with the CLI while also navigating in this web-admin.",
"experimental": "Experimental mode", "experimental": "Experimental mode",
"experimental_description": "Gives you access to experimental features. These are considered unstable and may break your system.<br> Enable this only if you know what you are doing.", "experimental_description": "Gives you access to experimental features. These are considered unstable and may break your system.<br> Enable this only if you know what you are doing.",
"transitions": "Page transition animations" "transitions": "Page transition animations",
"theme": "Toggle dark mode"
}, },
"tools_yunohost_settings": "YunoHost settings",
"tools_webadmin_settings": "Web-admin settings", "tools_webadmin_settings": "Web-admin settings",
"traceback": "Traceback", "traceback": "Traceback",
"udp": "UDP", "udp": "UDP",
@ -508,8 +536,6 @@
"unignore": "Unignore", "unignore": "Unignore",
"uninstall": "Uninstall", "uninstall": "Uninstall",
"unknown": "Unknown", "unknown": "Unknown",
"unmaintained": "Unmaintained",
"unmaintained_details": "This app has not been updated for quite a while and the previous maintainer has gone away or does not have time to maintain this app. Feel free to check the app repository to provide your help",
"upnp": "UPnP", "upnp": "UPnP",
"upnp_disabled": "UPnP is disabled.", "upnp_disabled": "UPnP is disabled.",
"upnp_enabled": "UPnP is enabled.", "upnp_enabled": "UPnP is enabled.",
@ -544,37 +570,18 @@
"words": { "words": {
"browse": "Browse", "browse": "Browse",
"collapse": "Collapse", "collapse": "Collapse",
"default": "Default" "default": "Default",
"link": "Link",
"none": "None",
"separator": ", ",
"valid": "Valid"
}, },
"wrong_password": "Wrong password", "wrong_password_or_username": "Wrong password or username",
"yes": "Yes", "yes": "Yes",
"yunohost_admin": "YunoHost Admin", "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", "certificate_manage": "Manage SSL certificate",
"ssl_certificate": "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": "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_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!" "purge_user_data_warning": "Purging user's data is not reversible. Be sure you know what you're doing!"
} }

View file

@ -14,7 +14,7 @@
"app_state_working": "Fonctionnelle", "app_state_working": "Fonctionnelle",
"applications": "Applications", "applications": "Applications",
"archive_empty": "Larchive est vide", "archive_empty": "Larchive est vide",
"backup": "Sauvegarde", "backup": "Sauvegardes",
"backup_action": "Sauvegarder", "backup_action": "Sauvegarder",
"backup_content": "Contenu de la sauvegarde", "backup_content": "Contenu de la sauvegarde",
"backup_create": "Créer une sauvegarde", "backup_create": "Créer une sauvegarde",
@ -267,7 +267,7 @@
"group_new": "Nouveau groupe", "group_new": "Nouveau groupe",
"group_explain_all_users": "Ceci est un groupe spécial contenant tous les comptes d'utilisateurs sur le serveur", "group_explain_all_users": "Ceci est un groupe spécial contenant tous les comptes d'utilisateurs sur le serveur",
"group_explain_visitors": "Ceci est un groupe spécial représentant les visiteurs anonymes", "group_explain_visitors": "Ceci est un groupe spécial représentant les visiteurs anonymes",
"group_specific_permissions": "Autorisations spécifiques à l'utilisateur", "group_specific_permissions": "Autorisations pour des utilisateurs individuels",
"groups_and_permissions": "Groupes et autorisations", "groups_and_permissions": "Groupes et autorisations",
"groups_and_permissions_manage": "Gérer les groupes et les autorisations", "groups_and_permissions_manage": "Gérer les groupes et les autorisations",
"permissions": "Permissions", "permissions": "Permissions",
@ -335,8 +335,9 @@
"domain": "mon-domaine.fr", "domain": "mon-domaine.fr",
"groupname": "Le nom de mon groupe", "groupname": "Le nom de mon groupe",
"lastname": "Dupont", "lastname": "Dupont",
"firstname": "Jean", "firstname": "Camille",
"username": "jeandupont", "fullname": "Camille Dupont",
"username": "camilledupont",
"file": "Parcourir un fichier ou le faire glisser et déposer" "file": "Parcourir un fichier ou le faire glisser et déposer"
}, },
"perform": "Exécuter", "perform": "Exécuter",

View file

@ -55,13 +55,13 @@ requireComponent.keys().forEach((fileName) => {
registerGlobalErrorHandlers() registerGlobalErrorHandlers()
// Load default locales translations files and setup store data // Load default locales translations files and setup store data
initDefaultLocales() initDefaultLocales().then(() => {
const app = new Vue({
const app = new Vue({
store, store,
router, router,
i18n, i18n,
render: h => h(App) render: h => h(App)
}) })
app.$mount('#app') app.$mount('#app')
})

View file

@ -141,54 +141,23 @@ const routes = [
} }
}, },
{ {
name: 'domain-info',
path: '/domains/:name', path: '/domains/:name',
component: () => import(/* webpackChunkName: "views/domain/info" */ '@/views/domain/DomainInfo'), component: () => import(/* webpackChunkName: "views/domain/info" */ '@/views/domain/DomainInfo'),
props: true, props: true,
meta: {
args: { param: 'name' },
breadcrumb: ['domain-list', 'domain-info']
}
},
{
// no need for name here, only children are visited
path: '/domains/:name/config',
component: () => import(/* webpackChunkName: "views/domain/config" */ '@/views/domain/DomainConfig'),
props: true,
children: [ children: [
{ {
name: 'domain-config', name: 'domain-info',
path: ':tabId?', path: ':tabId?',
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'), component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
props: true, props: true,
meta: { meta: {
routerParams: ['name'], // Override router key params to avoid view recreation at tab change. routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
args: { trad: 'config' }, args: { param: 'name' },
breadcrumb: ['domain-list', 'domain-info', 'domain-config'] breadcrumb: ['domain-list', 'domain-info']
} }
} }
] ]
}, },
{
name: 'domain-dns',
path: '/domains/:name/dns',
component: () => import(/* webpackChunkName: "views/domain/dns" */ '@/views/domain/DomainDns'),
props: true,
meta: {
args: { trad: 'dns' },
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 APPS
@ -241,16 +210,6 @@ const routes = [
breadcrumb: ['app-list', 'app-info'] breadcrumb: ['app-list', 'app-info']
} }
}, },
{
name: 'app-actions',
path: '/apps/:id/actions',
component: () => import(/* webpackChunkName: "views/apps/actions" */ '@/views/app/AppActions'),
props: true,
meta: {
args: { trad: 'app_actions' },
breadcrumb: ['app-list', 'app-info', 'app-actions']
}
},
{ {
// no need for name here, only children are visited // no need for name here, only children are visited
path: '/apps/:id/config-panel', path: '/apps/:id/config-panel',
@ -293,7 +252,7 @@ const routes = [
component: () => import(/* webpackChunkName: "views/service/list" */ '@/views/service/ServiceList'), component: () => import(/* webpackChunkName: "views/service/list" */ '@/views/service/ServiceList'),
meta: { meta: {
args: { trad: 'services' }, args: { trad: 'services' },
breadcrumb: ['service-list'] breadcrumb: ['tool-list', 'service-list']
} }
}, },
{ {
@ -303,7 +262,7 @@ const routes = [
props: true, props: true,
meta: { meta: {
args: { param: 'name' }, args: { param: 'name' },
breadcrumb: ['service-list', 'service-info'] breadcrumb: ['tool-list', 'service-list', 'service-info']
} }
}, },
@ -356,15 +315,6 @@ const routes = [
breadcrumb: ['tool-list', 'tool-firewall'] breadcrumb: ['tool-list', 'tool-firewall']
} }
}, },
{
name: 'tool-adminpw',
path: '/tools/adminpw',
component: () => import(/* webpackChunkName: "views/tools/adminpw" */ '@/views/tool/ToolAdminpw'),
meta: {
args: { trad: 'tools_adminpw' },
breadcrumb: ['tool-list', 'tool-adminpw']
}
},
{ {
name: 'tool-webadmin', name: 'tool-webadmin',
path: '/tools/webadmin', path: '/tools/webadmin',
@ -374,6 +324,23 @@ const routes = [
breadcrumb: ['tool-list', 'tool-webadmin'] breadcrumb: ['tool-list', 'tool-webadmin']
} }
}, },
{
path: '/tools/settings',
component: () => import(/* webpackChunkName: "views/tools/settings" */ '@/views/tool/ToolSettings'),
children: [
{
name: 'tool-settings',
path: ':tabId?',
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
props: true,
meta: {
routerParams: [],
args: { trad: 'tools_yunohost_settings' },
breadcrumb: ['tool-list', 'tool-settings']
}
}
]
},
{ {
name: 'tool-power', name: 'tool-power',
path: '/tools/power', path: '/tools/power',

View file

@ -0,0 +1,245 @@
// Taken from https://gist.github.com/johanlef/518a511b2b2f6b96c4f429b3af2f169a
// Those functions overrides built-in bootstrap's computation color functions (that
// generate flat variants and its darken/lighten alterations) to allow `var(--color)` CSS
// variables to be used as primary colors and be instead computed on the fly with `calc()`s
@function is-color($color) {
@if (type-of($color) == color) {
@return true;
}
@return false;
}
@function count-occurrences($string, $search) {
$searchIndex: str-index($string, $search);
$searchCount: 0;
@while $searchIndex {
$searchCount: $searchCount + 1;
$string: str-slice($string, $searchIndex + 1);
$searchIndex: str-index($string, $search);
}
@return $searchCount;
}
@function str-is-between($string, $first, $last) {
$firstCount: count-occurrences($string, $first);
$lastCount: count-occurrences($string, $last);
@return $firstCount == $lastCount;
}
@function recursive-color($color, $index: 0) {
$indices: (
0: h,
1: s,
2: l,
3: a
);
// find end of part
$end: str-index($color, ',');
@while ($end and not str-is-between(str-slice($color, 0, $end - 1), '(', ')')) {
$newEnd: str-index(str-slice($color, $end + 1), ',');
@if (not $newEnd) {
$newEnd: 0;
}
$end: 2 + $end + $newEnd;
}
@if ($end) {
$part: str-slice($color, 0, $end - 1);
$value: map-merge(
(
map-get($indices, $index): $part
),
recursive-color(str-slice($color, $end + 1), $index + 1)
);
@return $value;
}
@return ();
}
@function to-hsl($color) {
$c: inspect($color);
$h: 0;
$s: 0;
$l: 0;
$a: 1;
@if (is-color($color)) {
// std color
$h: hue($color);
$s: saturation($color);
$l: lightness($color);
$a: alpha($color);
@return (h: $h, s: $s, l: $l, a: $a);
}
@if (str-slice($c, 0, 3) == 'var') {
// var(--color)
$commaPos: str-index($c, ',');
$end: -2;
@if ($commaPos) {
$end: $commaPos - 1;
}
$var: str-slice($c, 7, $end);
$h: var(--#{$var}-h);
$s: var(--#{$var}-s);
$l: var(--#{$var}-l);
$a: var(--#{$var}-a, 1);
@return (h: $h, s: $s, l: $l, a: $a);
}
@if ($c == '0') {
@return (h: $h, s: $s, l: $l, a: $a);
}
// color is (maybe complex) calculated color
// e.g.: hsla(calc((var(--white-h) + var(--primary-h)) / 2), calc((var(--white-s) + var(--primary-s)) / 2), calc((var(--white-l) + var(--primary-l)) / 2), calc((var(--white-a, 1) + var(--primary-a, 1)) / 2)), hsla(calc((var(--white-h) + var(--primary-h)) / 2), calc((var(--white-s) + var(--primary-s)) / 2), calc((var(--white-l) + var(--primary-l)) / 2), calc((var(--white-a, 1) + var(--primary-a, 1)) / 2))
$startPos: str-index($c, '(');
$c: str-slice($c, $startPos + 1, -2); // 3 or 4 comma-separated vomplex values
@return recursive-color($c);
// $hEnd: str-index($c, ',');
// @if ($hEnd) {
// $h: str-slice($c, 0, $hEnd - 1);
// $c: str-slice($c, $hEnd + 1);
// $sEnd: str-index($c, ',');
// @if ($hEnd) {
// $h: str-slice($c, 0, $hEnd - 1);
// $c: str-slice($c, $hEnd + 1);
// $sEnd: str-index($c, ',');
// }
// }
// @return (h: $h, s: $s, l: $l, a: $a);
}
@function render-hsla($h, $s, $l, $a: 1) {
@return hsla($h, $s, $l, $a);
}
@function lighten($color, $amount) {
@if (is-color($color)) {
@return scale-color($color: $color, $lightness: $amount);
}
$c: to-hsl($color);
$h: map-get($c, h);
$s: map-get($c, s);
$l: map-get($c, l);
$a: map-get($c, a);
@return render-hsla($h, $s, calc(#{$l} + #{$amount}), $a);
}
@function darken($color, $amount) {
@return lighten($color, $amount * -1);
}
@function rgba($red, $green, $blue: false, $alpha: false) {
$color: $red;
@if (not $blue and not $alpha) {
$alpha: $green;
$color: $red;
}
$c: to-hsl($color);
$h: map-get($c, h);
$s: map-get($c, s);
$l: map-get($c, l);
@return render-hsla($h, $s, $l, $alpha);
}
@function rgb($red, $green, $blue) {
@return rgba($red, $green, $blue, 1);
}
@function mix($color-1, $color-2, $weight: 50%) {
$c1: to-hsl($color-1);
$c2: to-hsl($color-2);
$h1: map-get($c1, h);
$s1: map-get($c1, s);
$l1: map-get($c1, l);
$a1: map-get($c1, a);
$h2: map-get($c2, h);
$s2: map-get($c2, s);
$l2: map-get($c2, l);
$a2: map-get($c2, a);
$h: calc((#{$h1} + #{$h2}) / 2);
$s: calc((#{$s1} + #{$s2}) / 2);
$l: calc((#{$l1} + #{$l2}) / 2);
$a: calc((#{$a1} + #{$a2}) / 2);
@return render-hsla($h, $s, $l, $a);
}
@function fade-in($color, $amount) {
$c: to-hsl($color);
$h: map-get($c, h);
$s: map-get($c, s);
$l: map-get($c, l);
$a: map-get($c, a);
@if (not $a) {
$a: 1;
}
@return render-hsla($h, $s, $l, $a + $amount);
}
@function color-yiq($color, $dark: $yiq-text-dark, $light: $yiq-text-light) {
@if (is-color($color)) {
$r: red($color);
$g: green($color);
$b: blue($color);
$yiq: (($r * 299) + ($g * 587) + ($b * 114)) / 1000;
@if ($yiq >= $yiq-contrasted-threshold) {
@return $dark;
} @else {
@return $light;
}
} @else {
$c: to-hsl($color);
$l: map-get($c, l);
$th: $yiq-contrasted-threshold / 2.56; // convert hex to dec
$lightness: calc(-100 * calc(#{$l} - calc(#{$th} * 1%)));
// ignoring hue and saturation, just a light or dark gray
@return render-hsla(0, 0%, $lightness, 1);
}
}
// Taken from https://gist.github.com/johanlef/518a511b2b2f6b96c4f429b3af2f169a?permalink_comment_id=4053335#gistcomment-4053335
@function theme-color-level($color-name: "primary", $level: 0) {
$color: theme-color($color-name);
@if ($level == 0) {
@return $color;
}
$amount: $theme-color-interval * abs($level) / 100%;
$c: to-hsl($color);
$h: map-get($c, h);
$s: map-get($c, s);
$l: map-get($c, l);
$a: map-get($c, a);
@if ($level > 0) {
// Darken -X%: L = L * (1 - X)
// $rl: calc((#{$l} * #{1 - $amount}));
$rl: calc((#{$l} * #{$amount}));
@return render-hsla($h, $s, $rl, $a);
}
@if ($level < 0) {
// Ligthen +X%: L = L + X * (100 - L)
$rl: calc(#{$l} + #{$amount} * (100% - #{$l}));
@return render-hsla($h, $s, $rl, $a);
}
}

View file

@ -30,14 +30,44 @@
$font-size-base: .9rem; $font-size-base: .9rem;
$font-weight-bold: 500; $font-weight-bold: 500;
$blue: #2f7ed2; $white: var(--white);
$purple: #9932cc; $gray-100: var(--gray-100);
$yellow: #ffd452; $gray-200: var(--gray-200);
$gray-300: var(--gray-300);
$gray-400: var(--gray-400);
$gray-500: var(--gray-500);
$gray-600: var(--gray-600);
$gray-700: var(--gray-700);
$gray-800: var(--gray-800);
$gray-900: var(--gray-900);
$black: var(--black);
$blue: var(--blue);
$indigo: var(--indigo);
$purple: var(--purple);
$pink: var(--pink);
$red: var(--red);
$orange: var(--orange);
$yellow: var(--yellow);
$green: var(--green);
$teal: var(--teal);
$cyan: var(--cyan);
$theme-colors: ( $theme-colors: (
'best': $purple 'best': $purple,
); );
$yiq-contrasted-threshold: var(--yiq-contrasted-threshold);
$alert-bg-level: -10;
$alert-border-level: -9;
$alert-color-level: 5;
$list-group-item-bg-level: -11;
$list-group-item-color-level: 6;
$code-color: var(--code-color);
// Replace font-weight 300 with 200 // Replace font-weight 300 with 200
$font-weight-light: 200; $font-weight-light: 200;
$display1-weight: 200; $display1-weight: 200;
@ -62,15 +92,17 @@ $card-spacer-x: 1rem;
$list-group-item-padding-x: 1rem; $list-group-item-padding-x: 1rem;
// Hard coded for scss compilation to pass
$b-toast-background-opacity: 100%;
// Import default variables after the above setup to compute all other variables. // Import default variables after the above setup to compute all other variables.
@import '~bootstrap/scss/functions.scss'; @import '~bootstrap/scss/functions.scss';
@import '_functions-override.scss';
@import '~bootstrap/scss/variables'; @import '~bootstrap/scss/variables';
@import '~bootstrap/scss/mixins.scss'; @import '~bootstrap/scss/mixins.scss';
@import '~bootstrap-vue/src/variables'; @import '~bootstrap-vue/src/variables';
$body-color: $gray-800;
$hr-border-color: $gray-200; $hr-border-color: $gray-200;
$list-group-action-color: $gray-800; $list-group-action-color: $gray-800;
@ -97,3 +129,7 @@ $fa-font-size-base: $font-size-base;
// //
$thin-border: $hr-border-width solid $hr-border-color; $thin-border: $hr-border-width solid $hr-border-color;
$btn-padding-y-xs: .25rem;
$btn-padding-x-xs: .35rem;
$btn-line-height-xs: 1.5;

View file

@ -8,13 +8,131 @@
// Dependencies SCSS imports // Dependencies SCSS imports
// `~` allow to import a node_modules folder (resolved by Webpack) // `~` allow to import a node_modules folder (resolved by Webpack)
@import '~bootstrap/scss/bootstrap.scss'; // @import "~bootstrap/scss/root";
@import "~bootstrap/scss/reboot";
@import "~bootstrap/scss/type";
@import "~bootstrap/scss/images";
@import "~bootstrap/scss/code";
@import "~bootstrap/scss/grid";
@import "~bootstrap/scss/tables";
@import "~bootstrap/scss/forms";
@import "~bootstrap/scss/buttons";
@import "~bootstrap/scss/transitions";
@import "~bootstrap/scss/dropdown";
@import "~bootstrap/scss/button-group";
@import "~bootstrap/scss/input-group";
@import "~bootstrap/scss/custom-forms";
@import "~bootstrap/scss/nav";
@import "~bootstrap/scss/navbar";
@import "~bootstrap/scss/card";
@import "~bootstrap/scss/breadcrumb";
// @import "~bootstrap/scss/pagination";
@import "~bootstrap/scss/badge";
// @import "~bootstrap/scss/jumbotron";
@import "~bootstrap/scss/alert";
@import "~bootstrap/scss/progress";
// @import "~bootstrap/scss/media";
@import "~bootstrap/scss/list-group";
@import "~bootstrap/scss/close";
// @import "~bootstrap/scss/toasts";
@import "~bootstrap/scss/modal";
@import "~bootstrap/scss/tooltip";
@import "~bootstrap/scss/popover";
// @import "~bootstrap/scss/carousel";
@import "~bootstrap/scss/spinners";
@import "~bootstrap/scss/utilities";
// @import "~bootstrap/scss/print";
@import '~bootstrap-vue/src/index.scss'; @import '~bootstrap-vue/src/index.scss';
// Import fonts // Import fonts
@import 'font'; @import 'font';
@import '~fork-awesome/scss/fork-awesome.scss'; @import '~fork-awesome/scss/fork-awesome.scss';
// helper to set the required 4 CSS variables per color to allow `calc` computation of variants and states
@mixin hsl-color($name, $h, $s, $l) {
--#{$name}: hsl(#{$h}, #{$s}, #{$l});
--#{$name}-h: #{$h};
--#{$name}-s: #{$s};
--#{$name}-l: #{$l};
}
:root {
color-scheme: light;
--yiq-contrasted-threshold: 150;
@include hsl-color('white', 0, 0%, 100%);
@include hsl-color('black', 0, 0%, 0%);
@include hsl-color('blue', 211, 64%, 50%);
@include hsl-color('indigo', 263, 90%, 51%);
@include hsl-color('purple', 280, 61%, 50%);
@include hsl-color('pink', 332, 79%, 58%);
@include hsl-color('red', 354, 70%, 54%);
@include hsl-color('orange', 27, 98%, 54%);
@include hsl-color('yellow', 45, 100%, 66%);
@include hsl-color('green', 134, 61%, 41%);
@include hsl-color('teal', 162, 73%, 46%);
@include hsl-color('cyan', 188, 78%, 41%);
@include hsl-color('gray-100', 210, 17%, 98%);
@include hsl-color('gray-200', 210, 16%, 93%);
@include hsl-color('gray-300', 210, 14%, 89%);
@include hsl-color('gray-400', 210, 14%, 83%);
@include hsl-color('gray-500', 210, 11%, 71%);
@include hsl-color('gray-600', 208, 7%, 46%);
@include hsl-color('gray-700', 210, 9%, 31%);
@include hsl-color('gray-800', 210, 10%, 23%);
@include hsl-color('gray-900', 210, 11%, 15%);
--code-color: var(--pink);
// Overwrite list-group-item variants to lighter ones (used in diagnosis for example)
@each $color, $value in $theme-colors {
@include list-group-item-variant($color, theme-color-level($color, $list-group-item-bg-level), theme-color-level($color, $list-group-item-color-level));
.btn-#{$color} {
&:focus,
&.focus {
box-shadow: 0 0 0 $btn-focus-width rgba($value, .3);
}
}
}
}
[dark-theme="true"] {
color-scheme: dark; // Ask browser to use dark mode native styling
--yiq-contrasted-threshold: 120;
@include hsl-color('white', 256, 0%, 12.5%);
@include hsl-color('black', 256, 0%, 100%);
@include hsl-color('blue', 210.7, 95.5%, 65.5%);
@include hsl-color('purple', 280, 77.8%, 62.9%);
@include hsl-color('red', 0, 100%, 67.6%);
@include hsl-color('green', 134.3, 74.4%, 67.8%);
@include hsl-color('cyan', 188.4, 91.4%, 72.5%);
@include hsl-color('gray-900', 256, 0%, 98%);
@include hsl-color('gray-800', 256, 0%, 93%);
@include hsl-color('gray-700', 256, 0%, 89%);
@include hsl-color('gray-600', 256, 0%, 83%);
@include hsl-color('gray-500', 256, 0%, 71%);
@include hsl-color('gray-400', 256, 0%, 46%);
@include hsl-color('gray-300', 256, 0%, 31%);
@include hsl-color('gray-200', 256, 0%, 23%);
@include hsl-color('gray-100', 256, 0%, 15%);
--code-color: var(--gray-800);
@each $color, $value in $theme-colors {
@include list-group-item-variant($color, theme-color-level($color, -6), theme-color-level($color, 2));
.alert-#{$color} {
@include alert-variant(theme-color-level($color, -6), theme-color-level($color, -5), theme-color-level($color, 2));
}
}
}
// Style overrides happens after dependencies imports // Style overrides happens after dependencies imports
@ -30,7 +148,7 @@ body {
#app { #app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
min-height: 100vh min-height: 100vh;
} }
.menu-list .list-group-item { .menu-list .list-group-item {
@ -40,13 +158,6 @@ body {
} }
// Bootstrap overrides
// Overwrite list-group-item variants to lighter ones (used in diagnosis for example)
@each $color, $value in $theme-colors {
@include list-group-item-variant($color, theme-color-level($color, -11), theme-color-level($color, 6));
}
// Add breakpoints for w-* // Add breakpoints for w-*
@each $breakpoint in map-keys($grid-breakpoints) { @each $breakpoint in map-keys($grid-breakpoints) {
@each $size, $length in $sizes { @each $size, $length in $sizes {
@ -58,16 +169,24 @@ body {
} }
} }
// Add xs sized btn
.btn-xs {
@include button-size($btn-padding-y-xs, $btn-padding-x-xs, $btn-font-size-sm, $btn-line-height-xs, $btn-border-radius-sm);
}
// Allow state of input group to be displayed under the group // Allow state of input group to be displayed under the group
.input-group .is-invalid ~ .invalid-feedback { .input-group .is-invalid ~ .invalid-feedback {
display: block; display: block;
} }
.tooltip { top: 0; }
// Descriptive list (<b-row /> elems with <b-col> inside) // Descriptive list (<b-row /> elems with <b-col> inside)
// FIXME REMOVE when every infos switch to `DescriptionRow`
.row-line { .row-line {
@include media-breakpoint-up(md) { @include media-breakpoint-up(md) {
&:hover { &:hover {
background-color: rgba(0, 0, 0, 0.05); background-color: rgba($black, 0.05);
border-radius: 0.2rem; border-radius: 0.2rem;
} }
} }
@ -102,6 +221,11 @@ body {
} }
} }
h3.card-title {
margin-bottom: 1em;
border-bottom: solid 1px $hr-border-color;
}
// collapse icon // collapse icon
.not-collapsed > .icon { .not-collapsed > .icon {
transform: rotate(-90deg); transform: rotate(-90deg);
@ -165,8 +289,18 @@ body {
} }
} }
.alert p:last-child {
margin-bottom: 0;
}
code { code {
background: ghostwhite; background: $light;
padding: .15rem .25rem;
border-radius: $border-radius;
}
pre code {
padding: 0;
} }
.log { .log {

View file

@ -2,12 +2,27 @@ import Vue from 'vue'
import api from '@/api' import api from '@/api'
import { isEmptyValue } from '@/helpers/commons' import { isEmptyValue } from '@/helpers/commons'
import { stratify } from '@/helpers/data/tree'
export function getParentDomain (domain, domains, highest = false) {
const method = highest ? 'lastIndexOf' : 'indexOf'
let i = domain[method]('.')
while (i !== -1) {
const dn = domain.slice(i + 1)
if (domains.includes(dn)) return dn
i = domain[method]('.', i + (highest ? -1 : 1))
}
return null
}
export default { export default {
state: () => ({ state: () => ({
domains: undefined, // Array
main_domain: undefined, main_domain: undefined,
domains: undefined, // Array
domains_details: {},
users: undefined, // basic user data: Object {username: {data}} users: undefined, // basic user data: Object {username: {data}}
users_details: {}, // precise user data: Object {username: {data}} users_details: {}, // precise user data: Object {username: {data}}
groups: undefined, groups: undefined,
@ -19,6 +34,22 @@ export default {
state.domains = domains state.domains = domains
}, },
'SET_DOMAINS_DETAILS' (state, [name, details]) {
Vue.set(state.domains_details, name, details)
},
'UPDATE_DOMAINS_DETAILS' (state, payload) {
// FIXME use a common function to execute the same code ?
this.commit('SET_DOMAINS_DETAILS', payload)
},
'DEL_DOMAINS_DETAILS' (state, [name]) {
Vue.delete(state.domains_details, name)
if (state.domains) {
Vue.delete(state.domains, name)
}
},
'ADD_DOMAINS' (state, [{ domain }]) { 'ADD_DOMAINS' (state, [{ domain }]) {
state.domains.push(domain) state.domains.push(domain)
}, },
@ -48,12 +79,11 @@ export default {
Vue.set(state.users_details, username, userData) Vue.set(state.users_details, username, userData)
if (!state.users) return if (!state.users) return
const user = state.users[username] const user = state.users[username]
for (const key of ['firstname', 'lastname', 'mail']) { for (const key of ['fullname', 'mail']) {
if (user[key] !== userData[key]) { if (user[key] !== userData[key]) {
Vue.set(user, key, userData[key]) Vue.set(user, key, userData[key])
} }
} }
Vue.set(user, 'fullname', `${userData.firstname} ${userData.lastname}`)
}, },
'UPDATE_USERS_DETAILS' (state, payload) { 'UPDATE_USERS_DETAILS' (state, payload) {
@ -174,6 +204,44 @@ export default {
domains: state => state.domains, domains: state => state.domains,
orderedDomains: state => {
if (!state.domains) return
const splittedDomains = Object.fromEntries(state.domains.map(domain => {
// Keep the main part of the domain and the extension together
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
domain = domain.split('.')
domain.push(domain.pop() + domain.pop())
return [domain, domain.reverse()]
}))
return state.domains.sort((a, b) => splittedDomains[a] > splittedDomains[b])
},
domainsTree: (state, getters) => {
// This getter will not return any reactive data, make sure to assign its output
// to a component's `data`.
// FIXME manage to store the result in the store to allow reactive data (trigger an
// action when state.domain change)
const domains = getters.orderedDomains
if (!domains) return
const dataset = domains.map(name => ({
// data to build a hierarchy
name,
parent: getParentDomain(name, domains),
// utility data that will be used by `RecursiveListGroup` component
to: { name: 'domain-info', params: { name } },
opened: true
}))
return stratify(dataset)
},
domain: state => name => state.domains_details[name],
highestDomainParentName: (state, getters) => name => {
return getParentDomain(name, getters.orderedDomains, true)
},
mainDomain: state => state.main_domain, mainDomain: state => state.main_domain,
domainsAsChoices: state => { domainsAsChoices: state => {

View file

@ -142,8 +142,8 @@ export default {
}) })
}, },
'LOGIN' ({ dispatch }, password) { 'LOGIN' ({ dispatch }, credentials) {
return api.post('login', { credentials: password }, null, { websocket: false }).then(() => { return api.post('login', { credentials }, null, { websocket: false }).then(() => {
dispatch('CONNECT') dispatch('CONNECT')
}) })
}, },

View file

@ -13,6 +13,7 @@ export default {
fallbackLocale: localStorage.getItem('fallbackLocale'), fallbackLocale: localStorage.getItem('fallbackLocale'),
cache: localStorage.getItem('cache') !== 'false', cache: localStorage.getItem('cache') !== 'false',
transitions: localStorage.getItem('transitions') !== 'false', transitions: localStorage.getItem('transitions') !== 'false',
theme: localStorage.getItem('theme') !== 'false',
experimental: localStorage.getItem('experimental') === 'true', experimental: localStorage.getItem('experimental') === 'true',
spinner: 'pacman', spinner: 'pacman',
supportedLocales: supportedLocales supportedLocales: supportedLocales
@ -46,6 +47,12 @@ export default {
'SET_SPINNER' (state, spinner) { 'SET_SPINNER' (state, spinner) {
state.spinner = spinner state.spinner = spinner
},
'SET_THEME' (state, boolean) {
localStorage.setItem('theme', boolean)
state.theme = boolean
document.documentElement.setAttribute('dark-theme', boolean)
} }
}, },
@ -65,6 +72,10 @@ export default {
commit('SET_FALLBACKLOCALE', locale) commit('SET_FALLBACKLOCALE', locale)
i18n.fallbackLocale = [locale, 'en'] i18n.fallbackLocale = [locale, 'en']
}) })
},
'UPDATE_THEME' ({ commit }, theme) {
commit('SET_THEME', theme)
} }
}, },
@ -73,6 +84,7 @@ export default {
fallbackLocale: state => (state.fallbackLocale), fallbackLocale: state => (state.fallbackLocale),
cache: state => (state.cache), cache: state => (state.cache),
transitions: state => (state.transitions), transitions: state => (state.transitions),
theme: state => (state.theme),
experimental: state => state.experimental, experimental: state => state.experimental,
spinner: state => state.spinner, spinner: state => state.spinner,

View file

@ -25,7 +25,6 @@ export default {
{ routeName: 'domain-list', icon: 'globe', translation: 'domains' }, { routeName: 'domain-list', icon: 'globe', translation: 'domains' },
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' }, { routeName: 'app-list', icon: 'cubes', translation: 'applications' },
{ routeName: 'update', icon: 'refresh', translation: 'system_update' }, { routeName: 'update', icon: 'refresh', translation: 'system_update' },
{ routeName: 'service-list', icon: 'cog', translation: 'services' },
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' }, { routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
{ routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' }, { routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' },
{ routeName: 'backup', icon: 'archive', translation: 'backup' } { routeName: 'backup', icon: 'archive', translation: 'backup' }

View file

@ -1,37 +1,35 @@
<template> <template>
<b-form @submit.prevent="login"> <card-form
<b-input-group> :title="$t('login')" icon="lock"
<template v-slot:prepend> :validation="$v" :server-error="serverError"
<b-input-group-text> @submit.prevent="login"
<label class="sr-only" for="input-password">{{ $t('password') }}</label> >
<icon iname="lock" class="sm" /> <!-- ADMIN USERNAME -->
</b-input-group-text> <form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
</template>
<b-form-input <!-- ADMIN PASSWORD -->
id="input-password" <form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
required type="password"
v-model="password"
:placeholder="$t('administration_password')" :state="isValid"
/>
<template v-slot:append> <template #buttons>
<b-button type="submit" variant="success" :disabled="disabled"> <b-button
type="submit" variant="success"
:disabled="disabled" form="ynh-form"
>
{{ $t('login') }} {{ $t('login') }}
</b-button> </b-button>
</template> </template>
</b-input-group> </card-form>
<b-form-invalid-feedback :state="isValid">
{{ $t('wrong_password') }}
</b-form-invalid-feedback>
</b-form>
</template> </template>
<script> <script>
import { validationMixin } from 'vuelidate'
import { alphalownum_, required, minLength } from '@/helpers/validators'
export default { export default {
name: 'Login', name: 'Login',
mixins: [validationMixin],
props: { props: {
skipInstallCheck: { type: Boolean, default: false }, skipInstallCheck: { type: Boolean, default: false },
forceReload: { type: Boolean, default: false } forceReload: { type: Boolean, default: false }
@ -40,15 +38,42 @@ export default {
data () { data () {
return { return {
disabled: !this.skipInstallCheck, disabled: !this.skipInstallCheck,
password: '', serverError: '',
isValid: null, form: {
apiError: undefined username: '',
password: ''
},
fields: {
username: {
label: this.$i18n.t('user_username'),
props: {
id: 'username'
}
},
password: {
label: this.$i18n.t('password'),
props: {
id: 'password',
type: 'password'
}
}
}
}
},
validations () {
return {
form: {
username: { required, alphalownum_ },
password: { required, passwordLenght: minLength(8) }
}
} }
}, },
methods: { methods: {
login () { login () {
this.$store.dispatch('LOGIN', this.password).then(() => { const credentials = [this.form.username, this.form.password].join(':')
this.$store.dispatch('LOGIN', credentials).then(() => {
if (this.forceReload) { if (this.forceReload) {
window.location.href = '/yunohost/admin/' window.location.href = '/yunohost/admin/'
} else { } else {
@ -56,7 +81,7 @@ export default {
} }
}).catch(err => { }).catch(err => {
if (err.name !== 'APIUnauthorizedError') throw err if (err.name !== 'APIUnauthorizedError') throw err
this.isValid = false this.serverError = this.$i18n.t('wrong_password_or_username')
}) })
} }
}, },

View file

@ -12,7 +12,7 @@
<span v-html="$t('postinstall_intro_3')" /> <span v-html="$t('postinstall_intro_3')" />
</p> </p>
<b-button size="lg" variant="primary" @click="goToStep('domain')"> <b-button size="lg" variant="success" @click="goToStep('domain')">
{{ $t('begin') }} {{ $t('begin') }}
</b-button> </b-button>
</template> </template>
@ -33,16 +33,23 @@
</b-button> </b-button>
</template> </template>
<!-- PASSWORD SETUP STEP --> <!-- FIRST USER SETUP STEP -->
<template v-else-if="step === 'password'"> <template v-else-if="step === 'user'">
<password-form <card-form
:title="$t('postinstall_set_password')" :submit-text="$t('next')" :server-error="serverError" :title="$t('postinstall.user.title')" icon="user-plus"
@submit="setPassword" :validation="$v" :server-error="serverError"
:submit-text="$t('next')" @submit.prevent="setUser"
> >
<template #disclaimer> <read-only-alert-item
<p class="alert alert-warning" v-t="'postinstall_password'" /> :label="$t('postinstall.user.first_user_help')"
</template> type="info"
</password-form> />
<form-field
v-for="(field, name) in fields" :key="name"
v-bind="field" v-model="user[name]" :validation="$v.user[name]"
/>
</card-form>
<b-button variant="primary" @click="goToStep('domain')" class="mt-3"> <b-button variant="primary" @click="goToStep('domain')" class="mt-3">
<icon iname="chevron-left" /> {{ $t('previous') }} <icon iname="chevron-left" /> {{ $t('previous') }}
@ -74,25 +81,58 @@
</template> </template>
<script> <script>
import { validationMixin } from 'vuelidate'
import api from '@/api' import api from '@/api'
import { DomainForm, PasswordForm } from '@/views/_partials' import { DomainForm } from '@/views/_partials'
import Login from '@/views/Login' import Login from '@/views/Login'
import { alphalownum_, required, minLength, name, sameAs } from '@/helpers/validators'
export default { export default {
name: 'PostInstall', name: 'PostInstall',
mixins: [validationMixin],
components: { components: {
DomainForm, DomainForm,
PasswordForm,
Login Login
}, },
data () { data () {
return { return {
step: 'start', step: 'start',
serverError: '',
domain: undefined, domain: undefined,
password: undefined, user: {
serverError: '' username: '',
fullname: '',
password: '',
confirmation: ''
},
fields: {
username: {
label: this.$i18n.t('user_username'),
props: { id: 'username', placeholder: this.$i18n.t('placeholder.username') }
},
fullname: {
label: this.$i18n.t('user_fullname'),
props: { id: 'fullname', placeholder: this.$i18n.t('placeholder.fullname') }
},
password: {
label: this.$i18n.t('password'),
description: this.$i18n.t('good_practices_about_admin_password'),
descriptionVariant: 'warning',
props: { id: 'password', placeholder: '••••••••', type: 'password' }
},
confirmation: {
label: this.$i18n.t('password_confirmation'),
props: { id: 'confirmation', placeholder: '••••••••', type: 'password' }
}
}
} }
}, },
@ -104,11 +144,10 @@ export default {
setDomain ({ domain }) { setDomain ({ domain }) {
this.domain = domain this.domain = domain
this.goToStep('password') this.goToStep('user')
}, },
async setPassword ({ password }) { async setUser () {
this.password = password
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
this.$i18n.t('confirm_postinstall', { domain: this.domain }) this.$i18n.t('confirm_postinstall', { domain: this.domain })
) )
@ -117,22 +156,29 @@ export default {
}, },
performPostInstall (force = false) { performPostInstall (force = false) {
const data = {
domain: this.domain,
username: this.user.username,
fullname: this.user.fullname,
password: this.user.password
}
// FIXME does the api will throw an error for bad passwords ? // FIXME does the api will throw an error for bad passwords ?
api.post( api.post(
'postinstall' + (force ? '?force_diskspace' : ''), 'postinstall' + (force ? '?force_diskspace' : ''),
{ domain: this.domain, password: this.password }, data,
{ key: 'postinstall' } { key: 'postinstall' }
).then(() => { ).then(() => {
// Display success message and allow the user to login // Display success message and allow the user to login
this.goToStep('login') this.goToStep('login')
}).catch(err => { }).catch(err => {
const hasWordsInError = (words) => words.some((word) => (err.key || err.message).includes(word))
if (err.name !== 'APIBadRequestError') throw err if (err.name !== 'APIBadRequestError') throw err
if (err.key === 'postinstall_low_rootfsspace') { if (err.key === 'postinstall_low_rootfsspace') {
this.step = 'rootfsspace-error' this.step = 'rootfsspace-error'
} else if (err.key.includes('password')) { } else if (hasWordsInError(['domain', 'dyndns'])) {
this.step = 'password'
} else if (['domain', 'dyndns'].some(word => err.key.includes(word))) {
this.step = 'domain' this.step = 'domain'
} else if (hasWordsInError(['password', 'user'])) {
this.step = 'user'
} else { } else {
throw err throw err
} }
@ -141,6 +187,17 @@ export default {
} }
}, },
validations () {
return {
user: {
username: { required, alphalownum_ },
fullname: { required, name },
password: { required, passwordLenght: minLength(8) },
confirmation: { required, passwordMatch: sameAs('password') }
}
}
},
created () { created () {
this.$store.dispatch('CHECK_INSTALL').then(installed => { this.$store.dispatch('CHECK_INSTALL').then(installed => {
if (installed) { if (installed) {

View file

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

View file

@ -207,6 +207,7 @@ export default {
border-bottom-left-radius: 0; border-bottom-left-radius: 0;
font-size: $font-size-sm; font-size: $font-size-sm;
& > header { & > header {
cursor: ns-resize; cursor: ns-resize;
} }

View file

@ -1,88 +0,0 @@
<template>
<card-form
:title="title" icon="key-modern" :submit-text="submitText"
:validation="$v" :server-error="serverError"
@submit.prevent="onSubmit"
>
<template #disclaimer>
<p class="alert alert-warning">
{{ $t('good_practices_about_admin_password') }}
</p>
<slot name="disclaimer" />
<hr>
</template>
<slot name="extra" v-bind="{ v: $v, fields, form }">
<form-field
v-for="(value, key) in extra.fields" :key="key"
v-bind="value" v-model="$v.form.$model[key]" :validation="$v.form[key]"
/>
</slot>
<!-- ADMIN PASSWORD -->
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
<!-- ADMIN PASSWORD CONFIRMATION -->
<form-field v-bind="fields.confirmation" v-model="form.confirmation" :validation="$v.form.confirmation" />
</card-form>
</template>
<script>
import { validationMixin } from 'vuelidate'
import { required, minLength, sameAs } from '@/helpers/validators'
export default {
name: 'PasswordForm',
props: {
title: { type: String, required: true },
submitText: { type: String, default: null },
serverError: { type: String, default: '' },
extra: { type: Object, default: () => ({ form: {}, fields: {}, validations: {} }) }
},
data () {
return {
form: {
password: '',
confirmation: '',
...this.extra.form
},
fields: {
password: {
label: this.$i18n.t('password'),
props: { id: 'password', type: 'password', placeholder: '••••••••' }
},
confirmation: {
label: this.$i18n.t('password_confirmation'),
props: { id: 'confirmation', type: 'password', placeholder: '••••••••' }
},
...this.extra.fields
}
}
},
validations () {
return {
form: {
password: { required, passwordLenght: minLength(8) },
confirmation: { required, passwordMatch: sameAs('password') },
...this.extra.validations
}
}
},
methods: {
onSubmit () {
this.$emit('submit', this.form)
}
},
mixins: [validationMixin]
}
</script>

View file

@ -7,4 +7,3 @@ export { default as HistoryConsole } from './HistoryConsole'
export { default as ViewLockOverlay } from './ViewLockOverlay' export { default as ViewLockOverlay } from './ViewLockOverlay'
export { default as DomainForm } from './DomainForm' export { default as DomainForm } from './DomainForm'
export { default as PasswordForm } from './PasswordForm'

View file

@ -1,106 +0,0 @@
<template>
<view-base
:queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton"
>
<template v-if="actions" #default>
<b-alert variant="warning" class="mb-4">
<icon iname="exclamation-triangle" /> {{ $t('experimental_warning') }}
</b-alert>
<!-- ACTIONS FORMS -->
<card-form
v-for="(action, i) in actions" :key="i"
:title="action.name" icon="wrench" title-tag="h4"
:validation="$v.actions[i]" :id="action.id + '-form'" :server-error="action.serverError"
@submit.prevent="performAction(action)" :submit-text="$t('perform')"
>
<form-field
v-for="(field, fname) in action.fields" :key="fname" label-cols="0"
v-bind="field" v-model="action.form[fname]" :validation="$v.actions[i][fname]"
/>
</card-form>
</template>
<!-- In case of a custom url with no manifest found -->
<b-alert v-else-if="actions === null" variant="warning">
<icon iname="exclamation-triangle" /> {{ $t('app_no_actions') }}
</b-alert>
</view-base>
</template>
<script>
import api, { objectToParams } from '@/api'
import { validationMixin } from 'vuelidate'
import { formatI18nField, formatYunoHostArguments, formatFormData } from '@/helpers/yunohostArguments'
export default {
name: 'AppActions',
mixins: [validationMixin],
props: {
id: { type: String, required: true }
},
data () {
return {
queries: [
['GET', `apps/${this.id}/actions`],
['GET', { uri: 'domains' }],
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'users' }]
],
actions: undefined
}
},
validations () {
const validations = {}
for (const [i, action] of this.actions.entries()) {
if (action.validations) {
validations[i] = { form: action.validations }
}
}
return { actions: validations }
},
methods: {
onQueriesResponse (data) {
if (!data.actions) {
this.actions = null
return
}
this.actions = data.actions.map(({ name, id, description, arguments: arguments_ }) => {
const action = { name, id, serverError: '' }
if (description) action.description = formatI18nField(description)
if (arguments_ && arguments_.length) {
const { form, fields, validations } = formatYunoHostArguments(arguments_)
action.form = form
action.fields = fields
if (validations) action.validations = validations
}
return action
})
},
performAction (action) {
// FIXME api expects at least one argument ?! (fake one given with { dontmindthis } )
const args = objectToParams(action.form ? formatFormData(action.form) : { dontmindthis: undefined })
api.put(
`apps/${this.id}/actions/${action.id}`,
{ args },
{ key: 'apps.perform_action', action: action.id, name: this.id }
).then(() => {
this.$refs.view.fetchQueries()
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
action.serverError = err.message
})
}
}
}
</script>

View file

@ -70,27 +70,28 @@
<b-card-title class="d-flex mb-2"> <b-card-title class="d-flex mb-2">
{{ app.manifest.name }} {{ app.manifest.name }}
<small v-if="app.state !== 'working'" class="d-flex align-items-center ml-2"> <small v-if="app.state !== 'working' || app.high_quality" class="d-flex align-items-center ml-2">
<b-badge <b-badge
v-if="app.state !== 'highquality'" v-if="app.state !== 'working'"
:variant="(app.color === 'danger' && app.state === 'lowquality') ? 'warning' : app.color" :variant="app.color"
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)" v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
> >
<!-- app.state can be 'lowquality' or 'inprogress' -->
{{ $t('app_state_' + app.state) }} {{ $t('app_state_' + app.state) }}
</b-badge> </b-badge>
<icon <icon
v-else iname="star" class="star" v-if="app.high_quality" iname="star" class="star"
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)" v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)"
/> />
</small> </small>
</b-card-title> </b-card-title>
<b-card-text>{{ app.manifest.description }}</b-card-text> <b-card-text>{{ app.manifest.description }}</b-card-text>
<b-card-text v-if="app.maintained === 'orphaned'" class="align-self-end mt-auto"> <b-card-text v-if="!app.maintained" class="align-self-end mt-auto">
<span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')"> <span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')">
<icon iname="warning" /> {{ $t(app.maintained) }} <icon iname="warning" /> {{ $t('orphaned') }}
</span> </span>
</b-card-text> </b-card-text>
</b-card-body> </b-card-body>
@ -182,9 +183,9 @@ export default {
// Filtering options // Filtering options
qualityOptions: [ qualityOptions: [
{ value: 'isHighQuality', text: this.$i18n.t('only_highquality_apps') }, { value: 'high_quality', text: this.$i18n.t('only_highquality_apps') },
{ value: 'isDecentQuality', text: this.$i18n.t('only_decent_quality_apps') }, { value: 'decent_quality', text: this.$i18n.t('only_decent_quality_apps') },
{ value: 'isWorking', text: this.$i18n.t('only_working_apps') }, { value: 'working', text: this.$i18n.t('only_working_apps') },
{ value: 'all', text: this.$i18n.t('all_apps') } { value: 'all', text: this.$i18n.t('all_apps') }
], ],
categories: [ categories: [
@ -197,7 +198,7 @@ export default {
search: '', search: '',
category: null, category: null,
subtag: 'all', subtag: 'all',
quality: 'isDecentQuality', quality: 'decent_quality',
// Custom install form // Custom install form
customInstall: { customInstall: {
@ -264,51 +265,31 @@ export default {
}, },
methods: { methods: {
formatQuality (app) {
const filters = {
isHighQuality: false,
isDecentQuality: false,
isWorking: false,
state: 'inprogress'
}
if (app.state === 'inprogress') return filters
if (app.state === 'working' && app.level > 0) {
filters.state = 'working'
filters.isWorking = true
}
if (app.level <= 4 || app.level === '?') {
filters.state = 'lowquality'
return filters
} else {
filters.isDecentQuality = true
}
if (app.level >= 8) {
filters.state = 'highquality'
filters.isHighQuality = true
}
return filters
},
formatColor (app) {
if (app.isDecentQuality || app.isHighQuality) return 'success'
if (app.isWorking) return 'warning'
return 'danger'
},
onQueriesResponse (data) { onQueriesResponse (data) {
// APPS
const apps = [] const apps = []
for (const key in data.apps) { for (const key in data.apps) {
const app = data.apps[key] const app = data.apps[key]
if (app.state === 'notworking') continue app.isInstallable = !app.installed || app.manifest.integration.multi_instance
app.working = app.state === 'working'
Object.assign(app, this.formatQuality(app)) app.decent_quality = app.working && app.level > 4
app.isInstallable = !app.installed || app.manifest.multi_instance app.high_quality = app.working && app.level >= 8
if (app.maintained !== 'request_adoption') { app.color = 'danger'
app.maintained = app.maintained ? 'maintained' : 'orphaned' if (app.working && app.level <= 0) {
app.state = 'broken'
app.color = 'danger'
} else if (app.working && app.level <= 4) {
app.state = 'lowquality'
app.color = 'warning'
} else if (app.working) {
app.color = 'success'
} }
app.color = this.formatColor(app) app.searchValues = [
app.searchValues = [app.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ') app.id,
app.state,
app.manifest.name,
app.manifest.description,
app.potential_alternative_to.join(' ')
].join(' ').toLowerCase()
apps.push(app) apps.push(app)
} }
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1) this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
@ -328,10 +309,8 @@ export default {
// INSTALL APP // INSTALL APP
async onInstallClick (app) { async onInstallClick (app) {
if (!app.isDecentQuality) { if (!app.decent_quality) {
// Ask for confirmation const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + app.state))
const state = app.color === 'danger' ? 'inprogress' : app.state
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + state))
if (!confirmed) return if (!confirmed) return
} }
this.$router.push({ name: 'app-install', params: { id: app.id } }) this.$router.push({ name: 'app-install', params: { id: app.id } })

View file

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

View file

@ -2,24 +2,14 @@
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view"> <view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<!-- BASIC INFOS --> <!-- BASIC INFOS -->
<card v-if="infos" :title="infos.label" icon="cube"> <card v-if="infos" :title="infos.label" icon="cube">
<b-row <description-row
v-for="(value, prop) in infos" :key="prop" v-for="(value, key) in infos" :key="key"
no-gutters class="row-line" :term="$t(key)"
> >
<b-col cols="auto" md="3"> <a v-if="key === 'url'" :href="value" target="_blank">{{ value }}</a>
<strong>{{ $t(prop) }}</strong> <template v-else>{{ value }}</template>
</b-col> </description-row>
<b-col> <description-row :term="$t('app_info_access_desc')">
<a v-if="prop === 'url'" :href="value" target="_blank">{{ value }}</a>
<span v-else>{{ value }}</span>
</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col cols="auto" md="3">
<strong>{{ $t('app_info_access_desc') }}</strong>
<span class="sep" />
</b-col>
<b-col>
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') + '.' : $t('nobody') }} {{ allowedGroups.length > 0 ? allowedGroups.join(', ') + '.' : $t('nobody') }}
<b-button <b-button
size="sm" :to="{ name: 'group-list'}" variant="info" size="sm" :to="{ name: 'group-list'}" variant="info"
@ -27,8 +17,7 @@
> >
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }} <icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
</b-button> </b-button>
</b-col> </description-row>
</b-row>
</card> </card>
<!-- OPERATIONS --> <!-- OPERATIONS -->
@ -145,19 +134,6 @@
</b-form-group> </b-form-group>
</card> </card>
<!-- EXPERIMENTAL (displayed if experimental feature has been enabled in web-admin options)-->
<card v-if="experimental" :title="$t('experimental')" icon="flask">
<!-- APP ACTIONS -->
<b-form-group
:label="$t('app_actions_label')" label-for="actions"
label-cols-md="4" label-class="font-weight-bold"
>
<b-button id="actions" variant="warning" :to="{ name: 'app-actions', params: { id } }">
<icon iname="flask" /> {{ $t('app_actions') }}
</b-button>
</b-form-group>
</card>
<template #skeleton> <template #skeleton>
<card-info-skeleton :item-count="8" /> <card-info-skeleton :item-count="8" />
<card-form-skeleton /> <card-form-skeleton />
@ -195,7 +171,7 @@ export default {
}, },
computed: { computed: {
...mapGetters(['domains', 'experimental']), ...mapGetters(['domains']),
allowedGroups () { allowedGroups () {
if (!this.app) return if (!this.app) return
@ -243,7 +219,7 @@ export default {
label: mainPermission.label, label: mainPermission.label,
description: app.description, description: app.description,
version: app.version, version: app.version,
multi_instance: this.$i18n.t(app.manifest.multi_instance ? 'yes' : 'no'), multi_instance: this.$i18n.t(app.manifest.integration.multi_instance ? 'yes' : 'no'),
install_time: readableDate(app.settings.install_time, true, true) install_time: readableDate(app.settings.install_time, true, true)
} }
if (app.settings.domain && app.settings.path) { if (app.settings.domain && app.settings.path) {

View file

@ -3,18 +3,10 @@
<template v-if="infos"> <template v-if="infos">
<!-- BASIC INFOS --> <!-- BASIC INFOS -->
<card :title="name" icon="download"> <card :title="name" icon="download">
<b-row <description-row
v-for="(info, key) in infos" :key="key" v-for="(info, key) in infos" :key="key"
no-gutters class="row-line" :term="$t(key)" :details="info"
> />
<b-col cols="5" md="3" xl="3">
<strong>{{ $t(key) }}</strong>
<span class="sep" />
</b-col>
<b-col>
<span>{{ info }}</span>
</b-col>
</b-row>
</card> </card>
<!-- INSTALL FORM --> <!-- INSTALL FORM -->
@ -24,10 +16,9 @@
@submit.prevent="performInstall" @submit.prevent="performInstall"
> >
<template v-for="(field, fname) in fields"> <template v-for="(field, fname) in fields">
<form-field <component
v-if="isVisible(field.visible, field)" v-if="field.visible" :is="field.is" v-bind="field.props"
:key="fname" label-cols="0" v-model="form[fname]" :validation="$v.form[fname]" :key="fname"
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
/> />
</template> </template>
</card-form> </card-form>
@ -47,10 +38,13 @@
<script> <script>
import { validationMixin } from 'vuelidate' import { validationMixin } from 'vuelidate'
import evaluate from 'simple-evaluate'
import api, { objectToParams } from '@/api' import api, { objectToParams } from '@/api'
import { formatYunoHostArguments, formatI18nField, formatFormData, pFileReader } from '@/helpers/yunohostArguments' import {
formatYunoHostArguments,
formatI18nField,
formatFormData
} from '@/helpers/yunohostArguments'
export default { export default {
name: 'AppInstall', name: 'AppInstall',
@ -68,7 +62,6 @@ export default {
], ],
name: undefined, name: undefined,
infos: undefined, infos: undefined,
formDisclaimer: null,
form: undefined, form: undefined,
fields: undefined, fields: undefined,
validations: null, validations: null,
@ -85,17 +78,28 @@ export default {
onQueriesResponse (manifest) { onQueriesResponse (manifest) {
this.name = manifest.name this.name = manifest.name
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance'] const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
manifest.license = manifest.upstream.license
if (manifest.license === undefined || manifest.license === 'free') { if (manifest.license === undefined || manifest.license === 'free') {
infosKeys.splice(2, 1) infosKeys.splice(2, 1)
} }
manifest.description = formatI18nField(manifest.description) manifest.description = formatI18nField(manifest.description)
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no') manifest.multi_instance = this.$i18n.t(manifest.integration.multi_instance ? 'yes' : 'no')
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]])) this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
const { form, fields, validations, errors } = formatYunoHostArguments( // FIXME yunohost should add the label field by default
manifest.arguments.install, manifest.install.unshift({
manifest.name ask: this.$t('label_for_manifestname', { name: manifest.name }),
) default: manifest.name,
name: 'label',
help: this.$t('label_for_manifestname_help')
})
const {
form,
fields,
validations,
errors
} = formatYunoHostArguments(manifest.install)
this.fields = fields this.fields = fields
this.form = form this.form = form
@ -103,41 +107,6 @@ export default {
this.errors = errors 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 () { async performInstall () {
if ('path' in this.form && this.form.path === '/') { if ('path' in this.form && this.form.path === '/') {
const confirmed = await this.$askConfirmation( const confirmed = await this.$askConfirmation(
@ -148,7 +117,7 @@ export default {
const { data: args, label } = await formatFormData( const { data: args, label } = await formatFormData(
this.form, 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 } const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }

View file

@ -68,25 +68,8 @@ export default {
return return
} }
const multiInstances = {} this.apps = apps.map(({ id, name, description, manifest }) => {
this.apps = apps.map(({ id, name, description, permissions, manifest }) => { return { id, name: manifest.name, label: name, description }
// FIXME seems like some apps may no have a label (replace with id)
const label = permissions[id + '.main'].label
// Display the `id` of the instead of its `name` if multiple apps share the same name
if (manifest.multi_instance) {
if (!(name in multiInstances)) {
multiInstances[name] = []
}
const labels = multiInstances[name]
if (labels.includes(label)) {
name = id
}
labels.push(label)
}
if (label === name) {
name = null
}
return { id, name, description, label }
}).sort((prev, app) => { }).sort((prev, app) => {
return prev.label > app.label ? 1 : -1 return prev.label > app.label ? 1 : -1
}) })

View file

@ -74,7 +74,7 @@
</b-button> </b-button>
<b-button <b-button
v-if="item.details" v-if="item.details"
size="sm" variant="outline-dark" class="ml-lg-2 mt-2 mt-lg-0" size="sm" :variant="'outline-' + (theme ? 'light' : 'dark')" class="ml-lg-2 mt-2 mt-lg-0"
v-b-toggle="`collapse-${report.id}-item-${i}`" v-b-toggle="`collapse-${report.id}-item-${i}`"
> >
<icon iname="level-down" /> {{ $t('details') }} <icon iname="level-down" /> {{ $t('details') }}
@ -104,6 +104,8 @@
</template> </template>
<script> <script>
import { mapGetters } from 'vuex'
import api from '@/api' import api from '@/api'
import { distanceToNow } from '@/helpers/filters/date' import { distanceToNow } from '@/helpers/filters/date'
@ -120,6 +122,10 @@ export default {
} }
}, },
computed: {
...mapGetters(['theme'])
},
methods: { methods: {
formatReportItem (report, item) { formatReportItem (report, item) {
let issue = false let issue = false

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,10 +3,16 @@
:queries="queries" @queries-response="onQueriesResponse" :loading="loading" :queries="queries" @queries-response="onQueriesResponse" :loading="loading"
skeleton="card-info-skeleton" skeleton="card-info-skeleton"
> >
<card v-if="showAutoConfigCard" :title="$t('domain.dns.auto_config')" icon="wrench"> <section v-if="showAutoConfigCard" class="panel-section">
<b-alert variant="warning"> <b-card-title title-tag="h3">
<icon iname="flask" /> <icon iname="warning" /> <span v-html="$t('domain.dns.info')" /> {{ $t('domain.dns.auto_config') }}
</b-alert> </b-card-title>
<read-only-alert-item
:label="$t('domain.dns.info')"
type="warning"
icon="flask"
/>
<!-- AUTO CONFIG CHANGES --> <!-- AUTO CONFIG CHANGES -->
<template v-if="dnsChanges"> <template v-if="dnsChanges">
@ -32,27 +38,32 @@
</template> </template>
<!-- CONFIG OK ALERT --> <!-- CONFIG OK ALERT -->
<b-alert v-else-if="dnsChanges === null" variant="success" class="m-0"> <read-only-alert-item
<icon iname="thumbs-up" /> {{ $t('domain.dns.auto_config_ok') }} v-else-if="dnsChanges === null"
</b-alert> :label="$t('domain.dns.auto_config_ok')"
type="success"
icon="thumbs-up"
/>
<!-- CONFIG ERROR ALERT --> <!-- CONFIG ERROR ALERT -->
<template v-if="dnsErrors && dnsErrors.length"> <template v-if="dnsErrors && dnsErrors.length">
<b-alert <read-only-alert-item
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i" v-for="({ variant, icon, message }, i) in dnsErrors" :key="i"
:variant="variant" :class="dnsErrors.length === 1 ? 'm-0' : ''" :label="message"
> :type="variant"
<icon :iname="icon" /> <span v-html="message" /> :icon="icon"
</b-alert> />
</template> </template>
<!-- CONFIG OVERWRITE DISCLAIMER --> <!-- CONFIG OVERWRITE DISCLAIMER -->
<b-alert v-if="force !== null" variant="warning"> <read-only-alert-item
<icon iname="warning" /> <span v-html="$t('domain.dns.push_force_warning')" /> v-if="force !== null"
</b-alert> :label="$t('domain.dns.push_force_warning')"
type="warning"
/>
<!-- CONFIG PUSH SUBMIT --> <!-- CONFIG PUSH SUBMIT -->
<template v-if="dnsChanges" #buttons> <template v-if="dnsChanges">
<b-form-checkbox v-if="force !== null" v-model="force"> <b-form-checkbox v-if="force !== null" v-model="force">
{{ $t('domain.dns.push_force') }} {{ $t('domain.dns.push_force') }}
</b-form-checkbox> </b-form-checkbox>
@ -61,13 +72,14 @@
{{ $t('domain.dns.push') }} {{ $t('domain.dns.push') }}
</b-button> </b-button>
</template> </template>
</card> </section>
<!-- CURRENT DNS ZONE --> <!-- CURRENT DNS ZONE -->
<card <section v-if="showAutoConfigCard && dnsZone && dnsZone.length" class="panel-section">
v-if="showAutoConfigCard && dnsZone && dnsZone.length" <b-card-title title-tag="h3">
:title="$t('domain.dns.auto_config_zone')" icon="globe" no-body {{ $t('domain.dns.auto_config_zone') }}
> </b-card-title>
<div class="log"> <div class="log">
<div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records"> <div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records">
{{ record }} {{ record }}
@ -75,19 +87,21 @@
<span>{{ content }}</span> <span>{{ content }}</span>
</div> </div>
</div> </div>
</card> </section>
<!-- MANUAL CONFIG CARD --> <!-- MANUAL CONFIG CARD -->
<card <section v-if="showManualConfigCard" class="panel-section">
v-if="showManualConfigCard" <b-card-title title-tag="h3">
:title="$t('domain.dns.manual_config')" icon="globe" no-body {{ $t('domain.dns.manual_config') }}
> </b-card-title>
<b-alert variant="warning" class="m-0">
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }} <read-only-alert-item
</b-alert> :label="$t('domain_dns_conf_is_just_a_recommendation')"
type="warning"
/>
<pre class="log">{{ dnsConfig }}</pre> <pre class="log">{{ dnsConfig }}</pre>
</card> </section>
</view-base> </view-base>
</template> </template>

View file

@ -1,83 +1,160 @@
<template> <template>
<view-base :queries="queries" skeleton="card-list-skeleton"> <view-base
<card :title="name" icon="globe"> :queries="queries" @queries-response="onQueriesResponse"
<!-- VISIT --> ref="view" skeleton="card-list-skeleton"
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p> >
<b-button variant="success" :href="'https://' + name" target="_blank"> <!-- INFO CARD -->
<icon iname="external-link" /> {{ $t('domain_visit') }} <card v-if="domain" :title="name" icon="globe">
</b-button> <template v-if="isMainDomain" #header-next>
<hr> <b-badge variant="info" class="main-domain-badge">
<explain-what
id="explain-main-domain"
:title="$t('domain.types.main_domain')"
:content="$t('domain.explain.main_domain', { domain: name })"
>
<icon iname="star" /> {{ $t('domain.types.main_domain') }}
</explain-what>
</b-badge>
</template>
<template #header-buttons>
<!-- DEFAULT DOMAIN --> <!-- DEFAULT DOMAIN -->
<p>{{ $t('domain_default_desc') }}</p> <b-button v-if="!isMainDomain" @click="setAsDefaultDomain" variant="info">
<p v-if="isMainDomain" class="alert alert-info">
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
</p>
<b-button v-else variant="info" @click="setAsDefaultDomain">
<icon iname="star" /> {{ $t('set_default') }} <icon iname="star" /> {{ $t('set_default') }}
</b-button> </b-button>
<hr>
<!-- DOMAIN CONFIG --> <!-- DELETE DOMAIN -->
<p>{{ $t('domain.config.edit') }}</p> <b-button @click="deleteDomain" :disabled="isMainDomain" variant="danger">
<b-button variant="warning" :to="{ name: 'domain-config', param: { name } }">
<icon iname="cog" /> {{ $t('domain.config.title') }}
</b-button>
<hr>
<!-- DNS CONFIG -->
<p>{{ $t('domain.dns.edit') }}</p>
<b-button variant="warning" :to="{ name: 'domain-dns', param: { name } }">
<icon iname="globe" /> {{ $t('domain_dns_config') }}
</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
v-if="isMainDomain" class="alert alert-info"
v-html="$t('domain_delete_forbidden_desc', { domain: name })"
/>
<b-button v-else variant="danger" @click="deleteDomain">
<icon iname="trash-o" /> {{ $t('delete') }} <icon iname="trash-o" /> {{ $t('delete') }}
</b-button> </b-button>
</template>
<!-- DOMAIN LINK -->
<description-row :term="$t('words.link')">
<b-link :href="'https://' + name" target="_blank">
https://{{ name }}
</b-link>
</description-row>
<!-- DOMAIN CERT AUTHORITY -->
<description-row :term="$t('domain.info.certificate_authority')">
<icon :iname="cert.icon" :variant="cert.variant" class="mr-1" />
{{ $t('domain.cert.types.' + cert.authority) }}
<span class="text-secondary px-2">({{ $t('domain.cert.valid_for', { days: $tc('day_validity', cert.validity) }) }})</span>
</description-row>
<!-- DOMAIN REGISTRAR -->
<description-row v-if="domain.registrar" :term="$t('domain.info.registrar')">
<template v-if="domain.registrar === 'parent_domain'">
{{ $t('domain.see_parent_domain') }}&nbsp;<b-link :href="`#/domains/${domain.topest_parent}/dns`">
{{ domain.topest_parent }}
</b-link>
</template>
<template v-else>
{{ domain.registrar }}
</template>
</description-row>
<!-- DOMAIN APPS -->
<description-row :term="$t('domain.info.apps_on_domain')">
<b-button-group
v-for="app in domain.apps" :key="app.id"
size="sm" class="mr-2"
>
<b-button class="py-0 font-weight-bold" variant="outline-dark" :to="{ name: 'app-info', params: { id: app.id }}">
{{ app.name }}
</b-button>
<b-button
variant="outline-dark" class="py-0 px-1"
:href="'https://' + name + app.path" target="_blank"
>
<span class="sr-only">{{ $t('app.visit_app') }}</span>
<icon iname="external-link" />
</b-button>
</b-button-group>
{{ domain.apps.length ? '' : $t('words.none') }}
</description-row>
</card> </card>
<config-panels v-if="config.panels" v-bind="config" @submit="onConfigSubmit">
<template v-if="currentTab === 'dns'" #tab-after>
<domain-dns :name="name" />
</template>
</config-panels>
</view-base> </view-base>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import api from '@/api' import api, { objectToParams } from '@/api'
import {
formatFormData,
formatYunoHostConfigPanels
} from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels'
import DomainDns from './DomainDns.vue'
export default { export default {
name: 'DomainInfo', name: 'DomainInfo',
props: { components: {
name: { ConfigPanels,
type: String, DomainDns
required: true
}
}, },
data: () => { props: {
name: { type: String, required: true }
},
data () {
return { return {
queries: [ queries: [
['GET', { uri: 'domains/main', storeKey: 'main_domain' }] ['GET', { uri: 'domains', storeKey: 'domains' }],
] ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'domains', storeKey: 'domains_details', param: this.name }],
['GET', `domains/${this.name}/config?full`]
],
config: {}
} }
}, },
computed: { computed: {
...mapGetters(['mainDomain']), ...mapGetters(['mainDomain']),
currentTab () {
return this.$route.params.tabId
},
domain () {
return this.$store.getters.domain(this.name)
},
parentName () {
return this.$store.getters.highestDomainParentName(this.name)
},
cert () {
const { CA_type: authority, validity } = this.domain.certificate
const baseInfos = { authority, validity }
if (validity <= 0) {
return { icon: 'times', variant: 'danger', ...baseInfos }
} else if (authority === 'other') {
return validity < 15
? { icon: 'exclamation', variant: 'danger', ...baseInfos }
: { icon: 'check', variant: 'success', ...baseInfos }
} else if (authority === 'letsencrypt') {
return { icon: 'thumbs-up', variant: 'success', ...baseInfos }
}
return { icon: 'exclamation', variant: 'warning', ...baseInfos }
},
dns () {
return this.domain.dns
},
isMainDomain () { isMainDomain () {
if (!this.mainDomain) return if (!this.mainDomain) return
return this.name === this.mainDomain return this.name === this.mainDomain
@ -85,6 +162,30 @@ export default {
}, },
methods: { methods: {
onQueriesResponse (domains, mainDomain, domain, config) {
this.config = formatYunoHostConfigPanels(config)
},
async onConfigSubmit ({ id, form, action, name }) {
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
api.put(
action
? `domain/${this.name}/actions/${action}`
: `domains/${this.name}/config/${id}`,
{ args: objectToParams(args) },
{ key: `domains.${action ? 'action' : 'update'}_config`, id, name: this.name }
).then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find(panel => panel.id === id)
if (err.data.name) {
this.config.errors[id][err.data.name].message = err.message
} else this.$set(panel, 'serverError', err.message)
})
},
async deleteDomain () { async deleteDomain () {
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name })) const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
if (!confirmed) return if (!confirmed) return
@ -112,3 +213,10 @@ export default {
} }
} }
</script> </script>
<style lang="scss" scoped>
.main-domain-badge {
font-size: .75rem;
padding-right: .2em;
}
</style>

View file

@ -3,9 +3,10 @@
id="domain-list" id="domain-list"
:search.sync="search" :search.sync="search"
:items="domains" :items="domains"
:filtered-items="filteredDomains"
items-name="domains" items-name="domains"
:queries="queries" :queries="queries"
:filtered-items="hasFilteredItems"
@queries-response="onQueriesResponse"
> >
<template #top-bar-buttons> <template #top-bar-buttons>
<b-button variant="success" :to="{ name: 'domain-add' }"> <b-button variant="success" :to="{ name: 'domain-add' }">
@ -14,57 +15,75 @@
</b-button> </b-button>
</template> </template>
<b-list-group> <recursive-list-group :tree="tree" :toggle-text="$t('domain.toggle_subdomains')" class="mb-5">
<b-list-group-item <template #default="{ data, parent }">
v-for="domain in filteredDomains" :key="domain" <div class="w-100 d-flex justify-content-between align-items-center">
:to="{ name: 'domain-info', params: { name: domain }}" <h5 class="mr-3">
class="d-flex justify-content-between align-items-center pr-0" <b-link :to="data.to" class="text-body text-decoration-none">
<span class="font-weight-bold">{{ data.name.replace(parent ? parent.data.name : null, '') }}</span>
<span v-if="parent" class="text-secondary">{{ parent.data.name }}</span>
</b-link>
<small
v-if="data.name === mainDomain"
:title="$t('domain.types.main_domain')" class="ml-1"
v-b-tooltip.hover
> >
<div> <icon iname="star" />
<h5 class="font-weight-bold">
{{ domain }}
<small v-if="domain === mainDomain">
<span class="sr-only">{{ $t('words.default') }}</span>
<icon iname="star" :title="$t('words.default')" />
</small> </small>
</h5> </h5>
<p class="font-italic m-0">
https://{{ domain }}
</p>
</div> </div>
<icon iname="chevron-right" class="lg fs-sm ml-auto" /> </template>
</b-list-group-item> </recursive-list-group>
</b-list-group>
</view-search> </view-search>
</template> </template>
<script> <script>
import { mapGetters } from 'vuex' import { mapGetters } from 'vuex'
import RecursiveListGroup from '@/components/RecursiveListGroup'
export default { export default {
name: 'DomainList', name: 'DomainList',
components: {
RecursiveListGroup
},
data () { data () {
return { return {
queries: [ queries: [
['GET', { uri: 'domains/main', storeKey: 'main_domain' }], ['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
['GET', { uri: 'domains' }] ['GET', { uri: 'domains', storeKey: 'domains' }]
], ],
search: '' search: '',
domainsTree: undefined
} }
}, },
computed: { computed: {
...mapGetters(['domains', 'mainDomain']), ...mapGetters(['domains', 'mainDomain']),
filteredDomains () { tree () {
if (!this.domains || !this.mainDomain) return if (!this.domainsTree) return
if (this.search) {
const search = this.search.toLowerCase() const search = this.search.toLowerCase()
const mainDomain = this.mainDomain return this.domainsTree.filter(node => node.data.name.includes(search))
const domains = this.domains }
.filter(name => name.toLowerCase().includes(search)) return this.domainsTree
.sort(prevDomain => prevDomain === mainDomain ? -1 : 1) },
return domains.length ? domains : null
hasFilteredItems () {
if (!this.tree) return
return this.tree.children || null
}
},
methods: {
onQueriesResponse () {
// Add the tree to `data` to make it reactive
this.domainsTree = this.$store.getters.domainsTree
} }
} }
} }

View file

@ -43,7 +43,7 @@
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em> <em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em>
</p> </p>
</template> </template>
<template v-else> <template v-if="groupName == 'admins' || !group.isSpecial">
<tags-selectize-item <tags-selectize-item
v-model="group.members" :options="usersOptions" v-model="group.members" :options="usersOptions"
:id="groupName + '-users'" :label="$t('group_add_member')" :id="groupName + '-users'" :label="$t('group_add_member')"
@ -173,7 +173,7 @@ export default {
continue continue
} }
group.isSpecial = ['visitors', 'all_users'].includes(groupName) group.isSpecial = ['visitors', 'all_users', 'admins'].includes(groupName)
if (groupName === 'visitors') { if (groupName === 'visitors') {
// Forbid to add or remove a protected permission on group `visitors` // Forbid to add or remove a protected permission on group `visitors`
@ -189,6 +189,13 @@ export default {
}).map(({ id }) => permsDict[id].label) }).map(({ id }) => permsDict[id].label)
} }
if (groupName === 'admins') {
// Forbid to add ssh and sftp permission on group `admins`
group.disabledItems = permissions.filter(({ id }) => {
return ['ssh.main', 'sftp.main'].includes(id)
}).map(({ id }) => permsDict[id].label)
}
primaryGroups[groupName] = group primaryGroups[groupName] = group
} }

View file

@ -1,69 +0,0 @@
<template>
<password-form
:title="$t('postinstall_set_password')"
:server-error="serverError"
@submit="onSubmit"
:extra="extra"
/>
</template>
<script>
import api from '@/api'
import { validationMixin } from 'vuelidate'
import { PasswordForm } from '@/views/_partials'
import { required, minLength } from '@/helpers/validators'
export default {
name: 'ToolAdminpw',
data () {
return {
serverError: '',
extra: {
form: {
currentPassword: ''
},
fields: {
currentPassword: {
label: this.$i18n.t('tools_adminpw_current'),
description: this.$i18n.t('tools_adminpw_current_placeholder'),
props: { id: 'current-password', type: 'password', placeholder: '••••••••' }
}
},
validations: {
currentPassword: { required, passwordLenght: minLength(8) }
}
}
}
},
methods: {
onSubmit ({ currentPassword, password }) {
this.serverError = ''
api.fetchAll(
[['POST', 'login', { credentials: currentPassword }, null, { websocket: false }],
['PUT', 'adminpw', { new_password: password }, 'adminpw']],
{ wait: true }
).then(() => {
this.$store.dispatch('DISCONNECT')
}).catch(err => {
if (err.name === 'APIUnauthorizedError') {
// Prevent automatic disconnect if error in current password.
this.serverError = this.$i18n.t('wrong_password')
} else if (err.name === 'APIBadRequestError') {
// Display form error
this.serverError = err.message
} else {
throw err
}
})
}
},
mixins: [validationMixin],
components: { PasswordForm }
}
</script>

View file

@ -22,9 +22,10 @@ export default {
menu: [ menu: [
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' }, { routeName: 'tool-logs', icon: 'book', translation: 'logs' },
{ routeName: 'tool-migrations', icon: 'share', translation: 'migrations' }, { routeName: 'tool-migrations', icon: 'share', translation: 'migrations' },
{ routeName: 'service-list', icon: 'gears', translation: 'services' },
{ routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' }, { routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
{ routeName: 'tool-adminpw', icon: 'key-modern', translation: 'tools_adminpw' }, { routeName: 'tool-settings', icon: 'sliders', translation: 'tools_yunohost_settings' },
{ routeName: 'tool-webadmin', icon: 'cog', translation: 'tools_webadmin_settings' }, { routeName: 'tool-webadmin', icon: 'sliders', translation: 'tools_webadmin_settings' },
{ routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' } { routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' }
] ]
} }

View file

@ -3,7 +3,7 @@
:queries="queries" @queries-response="onQueriesResponse" :queries="queries" @queries-response="onQueriesResponse"
ref="view" skeleton="card-form-skeleton" 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> </view-base>
</template> </template>
@ -17,20 +17,18 @@ import ConfigPanels from '@/components/ConfigPanels'
export default { export default {
name: 'DomainConfig', name: 'ToolSettingsConfig',
components: { components: {
ConfigPanels ConfigPanels
}, },
props: { props: {},
name: { type: String, required: true }
},
data () { data () {
return { return {
queries: [ queries: [
['GET', `domains/${this.name}/config?full`] ['GET', 'settings?full']
], ],
config: {} config: {}
} }
@ -41,23 +39,21 @@ export default {
this.config = formatYunoHostConfigPanels(config) this.config = formatYunoHostConfigPanels(config)
}, },
async applyConfig (id_) { async onConfigSubmit ({ id, form }) {
const formatedData = await formatFormData( const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
this.config.forms[id_],
{ removeEmpty: false, removeNull: true, multipart: false }
)
// FIXME no route for potential action
api.put( api.put(
`domains/${this.name}/config`, `settings/${id}`,
{ key: id_, args: objectToParams(formatedData) }, { args: objectToParams(args) },
{ key: 'domains.update_config', name: this.name } { key: 'settings.update', panel: id }
).then(() => { ).then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true }) this.$refs.view.fetchQueries({ triggerLoading: true })
}).catch(err => { }).catch(err => {
if (err.name !== 'APIBadRequestError') throw 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) { 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) } else this.$set(panel, 'serverError', err.message)
}) })
} }

View file

@ -60,6 +60,13 @@ export default {
label: this.$i18n.t('tools_webadmin.transitions'), label: this.$i18n.t('tools_webadmin.transitions'),
component: 'CheckboxItem', component: 'CheckboxItem',
props: { labels: { true: 'enabled', false: 'disabled' } } props: { labels: { true: 'enabled', false: 'disabled' } }
},
theme: {
id: 'theme',
label: this.$i18n.t('tools_webadmin.theme'),
component: 'CheckboxItem',
props: { labels: { true: '🌙', false: '☀️' } }
} }
// experimental: added in `created()` // experimental: added in `created()`
@ -69,7 +76,7 @@ export default {
computed: { computed: {
// Those are set/get computed properties // Those are set/get computed properties
...mapStoreGetSet(['locale', 'fallbackLocale'], 'dispatch'), ...mapStoreGetSet(['locale', 'fallbackLocale', 'theme'], 'dispatch'),
...mapStoreGetSet(['cache', 'transitions', 'experimental']) ...mapStoreGetSet(['cache', 'transitions', 'experimental'])
}, },

View file

@ -9,26 +9,7 @@
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" /> <form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
<!-- USER FULLNAME --> <!-- USER FULLNAME -->
<form-field <form-field v-bind="fields.fullname" :validation="$v.form.fullname" v-model="form.fullname" />
v-bind="fields.fullname" :validation="$v.form.fullname"
>
<template #default="{ self }">
<b-input-group>
<template v-for="fname in ['firstname', 'lastname']">
<b-input-group-prepend :key="fname + 'prepend'">
<b-input-group-text :id="fname + '-label'" tag="label">
{{ self[fname].label }}
</b-input-group-text>
</b-input-group-prepend>
<input-item
v-bind="self[fname]" v-model="form.fullname[fname]" :key="fname + 'input'"
:name="self[fname].id" :aria-labelledby="fname + '-label'"
/>
</template>
</b-input-group>
</template>
</form-field>
<hr> <hr>
<!-- USER MAIL DOMAIN --> <!-- USER MAIL DOMAIN -->
@ -82,10 +63,7 @@ export default {
form: { form: {
username: '', username: '',
fullname: { fullname: '',
firstname: '',
lastname: ''
},
domain: '', domain: '',
password: '', password: '',
confirmation: '' confirmation: ''
@ -104,18 +82,9 @@ export default {
fullname: { fullname: {
label: this.$i18n.t('user_fullname'), label: this.$i18n.t('user_fullname'),
id: 'fullname',
props: { props: {
firstname: { id: 'fullname',
id: 'firstname', placeholder: this.$i18n.t('placeholder.fullname')
label: this.$i18n.t('common.firstname'),
placeholder: this.$i18n.t('placeholder.firstname')
},
lastname: {
id: 'lastname',
label: this.$i18n.t('common.lastname'),
placeholder: this.$i18n.t('placeholder.lastname')
}
} }
}, },
@ -156,10 +125,7 @@ export default {
return { return {
form: { form: {
username: { required, alphalownum_, notInUsers: unique(this.userNames) }, username: { required, alphalownum_, notInUsers: unique(this.userNames) },
fullname: { fullname: { required, name },
firstname: { required, name },
lastname: { required, name }
},
domain: { required }, domain: { required },
password: { required, passwordLenght: minLength(8) }, password: { required, passwordLenght: minLength(8) },
confirmation: { required, passwordMatch: sameAs('password') } confirmation: { required, passwordMatch: sameAs('password') }
@ -189,10 +155,6 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
#lastname-label {
border-left: 0;
}
.custom-select { .custom-select {
flex-basis: 40%; flex-basis: 40%;
} }

View file

@ -8,26 +8,9 @@
<!-- USERNAME (disabled) --> <!-- USERNAME (disabled) -->
<form-field v-bind="fields.username" /> <form-field v-bind="fields.username" />
<!-- USER FULLNAME (FIXME quite a mess, but will be removed)--> <!-- USER FULLNAME -->
<form-field v-bind="fields.fullname" :validation="$v.form.fullname"> <form-field v-bind="fields.fullname" v-model="form.fullname" :validation="$v.form.fullname" />
<template #default="{ self }">
<b-input-group>
<template v-for="name_ in ['firstname', 'lastname']">
<b-input-group-prepend :key="name_ + 'prepend'">
<b-input-group-text :id="name_ + '-label'" tag="label">
{{ self[name_].label }}
</b-input-group-text>
</b-input-group-prepend>
<input-item
v-bind="self[name_]" v-model.trim="form.fullname[name_]" :key="name_ + 'input'"
:name="self[name_].id" :aria-labelledby="name_ + '-label'"
:state="$v.form.fullname[name_].$invalid && $v.form.fullname.$anyDirty ? false : null"
/>
</template>
</b-input-group>
</template>
</form-field>
<hr> <hr>
<!-- USER EMAIL --> <!-- USER EMAIL -->
@ -137,7 +120,7 @@ export default {
], ],
form: { form: {
fullname: { firstname: '', lastname: '' }, fullname: '',
mail: { localPart: '', separator: '@', domain: '' }, mail: { localPart: '', separator: '@', domain: '' },
mailbox_quota: '', mailbox_quota: '',
mail_aliases: [], mail_aliases: [],
@ -157,18 +140,9 @@ export default {
fullname: { fullname: {
label: this.$i18n.t('user_fullname'), label: this.$i18n.t('user_fullname'),
id: 'fullname',
props: { props: {
firstname: { id: 'fullname',
id: 'firstname', placeholder: this.$i18n.t('placeholder.fullname')
label: this.$i18n.t('common.firstname'),
placeholder: this.$i18n.t('placeholder.firstname')
},
lastname: {
id: 'lastname',
label: this.$i18n.t('common.lastname'),
placeholder: this.$i18n.t('placeholder.lastname')
}
} }
}, },
@ -220,10 +194,7 @@ export default {
validations: { validations: {
form: { form: {
fullname: { fullname: { required, name },
firstname: { required, name },
lastname: { required, name }
},
mail: { mail: {
localPart: { required, email: emailLocalPart } localPart: { required, email: emailLocalPart }
}, },
@ -246,11 +217,7 @@ export default {
this.fields.mail.props.choices = this.domainsAsChoices this.fields.mail.props.choices = this.domainsAsChoices
this.fields.mail_aliases.props.choices = this.domainsAsChoices this.fields.mail_aliases.props.choices = this.domainsAsChoices
this.form.fullname = { this.form.fullname = user.fullname
// Copy value to avoid refering to the stored user data
firstname: user.firstname.valueOf(),
lastname: user.lastname.valueOf()
}
this.form.mail = adressToFormValue(user.mail) this.form.mail = adressToFormValue(user.mail)
if (user['mail-aliases']) { if (user['mail-aliases']) {
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail)) this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
@ -334,10 +301,6 @@ export default {
</script> </script>
<style lang="scss" scoped> <style lang="scss" scoped>
#lastname-label {
border-left: 0;
}
.mail-list { .mail-list {
display: flex; display: flex;
justify-items: stretch; justify-items: stretch;