mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
commit
027c2640fc
60 changed files with 2118 additions and 1269 deletions
|
@ -7,7 +7,12 @@
|
|||
:to="{ name: 'home' }" :disabled="waiting"
|
||||
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-nav class="ml-auto">
|
||||
|
@ -93,7 +98,8 @@ export default {
|
|||
'routerKey',
|
||||
'transitions',
|
||||
'transitionName',
|
||||
'waiting'
|
||||
'waiting',
|
||||
'theme'
|
||||
])
|
||||
},
|
||||
|
||||
|
@ -153,6 +159,8 @@ export default {
|
|||
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
|
||||
this.$store.commit('SET_SPINNER', 'spookycat')
|
||||
}
|
||||
|
||||
document.documentElement.setAttribute('dark-theme', localStorage.getItem('theme')) // updates the data-theme attribute
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
BIN
app/src/assets/logo_light.png
Normal file
BIN
app/src/assets/logo_light.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 17 KiB |
|
@ -1,7 +1,8 @@
|
|||
<template>
|
||||
<abstract-form
|
||||
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" />
|
||||
|
||||
|
@ -12,18 +13,24 @@
|
|||
<slot name="tab-before" />
|
||||
|
||||
<template v-for="section in panel.sections">
|
||||
<div v-if="isVisible(section.visible, section)" :key="section.id" class="mb-5">
|
||||
<component
|
||||
v-if="section.visible"
|
||||
:is="section.name ? 'section' : 'div'"
|
||||
:key="section.id"
|
||||
class="panel-section"
|
||||
>
|
||||
<b-card-title v-if="section.name" title-tag="h3">
|
||||
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
|
||||
</b-card-title>
|
||||
|
||||
<template v-for="(field, fname) in section.fields">
|
||||
<form-field
|
||||
v-if="isVisible(field.visible, field)" :key="fname"
|
||||
v-model="forms[panel.id][fname]" v-bind="field" :validation="validation[fname]"
|
||||
<component
|
||||
v-if="field.visible" :is="field.is" v-bind="field.props"
|
||||
v-model="forms[panel.id][fname]" :validation="validation[fname]" :key="fname"
|
||||
@action.stop="onAction(section.id, fname, section.fields)"
|
||||
/>
|
||||
</template>
|
||||
</div>
|
||||
</component>
|
||||
</template>
|
||||
|
||||
<slot name="tab-after" />
|
||||
|
@ -31,7 +38,7 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { configPanelsFieldIsVisible } from '@/helpers/yunohostArguments'
|
||||
import { filterObject } from '@/helpers/commons'
|
||||
|
||||
|
||||
export default {
|
||||
|
@ -55,8 +62,25 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
isVisible (expression, field) {
|
||||
return configPanelsFieldIsVisible(expression, field, this.forms)
|
||||
onApply () {
|
||||
const panelId = this.panel.id
|
||||
|
||||
this.$emit('submit', {
|
||||
id: panelId,
|
||||
form: this.forms[panelId]
|
||||
})
|
||||
},
|
||||
|
||||
onAction (sectionId, actionId, actionFields) {
|
||||
const panelId = this.panel.id
|
||||
const actionFieldsKeys = Object.keys(actionFields)
|
||||
|
||||
this.$emit('submit', {
|
||||
id: panelId,
|
||||
form: filterObject(this.forms[panelId], ([key]) => actionFieldsKeys.includes(key)),
|
||||
action: [panelId, sectionId, actionId].join('.'),
|
||||
name: actionId
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -65,6 +89,9 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
.card-title {
|
||||
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>
|
||||
|
|
|
@ -1,9 +1,13 @@
|
|||
<template>
|
||||
<routable-tabs
|
||||
:routes="routes_"
|
||||
v-bind="{ panels, forms, v: $v }"
|
||||
v-bind="{ panels, forms, v: $v, ...$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>
|
||||
</routable-tabs>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
@ -49,10 +53,3 @@ export default {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.card-title {
|
||||
margin-bottom: 1em;
|
||||
border-bottom: solid 1px #aaa;
|
||||
}
|
||||
</style>
|
||||
|
|
112
app/src/components/RecursiveListGroup.vue
Normal file
112
app/src/components/RecursiveListGroup.vue
Normal 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>
|
|
@ -13,7 +13,11 @@
|
|||
</b-card-header>
|
||||
|
||||
<!-- 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>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<component :is="titleTag" class="custom-header-title">
|
||||
<icon v-if="icon" :iname="icon" class="mr-2" />{{ title }}
|
||||
</component>
|
||||
<slot name="header-next" />
|
||||
</slot>
|
||||
|
||||
<div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }">
|
||||
|
|
60
app/src/components/globals/DescriptionRow.vue
Normal file
60
app/src/components/globals/DescriptionRow.vue
Normal 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>
|
63
app/src/components/globals/ExplainWhat.vue
Normal file
63
app/src/components/globals/ExplainWhat.vue
Normal 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>
|
|
@ -28,15 +28,18 @@
|
|||
<!-- Render description -->
|
||||
<template v-if="description || link">
|
||||
<div class="d-flex">
|
||||
<b-link v-if="link" :to="link" :href="link.href"
|
||||
class="ml-auto"
|
||||
<b-link
|
||||
v-if="link"
|
||||
:to="link" :href="link.href" class="ml-auto"
|
||||
>
|
||||
{{ link.text }}
|
||||
</b-link>
|
||||
</div>
|
||||
|
||||
<vue-showdown :markdown="description" flavor="github" v-if="description"
|
||||
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
|
||||
<vue-showdown
|
||||
v-if="description"
|
||||
:markdown="description" flavor="github"
|
||||
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
|
||||
/>
|
||||
</template>
|
||||
<!-- Slot available to overwrite the one above -->
|
||||
|
@ -76,8 +79,8 @@ export default {
|
|||
if ('label' in attrs) {
|
||||
const defaultAttrs = {
|
||||
'label-cols-md': 4,
|
||||
'label-cols-lg': 2,
|
||||
'label-class': 'font-weight-bold'
|
||||
'label-cols-lg': 3,
|
||||
'label-class': ['font-weight-bold', 'py-0']
|
||||
}
|
||||
if (!('label-cols' in attrs)) {
|
||||
for (const attr in defaultAttrs) {
|
||||
|
|
|
@ -1,12 +1,13 @@
|
|||
<template>
|
||||
<span :class="'icon fa fa-' + iname" aria-hidden="true" />
|
||||
<span :class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Icon',
|
||||
props: {
|
||||
iname: { type: String, required: true }
|
||||
iname: { type: String, required: true },
|
||||
variant: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -26,5 +27,21 @@ export default {
|
|||
&.fs-sm {
|
||||
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>
|
||||
|
|
65
app/src/components/globals/ReadOnlyField.vue
Normal file
65
app/src/components/globals/ReadOnlyField.vue
Normal file
|
@ -0,0 +1,65 @@
|
|||
<template>
|
||||
<b-row no-gutters class="description-row">
|
||||
<b-col v-bind="cols_" class="font-weight-bold">
|
||||
{{ label }}
|
||||
</b-col>
|
||||
|
||||
<!-- FIXME not sure about rendering html -->
|
||||
<b-col v-html="text" />
|
||||
</b-row>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'ReadOnlyField',
|
||||
|
||||
inheritAttrs: false,
|
||||
|
||||
props: {
|
||||
label: { type: String, required: true },
|
||||
component: { type: String, default: 'InputItem' },
|
||||
value: { type: null, default: null },
|
||||
cols: { type: Object, default: () => ({ md: 4, lg: 3 }) }
|
||||
},
|
||||
|
||||
computed: {
|
||||
cols_ () {
|
||||
return Object.assign({ md: 4, lg: 3 }, this.cols)
|
||||
},
|
||||
|
||||
text () {
|
||||
return this.parseValue(this.value)
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
parseValue (value) {
|
||||
const item = this.component
|
||||
if (item === 'FileItem') value = value.file ? value.file.name : null
|
||||
if (item === 'CheckboxItem') value = this.$i18n.t(value ? 'yes' : 'no')
|
||||
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
|
||||
if (Array.isArray(value)) {
|
||||
value = value.length ? value.join(this.$i18n.t('words.separator')) : null
|
||||
}
|
||||
if ([null, undefined, ''].includes(this.value)) value = this.$i18n.t('words.none')
|
||||
return value
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.description-row {
|
||||
@include media-breakpoint-up(md) {
|
||||
margin: 1rem 0;
|
||||
}
|
||||
@include media-breakpoint-down(sm) {
|
||||
flex-direction: column;
|
||||
&:not(:last-of-type) {
|
||||
margin-bottom: 0.5rem;
|
||||
padding-bottom: 0.5rem;
|
||||
border-bottom: $border-width solid $card-border-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
40
app/src/components/globals/formItems/ButtonItem.vue
Normal file
40
app/src/components/globals/formItems/ButtonItem.vue
Normal file
|
@ -0,0 +1,40 @@
|
|||
<template>
|
||||
<b-button
|
||||
:id="id"
|
||||
:variant="type"
|
||||
@click="$emit('action', $event)"
|
||||
:disabled="!enabled"
|
||||
class="d-block mb-3"
|
||||
>
|
||||
<icon :iname="icon_" class="mr-2" />
|
||||
<span v-html="label" />
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
||||
export default {
|
||||
name: 'ButtonItem',
|
||||
|
||||
props: {
|
||||
label: { type: String, default: null },
|
||||
id: { type: String, default: null },
|
||||
type: { type: String, default: 'success' },
|
||||
icon: { type: String, default: null },
|
||||
enabled: { type: [Boolean, String], default: true }
|
||||
},
|
||||
|
||||
computed: {
|
||||
icon_ () {
|
||||
const icons = {
|
||||
success: 'thumbs-up',
|
||||
info: 'info',
|
||||
warning: 'exclamation',
|
||||
danger: 'times'
|
||||
}
|
||||
|
||||
return this.icon || icons[this.type]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
16
app/src/components/globals/formItems/DisplayTextItem.vue
Normal file
16
app/src/components/globals/formItems/DisplayTextItem.vue
Normal file
|
@ -0,0 +1,16 @@
|
|||
<template>
|
||||
<div>
|
||||
<p v-text="label" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'DisplayTextItem',
|
||||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
label: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -1,19 +1,24 @@
|
|||
<template>
|
||||
<b-button-group class="w-100">
|
||||
<b-button @click="clearFiles" variant="danger" v-if="!this.required && this.value !== null && !this.value._removed">
|
||||
<b-button
|
||||
v-if="!this.required && this.value.file !== null"
|
||||
@click="clearFiles" variant="danger"
|
||||
>
|
||||
<span class="sr-only">{{ $t('delete') }}</span>
|
||||
<icon iname="trash" />
|
||||
</b-button>
|
||||
|
||||
<b-form-file
|
||||
v-model="file"
|
||||
:value="value.file"
|
||||
ref="input-file"
|
||||
:id="id"
|
||||
v-on="$listeners"
|
||||
:required="required"
|
||||
:placeholder="_placeholder"
|
||||
:accept="accept"
|
||||
:drop-placeholder="dropPlaceholder"
|
||||
:state="state"
|
||||
:browse-text="$t('words.browse')"
|
||||
@input="onInput"
|
||||
@blur="$parent.$emit('touch', name)"
|
||||
@focusout.native="$parent.$emit('touch', name)"
|
||||
/>
|
||||
|
@ -21,18 +26,14 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { getFileContent } from '@/helpers/commons'
|
||||
|
||||
export default {
|
||||
name: 'FileItem',
|
||||
|
||||
data () {
|
||||
return {
|
||||
file: this.value
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
value: { type: [File, null], default: null },
|
||||
value: { type: Object, default: () => ({ file: null }) },
|
||||
placeholder: { type: String, default: 'Choose a file or drop it here...' },
|
||||
dropPlaceholder: { type: String, default: null },
|
||||
accept: { type: String, default: null },
|
||||
|
@ -43,22 +44,35 @@ export default {
|
|||
|
||||
computed: {
|
||||
_placeholder: function () {
|
||||
return (this.value === null) ? this.placeholder : this.value.name
|
||||
return this.value.file === null ? this.placeholder : this.value.file.name
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
clearFiles () {
|
||||
const f = new File([''], this.placeholder)
|
||||
f._removed = true
|
||||
if (this.value && this.value.currentfile) {
|
||||
this.$refs['input-file'].reset()
|
||||
this.$emit('input', f)
|
||||
} else {
|
||||
this.$refs['input-file'].setFiles([f])
|
||||
this.file = f
|
||||
this.$emit('input', f)
|
||||
onInput (file) {
|
||||
const value = {
|
||||
file,
|
||||
content: '',
|
||||
current: false,
|
||||
removed: false
|
||||
}
|
||||
// Update the value with the new File and an empty content for now
|
||||
this.$emit('input', value)
|
||||
|
||||
// Asynchronously load the File content and update the value again
|
||||
getFileContent(file).then(content => {
|
||||
this.$emit('input', { ...value, content })
|
||||
})
|
||||
},
|
||||
|
||||
clearFiles () {
|
||||
this.$refs['input-file'].reset()
|
||||
this.$emit('input', {
|
||||
file: null,
|
||||
content: '',
|
||||
current: false,
|
||||
removed: true
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -2,7 +2,6 @@
|
|||
<b-input
|
||||
:value="value"
|
||||
:id="id"
|
||||
v-on="$listeners"
|
||||
:placeholder="placeholder"
|
||||
:type="type"
|
||||
:state="state"
|
||||
|
@ -12,6 +11,7 @@
|
|||
:step="step"
|
||||
:trim="trim"
|
||||
:autocomplete="autocomplete_"
|
||||
v-on="$listeners"
|
||||
@blur="$parent.$emit('touch', name)"
|
||||
/>
|
||||
</template>
|
||||
|
@ -21,11 +21,6 @@
|
|||
export default {
|
||||
name: 'InputItem',
|
||||
|
||||
data () {
|
||||
return {
|
||||
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
|
||||
}
|
||||
},
|
||||
props: {
|
||||
value: { type: [String, Number], default: null },
|
||||
id: { type: String, default: null },
|
||||
|
@ -40,6 +35,12 @@ export default {
|
|||
autocomplete: { type: String, default: null },
|
||||
pattern: { type: Object, default: null },
|
||||
name: { type: String, default: null }
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
|
@ -12,4 +12,3 @@ export default {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
<template>
|
||||
<b-alert class="d-flex" :variant="type" show>
|
||||
<icon :iname="icon_" class="mr-1 mt-1" />
|
||||
<vue-showdown :markdown="label" flavor="github"
|
||||
tag="span" class="markdown"
|
||||
<b-alert class="d-flex flex-column flex-md-row align-items-center" :variant="type" show>
|
||||
<icon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" />
|
||||
|
||||
<vue-showdown
|
||||
:markdown="label" flavor="github"
|
||||
tag="span" class="markdown"
|
||||
/>
|
||||
</b-alert>
|
||||
</template>
|
||||
|
@ -11,29 +13,23 @@
|
|||
export default {
|
||||
name: 'ReadOnlyAlertItem',
|
||||
|
||||
data () {
|
||||
const icons = {
|
||||
success: 'thumbs-up',
|
||||
info: 'info-circle',
|
||||
warning: 'warning',
|
||||
danger: 'times'
|
||||
}
|
||||
return {
|
||||
icon_: (this.icon) ? this.icon : icons[this.type]
|
||||
}
|
||||
},
|
||||
|
||||
props: {
|
||||
id: { type: String, default: null },
|
||||
label: { type: String, default: null },
|
||||
type: { type: String, default: null },
|
||||
icon: { type: String, default: null }
|
||||
},
|
||||
|
||||
computed: {
|
||||
icon_ () {
|
||||
const icons = {
|
||||
success: 'thumbs-up',
|
||||
info: 'info',
|
||||
warning: 'exclamation',
|
||||
danger: 'times'
|
||||
}
|
||||
return this.icon || icons[this.type]
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss">
|
||||
.alert p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -12,7 +12,6 @@
|
|||
@remove="onRemoveTag({ option: tag, removeTag })"
|
||||
:title="tag"
|
||||
:disabled="disabled || disabledItems.includes(tag)"
|
||||
variant="light"
|
||||
class="border border-dark mb-2"
|
||||
>
|
||||
<icon v-if="tagIcon" :iname="tagIcon" /> {{ tag }}
|
||||
|
@ -151,7 +150,7 @@ export default {
|
|||
padding-top: .5rem;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background-color: white;
|
||||
background-color: $white;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -1,6 +1,6 @@
|
|||
<template>
|
||||
<b-form-textarea
|
||||
v-model="value"
|
||||
:value="value"
|
||||
:id="id"
|
||||
:placeholder="placeholder"
|
||||
:required="required"
|
||||
|
|
|
@ -63,6 +63,19 @@ export function flattenObjectLiteral (obj, flattened = {}) {
|
|||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an new Object filtered with passed filter function.
|
||||
* Each entry `[key, value]` will be forwarded to the `filter` function.
|
||||
*
|
||||
* @param {Object} obj - object to filter.
|
||||
* @param {Function} filter - the filter function to call for each entry.
|
||||
* @return {Object}
|
||||
*/
|
||||
export function filterObject (obj, filter) {
|
||||
return Object.fromEntries(Object.entries(obj).filter((...args) => filter(...args)))
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns an new array containing items that are in first array but not in the other.
|
||||
*
|
||||
|
@ -100,3 +113,26 @@ export function escapeHtml (unsafe) {
|
|||
export function randint (min, max) {
|
||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns a File content.
|
||||
*
|
||||
* @param {File} file
|
||||
* @param {Object} [extraParams] - Optionnal params
|
||||
* @param {Boolean} [extraParams.base64] - returns a base64 representation of the file.
|
||||
* @return {Promise<String>}
|
||||
*/
|
||||
export function getFileContent (file, { base64 = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const reader = new FileReader()
|
||||
reader.onerror = reject
|
||||
reader.onload = () => resolve(reader.result)
|
||||
|
||||
if (base64) {
|
||||
reader.readAsDataURL(file)
|
||||
} else {
|
||||
reader.readAsText(file)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
209
app/src/helpers/data/tree.js
Normal file
209
app/src/helpers/data/tree.js
Normal 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)
|
||||
}
|
|
@ -2,9 +2,22 @@ import i18n from '@/i18n'
|
|||
import store from '@/store'
|
||||
import evaluate from 'simple-evaluate'
|
||||
import * as validators from '@/helpers/validators'
|
||||
import { isObjectLiteral, isEmptyValue, flattenObjectLiteral } from '@/helpers/commons'
|
||||
import {
|
||||
isObjectLiteral,
|
||||
isEmptyValue,
|
||||
flattenObjectLiteral,
|
||||
getFileContent
|
||||
} from '@/helpers/commons'
|
||||
|
||||
|
||||
const NO_VALUE_FIELDS = [
|
||||
'ReadOnlyField',
|
||||
'ReadOnlyAlertItem',
|
||||
'MarkdownItem',
|
||||
'DisplayTextItem',
|
||||
'ButtonItem'
|
||||
]
|
||||
|
||||
/**
|
||||
* 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.
|
||||
|
@ -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
|
||||
* will be automaticly transformed into a component on screen.
|
||||
|
@ -62,22 +118,21 @@ export function formatYunoHostArgument (arg) {
|
|||
const error = { message: null }
|
||||
arg.ask = formatI18nField(arg.ask)
|
||||
const field = {
|
||||
component: undefined,
|
||||
label: arg.ask,
|
||||
props: {}
|
||||
is: arg.readonly ? 'ReadOnlyField' : 'FormField',
|
||||
visible: [undefined, true, '"true"'].includes(arg.visible),
|
||||
props: {
|
||||
label: arg.ask,
|
||||
component: undefined,
|
||||
props: {}
|
||||
}
|
||||
}
|
||||
|
||||
const defaultProps = ['id:name', 'placeholder:example']
|
||||
const components = [
|
||||
{
|
||||
types: [undefined, 'string', 'path'],
|
||||
types: ['string', 'path'],
|
||||
name: 'InputItem',
|
||||
props: defaultProps.concat(['autocomplete', 'trim', 'choices']),
|
||||
callback: function () {
|
||||
if (arg.choices && Object.keys(arg.choices).length) {
|
||||
arg.type = 'select'
|
||||
this.name = 'SelectItem'
|
||||
}
|
||||
}
|
||||
props: defaultProps.concat(['autocomplete', 'trim', 'choices'])
|
||||
},
|
||||
{
|
||||
types: ['email', 'url', 'date', 'time', 'color'],
|
||||
|
@ -90,7 +145,7 @@ export function formatYunoHostArgument (arg) {
|
|||
props: defaultProps.concat(['type', 'autocomplete', 'trim']),
|
||||
callback: function () {
|
||||
if (!arg.help) {
|
||||
arg.help = 'good_practices_about_admin_password'
|
||||
arg.help = i18n.t('good_practices_about_admin_password')
|
||||
}
|
||||
arg.example = '••••••••••••'
|
||||
validation.passwordLenght = validators.minLength(8)
|
||||
|
@ -111,13 +166,13 @@ export function formatYunoHostArgument (arg) {
|
|||
}
|
||||
},
|
||||
{
|
||||
types: ['select', 'user', 'domain', 'app'],
|
||||
types: ['select', 'user', 'domain', 'app', 'group'],
|
||||
name: 'SelectItem',
|
||||
props: ['id:name', 'choices'],
|
||||
callback: function () {
|
||||
if ((arg.type !== 'select')) {
|
||||
field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
|
||||
}
|
||||
if (arg.type !== 'select') {
|
||||
field.props.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -125,9 +180,12 @@ export function formatYunoHostArgument (arg) {
|
|||
name: 'FileItem',
|
||||
props: defaultProps.concat(['accept']),
|
||||
callback: function () {
|
||||
if (value) {
|
||||
value = new File([''], value)
|
||||
value.currentfile = true
|
||||
value = {
|
||||
// in case of already defined file, we receive only the file path (not the actual file)
|
||||
file: value ? new File([''], value) : null,
|
||||
content: '',
|
||||
current: !!value,
|
||||
removed: false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
@ -141,11 +199,13 @@ export function formatYunoHostArgument (arg) {
|
|||
name: 'TagsItem',
|
||||
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
|
||||
callback: function () {
|
||||
if (arg.choices) {
|
||||
this.name = 'TagsSelectizeItem'
|
||||
field.props.auto = true
|
||||
field.props.itemsName = ''
|
||||
field.props.label = arg.placeholder
|
||||
if (arg.choices && arg.choices.length) {
|
||||
this.name = 'TagsSelectizeItem'
|
||||
Object.assign(field.props.props, {
|
||||
auto: true,
|
||||
itemsName: '',
|
||||
label: arg.placeholder
|
||||
})
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
value = value.split(',')
|
||||
|
@ -170,53 +230,67 @@ export function formatYunoHostArgument (arg) {
|
|||
types: ['alert'],
|
||||
name: 'ReadOnlyAlertItem',
|
||||
props: ['type:style', 'label:ask', 'icon'],
|
||||
readonly: true
|
||||
renderSelf: true
|
||||
},
|
||||
{
|
||||
types: ['markdown', 'display_text'],
|
||||
types: ['markdown'],
|
||||
name: 'MarkdownItem',
|
||||
props: ['label:ask'],
|
||||
readonly: true
|
||||
renderSelf: true
|
||||
},
|
||||
{
|
||||
types: ['display_text'],
|
||||
name: 'DisplayTextItem',
|
||||
props: ['label:ask'],
|
||||
renderSelf: true
|
||||
},
|
||||
{
|
||||
types: ['button'],
|
||||
name: 'ButtonItem',
|
||||
props: ['type:style', 'label:ask', 'icon', 'enabled'],
|
||||
renderSelf: true
|
||||
}
|
||||
]
|
||||
|
||||
// Default type management if no one is filled
|
||||
if (arg.type === undefined) {
|
||||
arg.type = (arg.choices === undefined) ? 'string' : 'select'
|
||||
arg.type = arg.choices && arg.choices.length ? 'select' : 'string'
|
||||
}
|
||||
|
||||
// Search the component bind to the type
|
||||
const component = components.find(element => element.types.includes(arg.type))
|
||||
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
|
||||
|
||||
// Callback use for specific behaviour
|
||||
if (component.callback) component.callback()
|
||||
field.component = component.name
|
||||
field.props.component = component.name
|
||||
// Affect properties to the field Item
|
||||
for (let prop of component.props) {
|
||||
prop = prop.split(':')
|
||||
const propName = prop[0]
|
||||
const argName = prop.slice(-1)[0]
|
||||
if (argName in arg) {
|
||||
field.props[propName] = arg[argName]
|
||||
field.props.props[propName] = arg[argName]
|
||||
}
|
||||
}
|
||||
// We don't want to display a label html item as this kind or field contains
|
||||
// already the text to display
|
||||
if (component.readonly) delete field.label
|
||||
|
||||
// Required (no need for checkbox its value can't be null)
|
||||
else if (field.component !== 'CheckboxItem' && arg.optional !== true) {
|
||||
if (!component.renderSelf && arg.type !== 'boolean' && arg.optional !== true) {
|
||||
validation.required = validators.required
|
||||
}
|
||||
if (arg.pattern && arg.type !== 'tags') {
|
||||
validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp))
|
||||
}
|
||||
validation.remote = validators.helpers.withParams(error, (v) => {
|
||||
const result = !error.message
|
||||
error.message = null
|
||||
return result
|
||||
})
|
||||
|
||||
if (!component.renderSelf && !arg.readonly) {
|
||||
// Bind a validation with what the server may respond
|
||||
validation.remote = validators.helpers.withParams(error, (v) => {
|
||||
const result = !error.message
|
||||
error.message = null
|
||||
return result
|
||||
})
|
||||
}
|
||||
|
||||
// field.props['title'] = field.pattern.error
|
||||
// Default value if still `null`
|
||||
if (value === null && arg.current_value) {
|
||||
value = arg.current_value
|
||||
|
@ -227,18 +301,17 @@ export function formatYunoHostArgument (arg) {
|
|||
|
||||
// Help message
|
||||
if (arg.help) {
|
||||
field.description = formatI18nField(arg.help)
|
||||
field.props.description = formatI18nField(arg.help)
|
||||
}
|
||||
|
||||
// Help message
|
||||
if (arg.helpLink) {
|
||||
field.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
|
||||
field.props.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
|
||||
}
|
||||
|
||||
if (arg.visible) {
|
||||
field.visible = arg.visible
|
||||
// Temporary value to wait visible expression to be evaluated
|
||||
field.isVisible = true
|
||||
if (component.renderSelf) {
|
||||
field.is = field.props.component
|
||||
field.props = field.props.props
|
||||
}
|
||||
|
||||
return {
|
||||
|
@ -256,30 +329,29 @@ export function formatYunoHostArgument (arg) {
|
|||
* as v-model values, fields that can be passed to a FormField component and validations.
|
||||
*
|
||||
* @param {Array} args - a yunohost arg array written by a packager.
|
||||
* @param {String} name - (temp) an app name to build a label field in case of manifest install args
|
||||
* @param {Object|null} forms - nested form used as the expression evualuations context.
|
||||
* @return {Object} an object containing all parsed values to be used in vue views.
|
||||
*/
|
||||
export function formatYunoHostArguments (args, name = null) {
|
||||
export function formatYunoHostArguments (args, forms) {
|
||||
const form = {}
|
||||
const fields = {}
|
||||
const validations = {}
|
||||
const errors = {}
|
||||
|
||||
// FIXME yunohost should add the label field by default
|
||||
if (name) {
|
||||
args.unshift({
|
||||
ask: i18n.t('label_for_manifestname', { name }),
|
||||
default: name,
|
||||
name: 'label'
|
||||
})
|
||||
}
|
||||
|
||||
for (const arg of args) {
|
||||
const { value, field, validation, error } = formatYunoHostArgument(arg)
|
||||
fields[arg.name] = field
|
||||
form[arg.name] = value
|
||||
if (validation) validations[arg.name] = validation
|
||||
errors[arg.name] = error
|
||||
|
||||
if ('visible' in arg && ![false, '"false"'].includes(arg.visible)) {
|
||||
addEvaluationGetter('visible', field, arg.visible, forms)
|
||||
}
|
||||
|
||||
if ('enabled' in arg) {
|
||||
addEvaluationGetter('enabled', field.props, arg.enabled, forms)
|
||||
}
|
||||
}
|
||||
|
||||
return { form, fields, validations, errors }
|
||||
|
@ -295,7 +367,7 @@ export function formatYunoHostConfigPanels (data) {
|
|||
}
|
||||
|
||||
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.validations[panelId] = {}
|
||||
result.errors[panelId] = {}
|
||||
|
@ -303,17 +375,34 @@ export function formatYunoHostConfigPanels (data) {
|
|||
if (name) panel.name = formatI18nField(name)
|
||||
if (help) panel.help = formatI18nField(help)
|
||||
|
||||
for (const { id: sectionId, name, help, visible, options } of sections) {
|
||||
const section = { id: sectionId, visible, isVisible: false }
|
||||
if (help) section.help = formatI18nField(help)
|
||||
if (name) section.name = formatI18nField(name)
|
||||
const { form, fields, validations, errors } = formatYunoHostArguments(options)
|
||||
for (const _section of sections) {
|
||||
const section = {
|
||||
id: _section.id,
|
||||
isActionSection: _section.is_action_section,
|
||||
visible: [undefined, true, '"true"'].includes(_section.visible)
|
||||
}
|
||||
if (_section.help) section.help = formatI18nField(_section.help)
|
||||
if (_section.name) section.name = formatI18nField(_section.name)
|
||||
if (_section.visible && ![false, '"false"'].includes(_section.visible)) {
|
||||
addEvaluationGetter('visible', section, _section.visible, result.forms)
|
||||
}
|
||||
|
||||
const {
|
||||
form,
|
||||
fields,
|
||||
validations,
|
||||
errors
|
||||
} = formatYunoHostArguments(_section.options, result.forms)
|
||||
// Merge all sections forms to the panel to get a unique form
|
||||
Object.assign(result.forms[panelId], form)
|
||||
Object.assign(result.validations[panelId], validations)
|
||||
Object.assign(result.errors[panelId], errors)
|
||||
section.fields = fields
|
||||
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)
|
||||
|
@ -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.
|
||||
* Convert Boolean to (1|0) and concatenate adresses.
|
||||
* Parse a front-end value to its API equivalent. This function returns a Promise or an
|
||||
* Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those
|
||||
* objects must be merged to define the final sent form.
|
||||
*
|
||||
* Convert Boolean to '1' (true) or '0' (false),
|
||||
* Concatenate two parts adresses (subdomain or email for example) into a single string,
|
||||
* Convert File to its Base64 representation or set its value to '' to ask for a removal.
|
||||
*
|
||||
* @param {*} value
|
||||
* @return {*}
|
||||
*/
|
||||
export function formatFormDataValue (value) {
|
||||
if (typeof value === 'boolean') {
|
||||
return value ? 1 : 0
|
||||
} else if (isObjectLiteral(value) && 'separator' in value) {
|
||||
return Object.values(value).join('')
|
||||
export function formatFormDataValue (value, key = null) {
|
||||
if (Array.isArray(value)) {
|
||||
return Promise.all(
|
||||
value.map(value_ => formatFormDataValue(value_))
|
||||
).then(resolvedValues => ({ [key]: resolvedValues }))
|
||||
}
|
||||
return value
|
||||
|
||||
let result = value
|
||||
if (typeof value === 'boolean') result = value ? 1 : 0
|
||||
if (isObjectLiteral(value) && 'file' in value) {
|
||||
// File has to be deleted
|
||||
if (value.removed) result = ''
|
||||
// File has not changed (will not be sent)
|
||||
else if (value.current || value.file === null) result = null
|
||||
else {
|
||||
return getFileContent(value.file, { base64: true }).then(content => {
|
||||
return {
|
||||
[key]: content.replace(/data:[^;]*;base64,/, ''),
|
||||
[key + '[name]']: value.file.name
|
||||
}
|
||||
})
|
||||
}
|
||||
} else if (isObjectLiteral(value) && 'separator' in value) {
|
||||
result = Object.values(value).join('')
|
||||
}
|
||||
|
||||
// Returns a resolved Promise for non async values
|
||||
return Promise.resolve(key ? { [key]: result } : result)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Convinient helper to properly parse a front-end form to its API equivalent.
|
||||
* This parse each values asynchronously, allow to inject keys into the final form and
|
||||
* make sure every async values resolves before resolving itself.
|
||||
*
|
||||
* @param {Object} formData
|
||||
* @return {Object}
|
||||
*/
|
||||
function formatFormDataValues (formData) {
|
||||
const promisedValues = Object.entries(formData).map(([key, value]) => {
|
||||
return formatFormDataValue(value, key)
|
||||
})
|
||||
|
||||
return Promise.all(promisedValues).then(resolvedValues => {
|
||||
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
|
@ -412,38 +486,28 @@ export function formatFormDataValue (value) {
|
|||
*/
|
||||
export async function formatFormData (
|
||||
formData,
|
||||
{ extract = null, flatten = false, removeEmpty = true, removeNull = false, multipart = true } = {}
|
||||
{ extract = null, flatten = false, removeEmpty = true, removeNull = false } = {}
|
||||
) {
|
||||
const output = {
|
||||
data: {},
|
||||
extracted: {}
|
||||
}
|
||||
const promises = []
|
||||
for (const key in formData) {
|
||||
const type = extract && extract.includes(key) ? 'extracted' : 'data'
|
||||
const value = Array.isArray(formData[key])
|
||||
? formData[key].map(item => formatFormDataValue(item))
|
||||
: formatFormDataValue(formData[key])
|
||||
|
||||
const values = await formatFormDataValues(formData)
|
||||
for (const key in values) {
|
||||
const type = extract && extract.includes(key) ? 'extracted' : 'data'
|
||||
const value = values[key]
|
||||
if (removeEmpty && isEmptyValue(value)) {
|
||||
continue
|
||||
} else if (removeNull && (value === null || value === undefined)) {
|
||||
} else if (removeNull && [null, undefined].includes(value)) {
|
||||
continue
|
||||
} else if (value instanceof File && !multipart) {
|
||||
if (value.currentfile) {
|
||||
continue
|
||||
} else if (value._removed) {
|
||||
output[type][key] = ''
|
||||
continue
|
||||
}
|
||||
promises.push(pFileReader(value, output[type], key))
|
||||
} else if (flatten && isObjectLiteral(value)) {
|
||||
flattenObjectLiteral(value, output[type])
|
||||
} else {
|
||||
output[type][key] = value
|
||||
}
|
||||
}
|
||||
if (promises.length) await Promise.all(promises)
|
||||
|
||||
const { data, extracted } = output
|
||||
return extract ? { data, ...extracted } : data
|
||||
}
|
||||
|
|
|
@ -80,7 +80,7 @@ function initDefaultLocales () {
|
|||
|
||||
store.dispatch('UPDATE_LOCALE', locale)
|
||||
store.dispatch('UPDATE_FALLBACKLOCALE', fallbackLocale || 'en')
|
||||
loadLocaleMessages('en')
|
||||
return loadLocaleMessages('en')
|
||||
}
|
||||
|
||||
export {
|
||||
|
|
|
@ -54,8 +54,6 @@
|
|||
"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_waiting": "Waiting for the server's response...",
|
||||
"app_actions": "Actions",
|
||||
"app_actions_label": "Perform actions",
|
||||
"app_choose_category": "Choose a category",
|
||||
"app_config_panel": "Config panel",
|
||||
"app_config_panel_label": "Configure this app",
|
||||
|
@ -69,18 +67,14 @@
|
|||
"app_install_parameters": "Install settings",
|
||||
"app_manage_label_and_tiles": "Manage label and tiles",
|
||||
"app_make_default": "Make default",
|
||||
"app_no_actions": "This application doesn't have any actions",
|
||||
"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_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_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_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",
|
||||
"archive_empty": "Empty archive",
|
||||
"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_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_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_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?",
|
||||
|
@ -134,6 +129,7 @@
|
|||
"delete": "Delete",
|
||||
"description": "Description",
|
||||
"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.",
|
||||
"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.",
|
||||
|
@ -143,6 +139,14 @@
|
|||
"disabled": "Disabled",
|
||||
"dns": "DNS",
|
||||
"domain": {
|
||||
"cert": {
|
||||
"types": {
|
||||
"selfsigned": "Self-signed",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"other": "Other/Unknown"
|
||||
},
|
||||
"valid_for": "valid for {days}"
|
||||
},
|
||||
"config": {
|
||||
"edit": "Edit 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_ok": "Automatic configuration seems to be OK!",
|
||||
"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",
|
||||
"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",
|
||||
|
@ -159,7 +170,20 @@
|
|||
"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_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_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_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_visit": "Visit",
|
||||
"domain_visit_url": "Visit {url}",
|
||||
"domains": "Domains",
|
||||
"download": "Download",
|
||||
"enable": "Enable",
|
||||
|
@ -227,14 +249,16 @@
|
|||
"group_name": "Group name",
|
||||
"group_all_users": "All users",
|
||||
"group_visitors": "Visitors",
|
||||
"group_admins": "Admins",
|
||||
"group_format_name_help": "You can use alpha-numeric chars and underscore",
|
||||
"group_add_member": "Add a user",
|
||||
"group_add_permission": "Add a permission",
|
||||
"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_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_specific_permissions": "User specific permissions",
|
||||
"group_specific_permissions": "Individual user permissions",
|
||||
"groups_and_permissions": "Groups and permissions",
|
||||
"groups_and_permissions_manage": "Manage groups and permissions",
|
||||
"permissions": "Permissions",
|
||||
|
@ -288,7 +312,8 @@
|
|||
"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.",
|
||||
"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:",
|
||||
"license": "License",
|
||||
"local_archives": "Local archives",
|
||||
|
@ -300,6 +325,7 @@
|
|||
"manage_apps": "Manage apps",
|
||||
"manage_domains": "Manage domains",
|
||||
"manage_users": "Manage users",
|
||||
"manage_groups": "Manage groups",
|
||||
"migrations": "Migrations",
|
||||
"migrations_pending": "Pending migrations",
|
||||
"migrations_done": "Previous migrations",
|
||||
|
@ -327,9 +353,10 @@
|
|||
"path": "Path",
|
||||
"perform": "Perform",
|
||||
"placeholder": {
|
||||
"username": "johndoe",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"username": "samsmith",
|
||||
"fullname": "Sam Smith",
|
||||
"firstname": "Sam",
|
||||
"lastname": "Smith",
|
||||
"groupname": "My group name",
|
||||
"domain": "my-domain.com",
|
||||
"file": "Browse a file or drag and drop it"
|
||||
|
@ -359,15 +386,17 @@
|
|||
"ports": "Ports",
|
||||
"postinstall": {
|
||||
"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_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_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_password": "Set administration password",
|
||||
"previous": "Previous",
|
||||
"protocol": "Protocol",
|
||||
"readme": "Readme",
|
||||
|
@ -376,15 +405,14 @@
|
|||
"restart": "Restart",
|
||||
"retry": "Retry",
|
||||
"human_routes": {
|
||||
"adminpw": "Change admin password",
|
||||
"apps": {
|
||||
"action_config": "Run action '{action}' of app '{name}' configuration",
|
||||
"change_label": "Change label of '{prevName}' for '{nextName}'",
|
||||
"change_url": "Change access URL of '{name}'",
|
||||
"install": "Install app '{name}'",
|
||||
"set_default": "Redirect '{domain}' domain root to '{name}'",
|
||||
"perform_action": "Perform action '{action}' of app '{name}'",
|
||||
"uninstall": "Uninstall app '{name}'",
|
||||
"update_config": "Update app '{name}' configuration"
|
||||
"update_config": "Update panel '{id}' of app '{name}' configuration"
|
||||
},
|
||||
"backups": {
|
||||
"create": "Create a backup",
|
||||
|
@ -406,13 +434,11 @@
|
|||
"domains": {
|
||||
"add": "Add domain '{name}'",
|
||||
"delete": "Delete domain '{name}'",
|
||||
"install_LE": "Install certificate for '{name}'",
|
||||
"manual_renew_LE": "Renew certificate for '{name}'",
|
||||
"cert_install": "Install certificate for '{name}'",
|
||||
"cert_renew": "Renew certificate for '{name}'",
|
||||
"push_dns_changes": "Push DNS records to registrar for '{name}'",
|
||||
"regen_selfsigned": "Renew self-signed certificate for '{name}'",
|
||||
"revert_to_selfsigned": "Revert to self-signed certificate for '{name}'",
|
||||
"set_default": "Set '{name}' as default domain",
|
||||
"update_config": "Update '{name}' configuration"
|
||||
"update_config": "Update panel '{id}' of domain '{name}' configuration"
|
||||
},
|
||||
"firewall": {
|
||||
"ports": "{action} port {port} ({protocol}, {connection})",
|
||||
|
@ -452,6 +478,9 @@
|
|||
"create": "Create user '{name}'",
|
||||
"delete": "Delete user '{name}'",
|
||||
"update": "Update user '{name}'"
|
||||
},
|
||||
"settings": {
|
||||
"update": "Update '{panel}' global settings"
|
||||
}
|
||||
},
|
||||
"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>",
|
||||
"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_adminpw": "Change administration password",
|
||||
"tools_adminpw_current": "Current password",
|
||||
"tools_adminpw_current_placeholder": "Enter your current password",
|
||||
"tools_reboot": "Reboot your server",
|
||||
"tools_reboot_btn": "Reboot",
|
||||
"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.",
|
||||
"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.",
|
||||
"transitions": "Page transition animations"
|
||||
"transitions": "Page transition animations",
|
||||
"theme": "Toggle dark mode"
|
||||
},
|
||||
"tools_yunohost_settings": "YunoHost settings",
|
||||
"tools_webadmin_settings": "Web-admin settings",
|
||||
"traceback": "Traceback",
|
||||
"udp": "UDP",
|
||||
|
@ -508,8 +536,6 @@
|
|||
"unignore": "Unignore",
|
||||
"uninstall": "Uninstall",
|
||||
"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_disabled": "UPnP is disabled.",
|
||||
"upnp_enabled": "UPnP is enabled.",
|
||||
|
@ -544,37 +570,18 @@
|
|||
"words": {
|
||||
"browse": "Browse",
|
||||
"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",
|
||||
"yunohost_admin": "YunoHost Admin",
|
||||
"certificate_alert_not_valid": "CRITICAL: Current certificate is not valid! HTTPS won't work at all!",
|
||||
"certificate_alert_selfsigned": "WARNING: Current certificate is self-signed. Browsers will display a spooky warning to new visitors!",
|
||||
"certificate_alert_letsencrypt_about_to_expire": "Current certificate is about to expire. It should soon be renewed automatically.",
|
||||
"certificate_alert_about_to_expire": "WARNING: Current certificate is about to expire! It will NOT be renewed automatically!",
|
||||
"certificate_alert_good": "Okay, current certificate looks good!",
|
||||
"certificate_alert_great": "Great! You're using a valid Let's Encrypt certificate!",
|
||||
"certificate_alert_unknown": "Unknown status",
|
||||
"certificate_manage": "Manage SSL certificate",
|
||||
"ssl_certificate": "SSL certificate",
|
||||
"confirm_cert_install_LE": "Are you sure you want to install a Let's Encrypt certificate for this domain?",
|
||||
"confirm_cert_regen_selfsigned": "Are you sure you want to regenerate a self-signed certificate for this domain?",
|
||||
"confirm_cert_manual_renew_LE": "Are you sure you want to manually renew the Let's Encrypt certificate for this domain now?",
|
||||
"confirm_cert_revert_to_selfsigned": "Are you sure you want to revert this domain to a self-signed certificate?",
|
||||
"certificate": "Certificate",
|
||||
"certificate_status": "Certificate status",
|
||||
"certificate_authority": "Certification authority",
|
||||
"validity": "Validity",
|
||||
"domain_is_eligible_for_ACME": "This domain seems correctly configured to install a Let's Encrypt certificate!",
|
||||
"domain_not_eligible_for_ACME": "This domain doesn't seem ready for a Let's Encrypt certificate. Please check your DNS configuration and HTTP server reachability. The 'DNS records' and 'Web' section in <a href='#/diagnosis'>the diagnosis page</a> can help you understand what is misconfigured.",
|
||||
"install_letsencrypt_cert": "Install a Let's Encrypt certificate",
|
||||
"manually_renew_letsencrypt_message": "Certificate will be automatically renewed during the last 15 days of validity. You can manually renew it if you want to. (Not recommended).",
|
||||
"manually_renew_letsencrypt": "Manually renew now",
|
||||
"regenerate_selfsigned_cert_message": "If you want, you can regenerate the self-signed certificate.",
|
||||
"regenerate_selfsigned_cert": "Regenerate self-signed certificate",
|
||||
"revert_to_selfsigned_cert_message": "If you really want to, you can reinstall a self-signed certificate. (Not recommended)",
|
||||
"revert_to_selfsigned_cert": "Revert to a self-signed certificate",
|
||||
"purge_user_data_checkbox": "Purge {name}'s data? (This will remove the content of its home and mail directories.)",
|
||||
"purge_user_data_warning": "Purging user's data is not reversible. Be sure you know what you're doing!"
|
||||
}
|
||||
|
|
|
@ -14,7 +14,7 @@
|
|||
"app_state_working": "Fonctionnelle",
|
||||
"applications": "Applications",
|
||||
"archive_empty": "L’archive est vide",
|
||||
"backup": "Sauvegarde",
|
||||
"backup": "Sauvegardes",
|
||||
"backup_action": "Sauvegarder",
|
||||
"backup_content": "Contenu de la sauvegarde",
|
||||
"backup_create": "Créer une sauvegarde",
|
||||
|
@ -267,7 +267,7 @@
|
|||
"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_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_manage": "Gérer les groupes et les autorisations",
|
||||
"permissions": "Permissions",
|
||||
|
@ -335,8 +335,9 @@
|
|||
"domain": "mon-domaine.fr",
|
||||
"groupname": "Le nom de mon groupe",
|
||||
"lastname": "Dupont",
|
||||
"firstname": "Jean",
|
||||
"username": "jeandupont",
|
||||
"firstname": "Camille",
|
||||
"fullname": "Camille Dupont",
|
||||
"username": "camilledupont",
|
||||
"file": "Parcourir un fichier ou le faire glisser et déposer"
|
||||
},
|
||||
"perform": "Exécuter",
|
||||
|
|
|
@ -55,13 +55,13 @@ requireComponent.keys().forEach((fileName) => {
|
|||
registerGlobalErrorHandlers()
|
||||
|
||||
// Load default locales translations files and setup store data
|
||||
initDefaultLocales()
|
||||
initDefaultLocales().then(() => {
|
||||
const app = new Vue({
|
||||
store,
|
||||
router,
|
||||
i18n,
|
||||
render: h => h(App)
|
||||
})
|
||||
|
||||
const app = new Vue({
|
||||
store,
|
||||
router,
|
||||
i18n,
|
||||
render: h => h(App)
|
||||
app.$mount('#app')
|
||||
})
|
||||
|
||||
app.$mount('#app')
|
||||
|
|
|
@ -141,54 +141,23 @@ const routes = [
|
|||
}
|
||||
},
|
||||
{
|
||||
name: 'domain-info',
|
||||
path: '/domains/:name',
|
||||
component: () => import(/* webpackChunkName: "views/domain/info" */ '@/views/domain/DomainInfo'),
|
||||
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: [
|
||||
{
|
||||
name: 'domain-config',
|
||||
name: 'domain-info',
|
||||
path: ':tabId?',
|
||||
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
|
||||
props: true,
|
||||
meta: {
|
||||
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
|
||||
args: { trad: 'config' },
|
||||
breadcrumb: ['domain-list', 'domain-info', 'domain-config']
|
||||
args: { param: 'name' },
|
||||
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 │
|
||||
|
@ -241,16 +210,6 @@ const routes = [
|
|||
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
|
||||
path: '/apps/:id/config-panel',
|
||||
|
@ -293,7 +252,7 @@ const routes = [
|
|||
component: () => import(/* webpackChunkName: "views/service/list" */ '@/views/service/ServiceList'),
|
||||
meta: {
|
||||
args: { trad: 'services' },
|
||||
breadcrumb: ['service-list']
|
||||
breadcrumb: ['tool-list', 'service-list']
|
||||
}
|
||||
},
|
||||
{
|
||||
|
@ -303,7 +262,7 @@ const routes = [
|
|||
props: true,
|
||||
meta: {
|
||||
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']
|
||||
}
|
||||
},
|
||||
{
|
||||
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',
|
||||
path: '/tools/webadmin',
|
||||
|
@ -374,6 +324,23 @@ const routes = [
|
|||
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',
|
||||
path: '/tools/power',
|
||||
|
|
245
app/src/scss/_functions-override.scss
Normal file
245
app/src/scss/_functions-override.scss
Normal 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);
|
||||
}
|
||||
}
|
|
@ -30,14 +30,44 @@
|
|||
$font-size-base: .9rem;
|
||||
$font-weight-bold: 500;
|
||||
|
||||
$blue: #2f7ed2;
|
||||
$purple: #9932cc;
|
||||
$yellow: #ffd452;
|
||||
$white: var(--white);
|
||||
$gray-100: var(--gray-100);
|
||||
$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: (
|
||||
'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
|
||||
$font-weight-light: 200;
|
||||
$display1-weight: 200;
|
||||
|
@ -62,15 +92,17 @@ $card-spacer-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 '~bootstrap/scss/functions.scss';
|
||||
@import '_functions-override.scss';
|
||||
@import '~bootstrap/scss/variables';
|
||||
@import '~bootstrap/scss/mixins.scss';
|
||||
@import '~bootstrap-vue/src/variables';
|
||||
|
||||
|
||||
$body-color: $gray-800;
|
||||
|
||||
$hr-border-color: $gray-200;
|
||||
|
||||
$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;
|
||||
|
||||
$btn-padding-y-xs: .25rem;
|
||||
$btn-padding-x-xs: .35rem;
|
||||
$btn-line-height-xs: 1.5;
|
||||
|
|
|
@ -8,13 +8,131 @@
|
|||
|
||||
// Dependencies SCSS imports
|
||||
// `~` 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 fonts
|
||||
@import 'font';
|
||||
@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
|
||||
|
||||
|
@ -30,7 +148,7 @@ body {
|
|||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.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-*
|
||||
@each $breakpoint in map-keys($grid-breakpoints) {
|
||||
@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
|
||||
.input-group .is-invalid ~ .invalid-feedback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.tooltip { top: 0; }
|
||||
// Descriptive list (<b-row /> elems with <b-col> inside)
|
||||
// FIXME REMOVE when every infos switch to `DescriptionRow`
|
||||
.row-line {
|
||||
@include media-breakpoint-up(md) {
|
||||
&:hover {
|
||||
background-color: rgba(0, 0, 0, 0.05);
|
||||
background-color: rgba($black, 0.05);
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
}
|
||||
|
@ -102,6 +221,11 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
h3.card-title {
|
||||
margin-bottom: 1em;
|
||||
border-bottom: solid 1px $hr-border-color;
|
||||
}
|
||||
|
||||
// collapse icon
|
||||
.not-collapsed > .icon {
|
||||
transform: rotate(-90deg);
|
||||
|
@ -165,8 +289,18 @@ body {
|
|||
}
|
||||
}
|
||||
|
||||
.alert p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
code {
|
||||
background: ghostwhite;
|
||||
background: $light;
|
||||
padding: .15rem .25rem;
|
||||
border-radius: $border-radius;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.log {
|
||||
|
|
|
@ -2,12 +2,27 @@ import Vue from 'vue'
|
|||
|
||||
import api from '@/api'
|
||||
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 {
|
||||
state: () => ({
|
||||
domains: undefined, // Array
|
||||
main_domain: undefined,
|
||||
domains: undefined, // Array
|
||||
domains_details: {},
|
||||
users: undefined, // basic user data: Object {username: {data}}
|
||||
users_details: {}, // precise user data: Object {username: {data}}
|
||||
groups: undefined,
|
||||
|
@ -19,6 +34,22 @@ export default {
|
|||
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 }]) {
|
||||
state.domains.push(domain)
|
||||
},
|
||||
|
@ -48,12 +79,11 @@ export default {
|
|||
Vue.set(state.users_details, username, userData)
|
||||
if (!state.users) return
|
||||
const user = state.users[username]
|
||||
for (const key of ['firstname', 'lastname', 'mail']) {
|
||||
for (const key of ['fullname', 'mail']) {
|
||||
if (user[key] !== userData[key]) {
|
||||
Vue.set(user, key, userData[key])
|
||||
}
|
||||
}
|
||||
Vue.set(user, 'fullname', `${userData.firstname} ${userData.lastname}`)
|
||||
},
|
||||
|
||||
'UPDATE_USERS_DETAILS' (state, payload) {
|
||||
|
@ -174,6 +204,44 @@ export default {
|
|||
|
||||
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,
|
||||
|
||||
domainsAsChoices: state => {
|
||||
|
|
|
@ -142,8 +142,8 @@ export default {
|
|||
})
|
||||
},
|
||||
|
||||
'LOGIN' ({ dispatch }, password) {
|
||||
return api.post('login', { credentials: password }, null, { websocket: false }).then(() => {
|
||||
'LOGIN' ({ dispatch }, credentials) {
|
||||
return api.post('login', { credentials }, null, { websocket: false }).then(() => {
|
||||
dispatch('CONNECT')
|
||||
})
|
||||
},
|
||||
|
|
|
@ -13,6 +13,7 @@ export default {
|
|||
fallbackLocale: localStorage.getItem('fallbackLocale'),
|
||||
cache: localStorage.getItem('cache') !== 'false',
|
||||
transitions: localStorage.getItem('transitions') !== 'false',
|
||||
theme: localStorage.getItem('theme') !== 'false',
|
||||
experimental: localStorage.getItem('experimental') === 'true',
|
||||
spinner: 'pacman',
|
||||
supportedLocales: supportedLocales
|
||||
|
@ -46,6 +47,12 @@ export default {
|
|||
|
||||
'SET_SPINNER' (state, 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)
|
||||
i18n.fallbackLocale = [locale, 'en']
|
||||
})
|
||||
},
|
||||
|
||||
'UPDATE_THEME' ({ commit }, theme) {
|
||||
commit('SET_THEME', theme)
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -73,6 +84,7 @@ export default {
|
|||
fallbackLocale: state => (state.fallbackLocale),
|
||||
cache: state => (state.cache),
|
||||
transitions: state => (state.transitions),
|
||||
theme: state => (state.theme),
|
||||
experimental: state => state.experimental,
|
||||
spinner: state => state.spinner,
|
||||
|
||||
|
|
|
@ -25,7 +25,6 @@ export default {
|
|||
{ routeName: 'domain-list', icon: 'globe', translation: 'domains' },
|
||||
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' },
|
||||
{ routeName: 'update', icon: 'refresh', translation: 'system_update' },
|
||||
{ routeName: 'service-list', icon: 'cog', translation: 'services' },
|
||||
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
|
||||
{ routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' },
|
||||
{ routeName: 'backup', icon: 'archive', translation: 'backup' }
|
||||
|
|
|
@ -1,37 +1,35 @@
|
|||
<template>
|
||||
<b-form @submit.prevent="login">
|
||||
<b-input-group>
|
||||
<template v-slot:prepend>
|
||||
<b-input-group-text>
|
||||
<label class="sr-only" for="input-password">{{ $t('password') }}</label>
|
||||
<icon iname="lock" class="sm" />
|
||||
</b-input-group-text>
|
||||
</template>
|
||||
<card-form
|
||||
:title="$t('login')" icon="lock"
|
||||
:validation="$v" :server-error="serverError"
|
||||
@submit.prevent="login"
|
||||
>
|
||||
<!-- ADMIN USERNAME -->
|
||||
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
|
||||
|
||||
<b-form-input
|
||||
id="input-password"
|
||||
required type="password"
|
||||
v-model="password"
|
||||
:placeholder="$t('administration_password')" :state="isValid"
|
||||
/>
|
||||
<!-- ADMIN PASSWORD -->
|
||||
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
|
||||
|
||||
<template v-slot:append>
|
||||
<b-button type="submit" variant="success" :disabled="disabled">
|
||||
{{ $t('login') }}
|
||||
</b-button>
|
||||
</template>
|
||||
</b-input-group>
|
||||
|
||||
<b-form-invalid-feedback :state="isValid">
|
||||
{{ $t('wrong_password') }}
|
||||
</b-form-invalid-feedback>
|
||||
</b-form>
|
||||
<template #buttons>
|
||||
<b-button
|
||||
type="submit" variant="success"
|
||||
:disabled="disabled" form="ynh-form"
|
||||
>
|
||||
{{ $t('login') }}
|
||||
</b-button>
|
||||
</template>
|
||||
</card-form>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { validationMixin } from 'vuelidate'
|
||||
import { alphalownum_, required, minLength } from '@/helpers/validators'
|
||||
|
||||
export default {
|
||||
name: 'Login',
|
||||
|
||||
mixins: [validationMixin],
|
||||
|
||||
props: {
|
||||
skipInstallCheck: { type: Boolean, default: false },
|
||||
forceReload: { type: Boolean, default: false }
|
||||
|
@ -40,15 +38,42 @@ export default {
|
|||
data () {
|
||||
return {
|
||||
disabled: !this.skipInstallCheck,
|
||||
password: '',
|
||||
isValid: null,
|
||||
apiError: undefined
|
||||
serverError: '',
|
||||
form: {
|
||||
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: {
|
||||
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) {
|
||||
window.location.href = '/yunohost/admin/'
|
||||
} else {
|
||||
|
@ -56,7 +81,7 @@ export default {
|
|||
}
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIUnauthorizedError') throw err
|
||||
this.isValid = false
|
||||
this.serverError = this.$i18n.t('wrong_password_or_username')
|
||||
})
|
||||
}
|
||||
},
|
||||
|
|
|
@ -12,7 +12,7 @@
|
|||
<span v-html="$t('postinstall_intro_3')" />
|
||||
</p>
|
||||
|
||||
<b-button size="lg" variant="primary" @click="goToStep('domain')">
|
||||
<b-button size="lg" variant="success" @click="goToStep('domain')">
|
||||
{{ $t('begin') }}
|
||||
</b-button>
|
||||
</template>
|
||||
|
@ -33,16 +33,23 @@
|
|||
</b-button>
|
||||
</template>
|
||||
|
||||
<!-- PASSWORD SETUP STEP -->
|
||||
<template v-else-if="step === 'password'">
|
||||
<password-form
|
||||
:title="$t('postinstall_set_password')" :submit-text="$t('next')" :server-error="serverError"
|
||||
@submit="setPassword"
|
||||
<!-- FIRST USER SETUP STEP -->
|
||||
<template v-else-if="step === 'user'">
|
||||
<card-form
|
||||
:title="$t('postinstall.user.title')" icon="user-plus"
|
||||
:validation="$v" :server-error="serverError"
|
||||
:submit-text="$t('next')" @submit.prevent="setUser"
|
||||
>
|
||||
<template #disclaimer>
|
||||
<p class="alert alert-warning" v-t="'postinstall_password'" />
|
||||
</template>
|
||||
</password-form>
|
||||
<read-only-alert-item
|
||||
:label="$t('postinstall.user.first_user_help')"
|
||||
type="info"
|
||||
/>
|
||||
|
||||
<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">
|
||||
<icon iname="chevron-left" /> {{ $t('previous') }}
|
||||
|
@ -74,25 +81,58 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { validationMixin } from 'vuelidate'
|
||||
|
||||
import api from '@/api'
|
||||
import { DomainForm, PasswordForm } from '@/views/_partials'
|
||||
import { DomainForm } from '@/views/_partials'
|
||||
import Login from '@/views/Login'
|
||||
import { alphalownum_, required, minLength, name, sameAs } from '@/helpers/validators'
|
||||
|
||||
export default {
|
||||
name: 'PostInstall',
|
||||
|
||||
mixins: [validationMixin],
|
||||
|
||||
components: {
|
||||
DomainForm,
|
||||
PasswordForm,
|
||||
Login
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
step: 'start',
|
||||
serverError: '',
|
||||
domain: undefined,
|
||||
password: undefined,
|
||||
serverError: ''
|
||||
user: {
|
||||
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 }) {
|
||||
this.domain = domain
|
||||
this.goToStep('password')
|
||||
this.goToStep('user')
|
||||
},
|
||||
|
||||
async setPassword ({ password }) {
|
||||
this.password = password
|
||||
async setUser () {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
this.$i18n.t('confirm_postinstall', { domain: this.domain })
|
||||
)
|
||||
|
@ -117,22 +156,29 @@ export default {
|
|||
},
|
||||
|
||||
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 ?
|
||||
api.post(
|
||||
'postinstall' + (force ? '?force_diskspace' : ''),
|
||||
{ domain: this.domain, password: this.password },
|
||||
data,
|
||||
{ key: 'postinstall' }
|
||||
).then(() => {
|
||||
// Display success message and allow the user to login
|
||||
this.goToStep('login')
|
||||
}).catch(err => {
|
||||
const hasWordsInError = (words) => words.some((word) => (err.key || err.message).includes(word))
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
if (err.key === 'postinstall_low_rootfsspace') {
|
||||
this.step = 'rootfsspace-error'
|
||||
} else if (err.key.includes('password')) {
|
||||
this.step = 'password'
|
||||
} else if (['domain', 'dyndns'].some(word => err.key.includes(word))) {
|
||||
} else if (hasWordsInError(['domain', 'dyndns'])) {
|
||||
this.step = 'domain'
|
||||
} else if (hasWordsInError(['password', 'user'])) {
|
||||
this.step = 'user'
|
||||
} else {
|
||||
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 () {
|
||||
this.$store.dispatch('CHECK_INSTALL').then(installed => {
|
||||
if (installed) {
|
||||
|
|
|
@ -126,12 +126,10 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
onSubmit () {
|
||||
async onSubmit () {
|
||||
const domainType = this.selected
|
||||
this.$emit('submit', {
|
||||
domain: formatFormDataValue(this.form[domainType]),
|
||||
domainType
|
||||
})
|
||||
const domain = await formatFormDataValue(this.form[domainType])
|
||||
this.$emit('submit', { domain, domainType })
|
||||
}
|
||||
},
|
||||
|
||||
|
|
|
@ -207,6 +207,7 @@ export default {
|
|||
border-bottom-left-radius: 0;
|
||||
font-size: $font-size-sm;
|
||||
|
||||
|
||||
& > header {
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -7,4 +7,3 @@ export { default as HistoryConsole } from './HistoryConsole'
|
|||
export { default as ViewLockOverlay } from './ViewLockOverlay'
|
||||
|
||||
export { default as DomainForm } from './DomainForm'
|
||||
export { default as PasswordForm } from './PasswordForm'
|
||||
|
|
|
@ -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>
|
|
@ -70,27 +70,28 @@
|
|||
<b-card-title class="d-flex mb-2">
|
||||
{{ 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
|
||||
v-if="app.state !== 'highquality'"
|
||||
:variant="(app.color === 'danger' && app.state === 'lowquality') ? 'warning' : app.color"
|
||||
v-if="app.state !== 'working'"
|
||||
:variant="app.color"
|
||||
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
|
||||
>
|
||||
<!-- app.state can be 'lowquality' or 'inprogress' -->
|
||||
{{ $t('app_state_' + app.state) }}
|
||||
</b-badge>
|
||||
|
||||
<icon
|
||||
v-else iname="star" class="star"
|
||||
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
|
||||
v-if="app.high_quality" iname="star" class="star"
|
||||
v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)"
|
||||
/>
|
||||
</small>
|
||||
</b-card-title>
|
||||
|
||||
<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')">
|
||||
<icon iname="warning" /> {{ $t(app.maintained) }}
|
||||
<icon iname="warning" /> {{ $t('orphaned') }}
|
||||
</span>
|
||||
</b-card-text>
|
||||
</b-card-body>
|
||||
|
@ -182,9 +183,9 @@ export default {
|
|||
|
||||
// Filtering options
|
||||
qualityOptions: [
|
||||
{ value: 'isHighQuality', text: this.$i18n.t('only_highquality_apps') },
|
||||
{ value: 'isDecentQuality', text: this.$i18n.t('only_decent_quality_apps') },
|
||||
{ value: 'isWorking', text: this.$i18n.t('only_working_apps') },
|
||||
{ value: 'high_quality', text: this.$i18n.t('only_highquality_apps') },
|
||||
{ value: 'decent_quality', text: this.$i18n.t('only_decent_quality_apps') },
|
||||
{ value: 'working', text: this.$i18n.t('only_working_apps') },
|
||||
{ value: 'all', text: this.$i18n.t('all_apps') }
|
||||
],
|
||||
categories: [
|
||||
|
@ -197,7 +198,7 @@ export default {
|
|||
search: '',
|
||||
category: null,
|
||||
subtag: 'all',
|
||||
quality: 'isDecentQuality',
|
||||
quality: 'decent_quality',
|
||||
|
||||
// Custom install form
|
||||
customInstall: {
|
||||
|
@ -264,51 +265,31 @@ export default {
|
|||
},
|
||||
|
||||
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) {
|
||||
// APPS
|
||||
const apps = []
|
||||
for (const key in data.apps) {
|
||||
const app = data.apps[key]
|
||||
if (app.state === 'notworking') continue
|
||||
|
||||
Object.assign(app, this.formatQuality(app))
|
||||
app.isInstallable = !app.installed || app.manifest.multi_instance
|
||||
if (app.maintained !== 'request_adoption') {
|
||||
app.maintained = app.maintained ? 'maintained' : 'orphaned'
|
||||
app.isInstallable = !app.installed || app.manifest.integration.multi_instance
|
||||
app.working = app.state === 'working'
|
||||
app.decent_quality = app.working && app.level > 4
|
||||
app.high_quality = app.working && app.level >= 8
|
||||
app.color = 'danger'
|
||||
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.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ')
|
||||
app.searchValues = [
|
||||
app.id,
|
||||
app.state,
|
||||
app.manifest.name,
|
||||
app.manifest.description,
|
||||
app.potential_alternative_to.join(' ')
|
||||
].join(' ').toLowerCase()
|
||||
apps.push(app)
|
||||
}
|
||||
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
|
||||
|
@ -328,10 +309,8 @@ export default {
|
|||
|
||||
// INSTALL APP
|
||||
async onInstallClick (app) {
|
||||
if (!app.isDecentQuality) {
|
||||
// Ask for confirmation
|
||||
const state = app.color === 'danger' ? 'inprogress' : app.state
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + state))
|
||||
if (!app.decent_quality) {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + app.state))
|
||||
if (!confirmed) return
|
||||
}
|
||||
this.$router.push({ name: 'app-install', params: { id: app.id } })
|
||||
|
|
|
@ -3,7 +3,10 @@
|
|||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
ref="view" skeleton="card-form-skeleton"
|
||||
>
|
||||
<config-panels v-if="config.panels" v-bind="config" @submit="applyConfig" />
|
||||
<config-panels
|
||||
v-if="config.panels" v-bind="config"
|
||||
@submit="onConfigSubmit"
|
||||
/>
|
||||
|
||||
<b-alert v-else-if="config.panels === null" variant="warning">
|
||||
<icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }}
|
||||
|
@ -34,7 +37,7 @@ export default {
|
|||
data () {
|
||||
return {
|
||||
queries: [
|
||||
['GET', `apps/${this.id}/config-panel?full`]
|
||||
['GET', `apps/${this.id}/config?full`]
|
||||
],
|
||||
config: {}
|
||||
}
|
||||
|
@ -49,23 +52,22 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
async applyConfig (id_) {
|
||||
const formatedData = await formatFormData(
|
||||
this.config.forms[id_],
|
||||
{ removeEmpty: false, removeNull: true, multipart: false }
|
||||
)
|
||||
async onConfigSubmit ({ id, form, action, name }) {
|
||||
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||
|
||||
api.put(
|
||||
`apps/${this.id}/config`,
|
||||
{ key: id_, args: objectToParams(formatedData) },
|
||||
{ key: 'apps.update_config', name: this.id }
|
||||
).then(response => {
|
||||
action
|
||||
? `apps/${this.id}/actions/${action}`
|
||||
: `apps/${this.id}/config/${id}`,
|
||||
{ args: objectToParams(args) },
|
||||
{ key: `apps.${action ? 'action' : 'update'}_config`, id, name: this.id }
|
||||
).then(() => {
|
||||
this.$refs.view.fetchQueries({ triggerLoading: true })
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
const panel = this.config.panels.find(({ id }) => id_ === id)
|
||||
const panel = this.config.panels.find(panel => panel.id === id)
|
||||
if (err.data.name) {
|
||||
this.config.errors[id_][err.data.name].message = err.message
|
||||
this.config.errors[id][err.data.name].message = err.message
|
||||
} else this.$set(panel, 'serverError', err.message)
|
||||
})
|
||||
}
|
||||
|
|
|
@ -2,33 +2,22 @@
|
|||
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
|
||||
<!-- BASIC INFOS -->
|
||||
<card v-if="infos" :title="infos.label" icon="cube">
|
||||
<b-row
|
||||
v-for="(value, prop) in infos" :key="prop"
|
||||
no-gutters class="row-line"
|
||||
<description-row
|
||||
v-for="(value, key) in infos" :key="key"
|
||||
:term="$t(key)"
|
||||
>
|
||||
<b-col cols="auto" md="3">
|
||||
<strong>{{ $t(prop) }}</strong>
|
||||
</b-col>
|
||||
<b-col>
|
||||
<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') }}
|
||||
<b-button
|
||||
size="sm" :to="{ name: 'group-list'}" variant="info"
|
||||
class="ml-2"
|
||||
>
|
||||
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
|
||||
</b-button>
|
||||
</b-col>
|
||||
</b-row>
|
||||
<a v-if="key === 'url'" :href="value" target="_blank">{{ value }}</a>
|
||||
<template v-else>{{ value }}</template>
|
||||
</description-row>
|
||||
<description-row :term="$t('app_info_access_desc')">
|
||||
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') + '.' : $t('nobody') }}
|
||||
<b-button
|
||||
size="sm" :to="{ name: 'group-list'}" variant="info"
|
||||
class="ml-2"
|
||||
>
|
||||
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
|
||||
</b-button>
|
||||
</description-row>
|
||||
</card>
|
||||
|
||||
<!-- OPERATIONS -->
|
||||
|
@ -145,19 +134,6 @@
|
|||
</b-form-group>
|
||||
</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>
|
||||
<card-info-skeleton :item-count="8" />
|
||||
<card-form-skeleton />
|
||||
|
@ -195,7 +171,7 @@ export default {
|
|||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['domains', 'experimental']),
|
||||
...mapGetters(['domains']),
|
||||
|
||||
allowedGroups () {
|
||||
if (!this.app) return
|
||||
|
@ -243,7 +219,7 @@ export default {
|
|||
label: mainPermission.label,
|
||||
description: app.description,
|
||||
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)
|
||||
}
|
||||
if (app.settings.domain && app.settings.path) {
|
||||
|
|
|
@ -3,18 +3,10 @@
|
|||
<template v-if="infos">
|
||||
<!-- BASIC INFOS -->
|
||||
<card :title="name" icon="download">
|
||||
<b-row
|
||||
<description-row
|
||||
v-for="(info, key) in infos" :key="key"
|
||||
no-gutters class="row-line"
|
||||
>
|
||||
<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>
|
||||
:term="$t(key)" :details="info"
|
||||
/>
|
||||
</card>
|
||||
|
||||
<!-- INSTALL FORM -->
|
||||
|
@ -24,10 +16,9 @@
|
|||
@submit.prevent="performInstall"
|
||||
>
|
||||
<template v-for="(field, fname) in fields">
|
||||
<form-field
|
||||
v-if="isVisible(field.visible, field)"
|
||||
:key="fname" label-cols="0"
|
||||
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
|
||||
<component
|
||||
v-if="field.visible" :is="field.is" v-bind="field.props"
|
||||
v-model="form[fname]" :validation="$v.form[fname]" :key="fname"
|
||||
/>
|
||||
</template>
|
||||
</card-form>
|
||||
|
@ -47,10 +38,13 @@
|
|||
|
||||
<script>
|
||||
import { validationMixin } from 'vuelidate'
|
||||
import evaluate from 'simple-evaluate'
|
||||
|
||||
import api, { objectToParams } from '@/api'
|
||||
import { formatYunoHostArguments, formatI18nField, formatFormData, pFileReader } from '@/helpers/yunohostArguments'
|
||||
import {
|
||||
formatYunoHostArguments,
|
||||
formatI18nField,
|
||||
formatFormData
|
||||
} from '@/helpers/yunohostArguments'
|
||||
|
||||
export default {
|
||||
name: 'AppInstall',
|
||||
|
@ -68,7 +62,6 @@ export default {
|
|||
],
|
||||
name: undefined,
|
||||
infos: undefined,
|
||||
formDisclaimer: null,
|
||||
form: undefined,
|
||||
fields: undefined,
|
||||
validations: null,
|
||||
|
@ -85,17 +78,28 @@ export default {
|
|||
onQueriesResponse (manifest) {
|
||||
this.name = manifest.name
|
||||
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
|
||||
manifest.license = manifest.upstream.license
|
||||
if (manifest.license === undefined || manifest.license === 'free') {
|
||||
infosKeys.splice(2, 1)
|
||||
}
|
||||
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]]))
|
||||
|
||||
const { form, fields, validations, errors } = formatYunoHostArguments(
|
||||
manifest.arguments.install,
|
||||
manifest.name
|
||||
)
|
||||
// FIXME yunohost should add the label field by default
|
||||
manifest.install.unshift({
|
||||
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.form = form
|
||||
|
@ -103,41 +107,6 @@ export default {
|
|||
this.errors = errors
|
||||
},
|
||||
|
||||
isVisible (expression, field) {
|
||||
if (!expression || !field) return true
|
||||
const context = {}
|
||||
|
||||
const promises = []
|
||||
for (const shortname in this.form) {
|
||||
if (this.form[shortname] instanceof File) {
|
||||
if (expression.includes(shortname)) {
|
||||
promises.push(pFileReader(this.form[shortname], context, shortname, false))
|
||||
}
|
||||
} else {
|
||||
context[shortname] = this.form[shortname]
|
||||
}
|
||||
}
|
||||
// Allow to use match(var,regexp) function
|
||||
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
|
||||
let i = 0
|
||||
Promise.all(promises).then(() => {
|
||||
for (const matched of expression.matchAll(matchRe)) {
|
||||
i++
|
||||
const varName = matched[1] + '__re' + i.toString()
|
||||
context[varName] = new RegExp(matched[2], 'm').test(context[matched[1]])
|
||||
expression = expression.replace(matched[0], varName)
|
||||
}
|
||||
|
||||
try {
|
||||
field.isVisible = evaluate(context, expression)
|
||||
} catch (error) {
|
||||
field.isVisible = false
|
||||
}
|
||||
})
|
||||
// This value should be updated magically when vuejs will detect isVisible changed
|
||||
return field.isVisible
|
||||
},
|
||||
|
||||
async performInstall () {
|
||||
if ('path' in this.form && this.form.path === '/') {
|
||||
const confirmed = await this.$askConfirmation(
|
||||
|
@ -148,7 +117,7 @@ export default {
|
|||
|
||||
const { data: args, label } = await formatFormData(
|
||||
this.form,
|
||||
{ extract: ['label'], removeEmpty: false, removeNull: true, multipart: false }
|
||||
{ extract: ['label'], removeEmpty: false, removeNull: true }
|
||||
)
|
||||
const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }
|
||||
|
||||
|
|
|
@ -68,25 +68,8 @@ export default {
|
|||
return
|
||||
}
|
||||
|
||||
const multiInstances = {}
|
||||
this.apps = apps.map(({ id, name, description, permissions, manifest }) => {
|
||||
// FIXME seems like some apps may no have a label (replace with id)
|
||||
const label = permissions[id + '.main'].label
|
||||
// Display the `id` of the instead of its `name` if multiple apps share the same name
|
||||
if (manifest.multi_instance) {
|
||||
if (!(name in multiInstances)) {
|
||||
multiInstances[name] = []
|
||||
}
|
||||
const labels = multiInstances[name]
|
||||
if (labels.includes(label)) {
|
||||
name = id
|
||||
}
|
||||
labels.push(label)
|
||||
}
|
||||
if (label === name) {
|
||||
name = null
|
||||
}
|
||||
return { id, name, description, label }
|
||||
this.apps = apps.map(({ id, name, description, manifest }) => {
|
||||
return { id, name: manifest.name, label: name, description }
|
||||
}).sort((prev, app) => {
|
||||
return prev.label > app.label ? 1 : -1
|
||||
})
|
||||
|
|
|
@ -74,7 +74,7 @@
|
|||
</b-button>
|
||||
<b-button
|
||||
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}`"
|
||||
>
|
||||
<icon iname="level-down" /> {{ $t('details') }}
|
||||
|
@ -104,6 +104,8 @@
|
|||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import api from '@/api'
|
||||
import { distanceToNow } from '@/helpers/filters/date'
|
||||
|
||||
|
@ -120,6 +122,10 @@ export default {
|
|||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['theme'])
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatReportItem (report, item) {
|
||||
let issue = false
|
||||
|
|
|
@ -1,160 +0,0 @@
|
|||
<template>
|
||||
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
|
||||
<card v-if="cert" :title="$t('certificate_status')" icon="lock">
|
||||
<p :class="'alert alert-' + cert.alert.type">
|
||||
<icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }}
|
||||
</p>
|
||||
|
||||
<b-row no-gutters class="row-line">
|
||||
<b-col md="4" xl="2">
|
||||
<strong v-t="'certificate_authority'" />
|
||||
</b-col>
|
||||
<b-col>{{ cert.type }} ({{ name }})</b-col>
|
||||
</b-row>
|
||||
|
||||
<b-row no-gutters class="row-line">
|
||||
<b-col md="4" xl="2">
|
||||
<strong v-t="'validity'" />
|
||||
</b-col>
|
||||
<b-col>{{ $tc('day_validity', cert.validity) }}</b-col>
|
||||
</b-row>
|
||||
</card>
|
||||
|
||||
<card v-if="cert" :title="$t('operations')" icon="wrench">
|
||||
<!-- CERT INSTALL LETSENCRYPT -->
|
||||
<template v-if="actionsEnabled.installLetsencrypt">
|
||||
<p>
|
||||
<icon :iname="cert.acmeEligible ? 'check' : 'meh-o'" /> <span v-html="$t(`domain_${cert.acmeEligible ? 'is' : 'not'}_eligible_for_ACME`)" />
|
||||
</p>
|
||||
|
||||
<b-button @click="callAction('install_LE')" variant="success" :disabled="!cert.acmeEligible">
|
||||
<icon iname="star" /> {{ $t('install_letsencrypt_cert') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
</template>
|
||||
|
||||
<!-- CERT RENEW LETS-ENCRYPT -->
|
||||
<template v-if="actionsEnabled.manualRenewLetsencrypt">
|
||||
<p v-t="'manually_renew_letsencrypt_message'" />
|
||||
|
||||
<b-button @click="callAction('manual_renew_LE')" variant="warning">
|
||||
<icon iname="refresh" /> {{ $t('manually_renew_letsencrypt') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
</template>
|
||||
|
||||
<!-- CERT REGEN SELF-SIGNED -->
|
||||
<template v-if="actionsEnabled.regenSelfsigned">
|
||||
<p v-t="'regenerate_selfsigned_cert_message'" />
|
||||
|
||||
<b-button @click="callAction('regen_selfsigned')" variant="warning">
|
||||
<icon iname="refresh" /> {{ $t('regenerate_selfsigned_cert') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
</template>
|
||||
|
||||
<!-- CERT REPLACE WITH SELF-SIGNED -->
|
||||
<template v-if="actionsEnabled.replaceWithSelfsigned">
|
||||
<p v-t="'revert_to_selfsigned_cert_message'" />
|
||||
|
||||
<b-button @click="callAction('revert_to_selfsigned')" variant="danger">
|
||||
<icon iname="exclamation-triangle" /> {{ $t('revert_to_selfsigned_cert') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
</template>
|
||||
</card>
|
||||
|
||||
<template #skeleton>
|
||||
<card-info-skeleton :item-count="2" />
|
||||
<card-buttons-skeleton :item-count="2" />
|
||||
</template>
|
||||
</view-base>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import api from '@/api'
|
||||
|
||||
export default {
|
||||
name: 'DomainCert',
|
||||
|
||||
props: {
|
||||
name: { type: String, required: true }
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
queries: [
|
||||
['GET', `domains/${this.name}/cert?full`]
|
||||
],
|
||||
cert: undefined,
|
||||
actionsEnabled: undefined
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
formatCertAlert (code, type) {
|
||||
switch (code) {
|
||||
case 'critical': return { type: 'danger', trad: 'not_valid', icon: 'exclamation-circle' }
|
||||
case 'warning': return { type: 'warning', trad: 'selfsigned', icon: 'exclamation-triangle' }
|
||||
case 'attention':
|
||||
if (type === 'lets-encrypt') {
|
||||
return { type: 'warning', trad: 'letsencrypt_about_to_expire', icon: 'clock-o' }
|
||||
} else {
|
||||
return { type: 'danger', trad: 'about_to_expire', icon: 'clock-o' }
|
||||
}
|
||||
case 'good': return { type: 'success', trad: 'good', icon: 'check-circle' }
|
||||
case 'great': return { type: 'success', trad: 'great', icon: 'thumbs-up' }
|
||||
default: return { type: 'warning', trad: 'unknown', icon: 'question' }
|
||||
}
|
||||
},
|
||||
|
||||
onQueriesResponse (data) {
|
||||
const certData = data.certificates[this.name]
|
||||
|
||||
const cert = {
|
||||
type: certData.CA_type.verbose,
|
||||
name: certData.CA_name,
|
||||
validity: certData.validity,
|
||||
acmeEligible: certData.ACME_eligible,
|
||||
alert: this.formatCertAlert(certData.summary.code, certData.CA_type.verbose)
|
||||
}
|
||||
|
||||
const actionsEnabled = {
|
||||
installLetsencrypt: false,
|
||||
manualRenewLetsencrypt: false,
|
||||
regenSelfsigned: false,
|
||||
replaceWithSelfsigned: false
|
||||
}
|
||||
|
||||
switch (certData.CA_type.code) {
|
||||
case 'self-signed':
|
||||
actionsEnabled.installLetsencrypt = true
|
||||
actionsEnabled.regenSelfsigned = true
|
||||
break
|
||||
case 'lets-encrypt':
|
||||
actionsEnabled.manualRenewLetsencrypt = true
|
||||
actionsEnabled.replaceWithSelfsigned = true
|
||||
break
|
||||
default:
|
||||
actionsEnabled.replaceWithSelfsigned = true
|
||||
}
|
||||
|
||||
this.cert = cert
|
||||
this.actionsEnabled = actionsEnabled
|
||||
},
|
||||
|
||||
async callAction (action) {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t(`confirm_cert_${action}`))
|
||||
if (!confirmed) return
|
||||
|
||||
let uri = `domains/${this.name}/cert`
|
||||
if (action === 'regen_selfsigned') uri += '?self_signed'
|
||||
else if (action === 'manual_renew_LE') uri += '?force'
|
||||
else if (action === 'revert_to_selfsigned') uri += '?self_signed&force'
|
||||
api.put(
|
||||
uri, {}, { key: 'domains.' + action, name: this.name }
|
||||
).then(this.$refs.view.fetchQueries)
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
|
@ -3,10 +3,16 @@
|
|||
:queries="queries" @queries-response="onQueriesResponse" :loading="loading"
|
||||
skeleton="card-info-skeleton"
|
||||
>
|
||||
<card v-if="showAutoConfigCard" :title="$t('domain.dns.auto_config')" icon="wrench">
|
||||
<b-alert variant="warning">
|
||||
<icon iname="flask" /> <icon iname="warning" /> <span v-html="$t('domain.dns.info')" />
|
||||
</b-alert>
|
||||
<section v-if="showAutoConfigCard" class="panel-section">
|
||||
<b-card-title title-tag="h3">
|
||||
{{ $t('domain.dns.auto_config') }}
|
||||
</b-card-title>
|
||||
|
||||
<read-only-alert-item
|
||||
:label="$t('domain.dns.info')"
|
||||
type="warning"
|
||||
icon="flask"
|
||||
/>
|
||||
|
||||
<!-- AUTO CONFIG CHANGES -->
|
||||
<template v-if="dnsChanges">
|
||||
|
@ -32,27 +38,32 @@
|
|||
</template>
|
||||
|
||||
<!-- CONFIG OK ALERT -->
|
||||
<b-alert v-else-if="dnsChanges === null" variant="success" class="m-0">
|
||||
<icon iname="thumbs-up" /> {{ $t('domain.dns.auto_config_ok') }}
|
||||
</b-alert>
|
||||
<read-only-alert-item
|
||||
v-else-if="dnsChanges === null"
|
||||
:label="$t('domain.dns.auto_config_ok')"
|
||||
type="success"
|
||||
icon="thumbs-up"
|
||||
/>
|
||||
|
||||
<!-- CONFIG ERROR ALERT -->
|
||||
<template v-if="dnsErrors && dnsErrors.length">
|
||||
<b-alert
|
||||
<read-only-alert-item
|
||||
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i"
|
||||
:variant="variant" :class="dnsErrors.length === 1 ? 'm-0' : ''"
|
||||
>
|
||||
<icon :iname="icon" /> <span v-html="message" />
|
||||
</b-alert>
|
||||
:label="message"
|
||||
:type="variant"
|
||||
:icon="icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- CONFIG OVERWRITE DISCLAIMER -->
|
||||
<b-alert v-if="force !== null" variant="warning">
|
||||
<icon iname="warning" /> <span v-html="$t('domain.dns.push_force_warning')" />
|
||||
</b-alert>
|
||||
<read-only-alert-item
|
||||
v-if="force !== null"
|
||||
:label="$t('domain.dns.push_force_warning')"
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
<!-- CONFIG PUSH SUBMIT -->
|
||||
<template v-if="dnsChanges" #buttons>
|
||||
<template v-if="dnsChanges">
|
||||
<b-form-checkbox v-if="force !== null" v-model="force">
|
||||
{{ $t('domain.dns.push_force') }}
|
||||
</b-form-checkbox>
|
||||
|
@ -61,13 +72,14 @@
|
|||
{{ $t('domain.dns.push') }}
|
||||
</b-button>
|
||||
</template>
|
||||
</card>
|
||||
</section>
|
||||
|
||||
<!-- CURRENT DNS ZONE -->
|
||||
<card
|
||||
v-if="showAutoConfigCard && dnsZone && dnsZone.length"
|
||||
:title="$t('domain.dns.auto_config_zone')" icon="globe" no-body
|
||||
>
|
||||
<section v-if="showAutoConfigCard && dnsZone && dnsZone.length" class="panel-section">
|
||||
<b-card-title title-tag="h3">
|
||||
{{ $t('domain.dns.auto_config_zone') }}
|
||||
</b-card-title>
|
||||
|
||||
<div class="log">
|
||||
<div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records">
|
||||
{{ record }}
|
||||
|
@ -75,19 +87,21 @@
|
|||
<span>{{ content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</card>
|
||||
</section>
|
||||
|
||||
<!-- MANUAL CONFIG CARD -->
|
||||
<card
|
||||
v-if="showManualConfigCard"
|
||||
:title="$t('domain.dns.manual_config')" icon="globe" no-body
|
||||
>
|
||||
<b-alert variant="warning" class="m-0">
|
||||
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }}
|
||||
</b-alert>
|
||||
<section v-if="showManualConfigCard" class="panel-section">
|
||||
<b-card-title title-tag="h3">
|
||||
{{ $t('domain.dns.manual_config') }}
|
||||
</b-card-title>
|
||||
|
||||
<read-only-alert-item
|
||||
:label="$t('domain_dns_conf_is_just_a_recommendation')"
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
<pre class="log">{{ dnsConfig }}</pre>
|
||||
</card>
|
||||
</section>
|
||||
</view-base>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,83 +1,160 @@
|
|||
<template>
|
||||
<view-base :queries="queries" skeleton="card-list-skeleton">
|
||||
<card :title="name" icon="globe">
|
||||
<!-- VISIT -->
|
||||
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p>
|
||||
<b-button variant="success" :href="'https://' + name" target="_blank">
|
||||
<icon iname="external-link" /> {{ $t('domain_visit') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
<view-base
|
||||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
ref="view" skeleton="card-list-skeleton"
|
||||
>
|
||||
<!-- INFO CARD -->
|
||||
<card v-if="domain" :title="name" icon="globe">
|
||||
<template v-if="isMainDomain" #header-next>
|
||||
<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>
|
||||
|
||||
<!-- DEFAULT DOMAIN -->
|
||||
<p>{{ $t('domain_default_desc') }}</p>
|
||||
<p v-if="isMainDomain" class="alert alert-info">
|
||||
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
|
||||
</p>
|
||||
<b-button v-else variant="info" @click="setAsDefaultDomain">
|
||||
<icon iname="star" /> {{ $t('set_default') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
<template #header-buttons>
|
||||
<!-- DEFAULT DOMAIN -->
|
||||
<b-button v-if="!isMainDomain" @click="setAsDefaultDomain" variant="info">
|
||||
<icon iname="star" /> {{ $t('set_default') }}
|
||||
</b-button>
|
||||
|
||||
<!-- DOMAIN CONFIG -->
|
||||
<p>{{ $t('domain.config.edit') }}</p>
|
||||
<b-button variant="warning" :to="{ name: 'domain-config', param: { name } }">
|
||||
<icon iname="cog" /> {{ $t('domain.config.title') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
<!-- DELETE DOMAIN -->
|
||||
<b-button @click="deleteDomain" :disabled="isMainDomain" variant="danger">
|
||||
<icon iname="trash-o" /> {{ $t('delete') }}
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<!-- 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>
|
||||
<!-- DOMAIN LINK -->
|
||||
<description-row :term="$t('words.link')">
|
||||
<b-link :href="'https://' + name" target="_blank">
|
||||
https://{{ name }}
|
||||
</b-link>
|
||||
</description-row>
|
||||
|
||||
<!-- 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>
|
||||
<!-- 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>
|
||||
|
||||
<!-- 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') }}
|
||||
</b-button>
|
||||
<!-- 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') }} <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>
|
||||
|
||||
<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>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
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 {
|
||||
name: 'DomainInfo',
|
||||
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
components: {
|
||||
ConfigPanels,
|
||||
DomainDns
|
||||
},
|
||||
|
||||
data: () => {
|
||||
props: {
|
||||
name: { type: String, required: true }
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
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: {
|
||||
...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 () {
|
||||
if (!this.mainDomain) return
|
||||
return this.name === this.mainDomain
|
||||
|
@ -85,6 +162,30 @@ export default {
|
|||
},
|
||||
|
||||
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 () {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
|
||||
if (!confirmed) return
|
||||
|
@ -112,3 +213,10 @@ export default {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-domain-badge {
|
||||
font-size: .75rem;
|
||||
padding-right: .2em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,9 +3,10 @@
|
|||
id="domain-list"
|
||||
:search.sync="search"
|
||||
:items="domains"
|
||||
:filtered-items="filteredDomains"
|
||||
items-name="domains"
|
||||
:queries="queries"
|
||||
:filtered-items="hasFilteredItems"
|
||||
@queries-response="onQueriesResponse"
|
||||
>
|
||||
<template #top-bar-buttons>
|
||||
<b-button variant="success" :to="{ name: 'domain-add' }">
|
||||
|
@ -14,57 +15,75 @@
|
|||
</b-button>
|
||||
</template>
|
||||
|
||||
<b-list-group>
|
||||
<b-list-group-item
|
||||
v-for="domain in filteredDomains" :key="domain"
|
||||
:to="{ name: 'domain-info', params: { name: domain }}"
|
||||
class="d-flex justify-content-between align-items-center pr-0"
|
||||
>
|
||||
<div>
|
||||
<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')" />
|
||||
<recursive-list-group :tree="tree" :toggle-text="$t('domain.toggle_subdomains')" class="mb-5">
|
||||
<template #default="{ data, parent }">
|
||||
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mr-3">
|
||||
<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
|
||||
>
|
||||
<icon iname="star" />
|
||||
</small>
|
||||
</h5>
|
||||
<p class="font-italic m-0">
|
||||
https://{{ domain }}
|
||||
</p>
|
||||
</div>
|
||||
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</template>
|
||||
</recursive-list-group>
|
||||
</view-search>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import RecursiveListGroup from '@/components/RecursiveListGroup'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'DomainList',
|
||||
|
||||
components: {
|
||||
RecursiveListGroup
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
queries: [
|
||||
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
|
||||
['GET', { uri: 'domains' }]
|
||||
['GET', { uri: 'domains', storeKey: 'domains' }]
|
||||
],
|
||||
search: ''
|
||||
search: '',
|
||||
domainsTree: undefined
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['domains', 'mainDomain']),
|
||||
|
||||
filteredDomains () {
|
||||
if (!this.domains || !this.mainDomain) return
|
||||
const search = this.search.toLowerCase()
|
||||
const mainDomain = this.mainDomain
|
||||
const domains = this.domains
|
||||
.filter(name => name.toLowerCase().includes(search))
|
||||
.sort(prevDomain => prevDomain === mainDomain ? -1 : 1)
|
||||
return domains.length ? domains : null
|
||||
tree () {
|
||||
if (!this.domainsTree) return
|
||||
if (this.search) {
|
||||
const search = this.search.toLowerCase()
|
||||
return this.domainsTree.filter(node => node.data.name.includes(search))
|
||||
}
|
||||
return this.domainsTree
|
||||
},
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -43,7 +43,7 @@
|
|||
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em>
|
||||
</p>
|
||||
</template>
|
||||
<template v-else>
|
||||
<template v-if="groupName == 'admins' || !group.isSpecial">
|
||||
<tags-selectize-item
|
||||
v-model="group.members" :options="usersOptions"
|
||||
:id="groupName + '-users'" :label="$t('group_add_member')"
|
||||
|
@ -173,7 +173,7 @@ export default {
|
|||
continue
|
||||
}
|
||||
|
||||
group.isSpecial = ['visitors', 'all_users'].includes(groupName)
|
||||
group.isSpecial = ['visitors', 'all_users', 'admins'].includes(groupName)
|
||||
|
||||
if (groupName === 'visitors') {
|
||||
// Forbid to add or remove a protected permission on group `visitors`
|
||||
|
@ -189,6 +189,13 @@ export default {
|
|||
}).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
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
|
@ -22,9 +22,10 @@ export default {
|
|||
menu: [
|
||||
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
|
||||
{ routeName: 'tool-migrations', icon: 'share', translation: 'migrations' },
|
||||
{ routeName: 'service-list', icon: 'gears', translation: 'services' },
|
||||
{ routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
|
||||
{ routeName: 'tool-adminpw', icon: 'key-modern', translation: 'tools_adminpw' },
|
||||
{ routeName: 'tool-webadmin', icon: 'cog', translation: 'tools_webadmin_settings' },
|
||||
{ routeName: 'tool-settings', icon: 'sliders', translation: 'tools_yunohost_settings' },
|
||||
{ routeName: 'tool-webadmin', icon: 'sliders', translation: 'tools_webadmin_settings' },
|
||||
{ routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' }
|
||||
]
|
||||
}
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
ref="view" skeleton="card-form-skeleton"
|
||||
>
|
||||
<config-panels v-if="config.panels" v-bind="config" @submit="applyConfig" />
|
||||
<config-panels v-if="config.panels" v-bind="config" @submit="onConfigSubmit" />
|
||||
</view-base>
|
||||
</template>
|
||||
|
||||
|
@ -17,20 +17,18 @@ import ConfigPanels from '@/components/ConfigPanels'
|
|||
|
||||
|
||||
export default {
|
||||
name: 'DomainConfig',
|
||||
name: 'ToolSettingsConfig',
|
||||
|
||||
components: {
|
||||
ConfigPanels
|
||||
},
|
||||
|
||||
props: {
|
||||
name: { type: String, required: true }
|
||||
},
|
||||
props: {},
|
||||
|
||||
data () {
|
||||
return {
|
||||
queries: [
|
||||
['GET', `domains/${this.name}/config?full`]
|
||||
['GET', 'settings?full']
|
||||
],
|
||||
config: {}
|
||||
}
|
||||
|
@ -41,23 +39,21 @@ export default {
|
|||
this.config = formatYunoHostConfigPanels(config)
|
||||
},
|
||||
|
||||
async applyConfig (id_) {
|
||||
const formatedData = await formatFormData(
|
||||
this.config.forms[id_],
|
||||
{ removeEmpty: false, removeNull: true, multipart: false }
|
||||
)
|
||||
async onConfigSubmit ({ id, form }) {
|
||||
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||
|
||||
// FIXME no route for potential action
|
||||
api.put(
|
||||
`domains/${this.name}/config`,
|
||||
{ key: id_, args: objectToParams(formatedData) },
|
||||
{ key: 'domains.update_config', name: this.name }
|
||||
`settings/${id}`,
|
||||
{ args: objectToParams(args) },
|
||||
{ key: 'settings.update', panel: id }
|
||||
).then(() => {
|
||||
this.$refs.view.fetchQueries({ triggerLoading: true })
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
const panel = this.config.panels.find(({ id }) => id_ === id)
|
||||
const panel = this.config.panels.find(panel => panel.id === id)
|
||||
if (err.data.name) {
|
||||
this.config.errors[id_][err.data.name].message = err.message
|
||||
this.config.errors[id][err.data.name].message = err.message
|
||||
} else this.$set(panel, 'serverError', err.message)
|
||||
})
|
||||
}
|
|
@ -60,6 +60,13 @@ export default {
|
|||
label: this.$i18n.t('tools_webadmin.transitions'),
|
||||
component: 'CheckboxItem',
|
||||
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()`
|
||||
|
@ -69,7 +76,7 @@ export default {
|
|||
|
||||
computed: {
|
||||
// Those are set/get computed properties
|
||||
...mapStoreGetSet(['locale', 'fallbackLocale'], 'dispatch'),
|
||||
...mapStoreGetSet(['locale', 'fallbackLocale', 'theme'], 'dispatch'),
|
||||
...mapStoreGetSet(['cache', 'transitions', 'experimental'])
|
||||
},
|
||||
|
||||
|
|
|
@ -9,26 +9,7 @@
|
|||
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
|
||||
|
||||
<!-- USER FULLNAME -->
|
||||
<form-field
|
||||
v-bind="fields.fullname" :validation="$v.form.fullname"
|
||||
>
|
||||
<template #default="{ self }">
|
||||
<b-input-group>
|
||||
<template v-for="fname in ['firstname', 'lastname']">
|
||||
<b-input-group-prepend :key="fname + 'prepend'">
|
||||
<b-input-group-text :id="fname + '-label'" tag="label">
|
||||
{{ self[fname].label }}
|
||||
</b-input-group-text>
|
||||
</b-input-group-prepend>
|
||||
|
||||
<input-item
|
||||
v-bind="self[fname]" v-model="form.fullname[fname]" :key="fname + 'input'"
|
||||
:name="self[fname].id" :aria-labelledby="fname + '-label'"
|
||||
/>
|
||||
</template>
|
||||
</b-input-group>
|
||||
</template>
|
||||
</form-field>
|
||||
<form-field v-bind="fields.fullname" :validation="$v.form.fullname" v-model="form.fullname" />
|
||||
<hr>
|
||||
|
||||
<!-- USER MAIL DOMAIN -->
|
||||
|
@ -82,10 +63,7 @@ export default {
|
|||
|
||||
form: {
|
||||
username: '',
|
||||
fullname: {
|
||||
firstname: '',
|
||||
lastname: ''
|
||||
},
|
||||
fullname: '',
|
||||
domain: '',
|
||||
password: '',
|
||||
confirmation: ''
|
||||
|
@ -104,18 +82,9 @@ export default {
|
|||
|
||||
fullname: {
|
||||
label: this.$i18n.t('user_fullname'),
|
||||
id: 'fullname',
|
||||
props: {
|
||||
firstname: {
|
||||
id: 'firstname',
|
||||
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')
|
||||
}
|
||||
id: 'fullname',
|
||||
placeholder: this.$i18n.t('placeholder.fullname')
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -156,10 +125,7 @@ export default {
|
|||
return {
|
||||
form: {
|
||||
username: { required, alphalownum_, notInUsers: unique(this.userNames) },
|
||||
fullname: {
|
||||
firstname: { required, name },
|
||||
lastname: { required, name }
|
||||
},
|
||||
fullname: { required, name },
|
||||
domain: { required },
|
||||
password: { required, passwordLenght: minLength(8) },
|
||||
confirmation: { required, passwordMatch: sameAs('password') }
|
||||
|
@ -189,10 +155,6 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#lastname-label {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.custom-select {
|
||||
flex-basis: 40%;
|
||||
}
|
||||
|
|
|
@ -8,26 +8,9 @@
|
|||
<!-- USERNAME (disabled) -->
|
||||
<form-field v-bind="fields.username" />
|
||||
|
||||
<!-- USER FULLNAME (FIXME quite a mess, but will be removed)-->
|
||||
<form-field v-bind="fields.fullname" :validation="$v.form.fullname">
|
||||
<template #default="{ self }">
|
||||
<b-input-group>
|
||||
<template v-for="name_ in ['firstname', 'lastname']">
|
||||
<b-input-group-prepend :key="name_ + 'prepend'">
|
||||
<b-input-group-text :id="name_ + '-label'" tag="label">
|
||||
{{ self[name_].label }}
|
||||
</b-input-group-text>
|
||||
</b-input-group-prepend>
|
||||
<!-- USER FULLNAME -->
|
||||
<form-field v-bind="fields.fullname" v-model="form.fullname" :validation="$v.form.fullname" />
|
||||
|
||||
<input-item
|
||||
v-bind="self[name_]" v-model.trim="form.fullname[name_]" :key="name_ + 'input'"
|
||||
:name="self[name_].id" :aria-labelledby="name_ + '-label'"
|
||||
:state="$v.form.fullname[name_].$invalid && $v.form.fullname.$anyDirty ? false : null"
|
||||
/>
|
||||
</template>
|
||||
</b-input-group>
|
||||
</template>
|
||||
</form-field>
|
||||
<hr>
|
||||
|
||||
<!-- USER EMAIL -->
|
||||
|
@ -137,7 +120,7 @@ export default {
|
|||
],
|
||||
|
||||
form: {
|
||||
fullname: { firstname: '', lastname: '' },
|
||||
fullname: '',
|
||||
mail: { localPart: '', separator: '@', domain: '' },
|
||||
mailbox_quota: '',
|
||||
mail_aliases: [],
|
||||
|
@ -157,18 +140,9 @@ export default {
|
|||
|
||||
fullname: {
|
||||
label: this.$i18n.t('user_fullname'),
|
||||
id: 'fullname',
|
||||
props: {
|
||||
firstname: {
|
||||
id: 'firstname',
|
||||
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')
|
||||
}
|
||||
id: 'fullname',
|
||||
placeholder: this.$i18n.t('placeholder.fullname')
|
||||
}
|
||||
},
|
||||
|
||||
|
@ -220,10 +194,7 @@ export default {
|
|||
|
||||
validations: {
|
||||
form: {
|
||||
fullname: {
|
||||
firstname: { required, name },
|
||||
lastname: { required, name }
|
||||
},
|
||||
fullname: { required, name },
|
||||
mail: {
|
||||
localPart: { required, email: emailLocalPart }
|
||||
},
|
||||
|
@ -246,11 +217,7 @@ export default {
|
|||
this.fields.mail.props.choices = this.domainsAsChoices
|
||||
this.fields.mail_aliases.props.choices = this.domainsAsChoices
|
||||
|
||||
this.form.fullname = {
|
||||
// Copy value to avoid refering to the stored user data
|
||||
firstname: user.firstname.valueOf(),
|
||||
lastname: user.lastname.valueOf()
|
||||
}
|
||||
this.form.fullname = user.fullname
|
||||
this.form.mail = adressToFormValue(user.mail)
|
||||
if (user['mail-aliases']) {
|
||||
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
|
||||
|
@ -334,10 +301,6 @@ export default {
|
|||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
#lastname-label {
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.mail-list {
|
||||
display: flex;
|
||||
justify-items: stretch;
|
||||
|
|
Loading…
Add table
Reference in a new issue