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"
|
:to="{ name: 'home' }" :disabled="waiting"
|
||||||
exact exact-active-class="active"
|
exact exact-active-class="active"
|
||||||
>
|
>
|
||||||
<img alt="Yunohost logo" src="./assets/logo.png">
|
<span v-if="theme">
|
||||||
|
<img alt="YunoHost logo" src="./assets/logo_light.png" width="40">
|
||||||
|
</span>
|
||||||
|
<span v-else>
|
||||||
|
<img alt="YunoHost logo" src="./assets/logo_dark.png" width="40">
|
||||||
|
</span>
|
||||||
</b-navbar-brand>
|
</b-navbar-brand>
|
||||||
|
|
||||||
<b-navbar-nav class="ml-auto">
|
<b-navbar-nav class="ml-auto">
|
||||||
|
@ -93,7 +98,8 @@ export default {
|
||||||
'routerKey',
|
'routerKey',
|
||||||
'transitions',
|
'transitions',
|
||||||
'transitionName',
|
'transitionName',
|
||||||
'waiting'
|
'waiting',
|
||||||
|
'theme'
|
||||||
])
|
])
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -153,6 +159,8 @@ export default {
|
||||||
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
|
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
|
||||||
this.$store.commit('SET_SPINNER', 'spookycat')
|
this.$store.commit('SET_SPINNER', 'spookycat')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
document.documentElement.setAttribute('dark-theme', localStorage.getItem('theme')) // updates the data-theme attribute
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
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>
|
<template>
|
||||||
<abstract-form
|
<abstract-form
|
||||||
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }"
|
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }"
|
||||||
@submit.prevent.stop="$emit('submit', panel.id)"
|
@submit.prevent.stop="onApply"
|
||||||
|
:no-footer="!panel.hasApplyButton"
|
||||||
>
|
>
|
||||||
<slot name="tab-top" />
|
<slot name="tab-top" />
|
||||||
|
|
||||||
|
@ -12,18 +13,24 @@
|
||||||
<slot name="tab-before" />
|
<slot name="tab-before" />
|
||||||
|
|
||||||
<template v-for="section in panel.sections">
|
<template v-for="section in panel.sections">
|
||||||
<div v-if="isVisible(section.visible, section)" :key="section.id" class="mb-5">
|
<component
|
||||||
|
v-if="section.visible"
|
||||||
|
:is="section.name ? 'section' : 'div'"
|
||||||
|
:key="section.id"
|
||||||
|
class="panel-section"
|
||||||
|
>
|
||||||
<b-card-title v-if="section.name" title-tag="h3">
|
<b-card-title v-if="section.name" title-tag="h3">
|
||||||
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
|
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
|
||||||
</b-card-title>
|
</b-card-title>
|
||||||
|
|
||||||
<template v-for="(field, fname) in section.fields">
|
<template v-for="(field, fname) in section.fields">
|
||||||
<form-field
|
<component
|
||||||
v-if="isVisible(field.visible, field)" :key="fname"
|
v-if="field.visible" :is="field.is" v-bind="field.props"
|
||||||
v-model="forms[panel.id][fname]" v-bind="field" :validation="validation[fname]"
|
v-model="forms[panel.id][fname]" :validation="validation[fname]" :key="fname"
|
||||||
|
@action.stop="onAction(section.id, fname, section.fields)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<slot name="tab-after" />
|
<slot name="tab-after" />
|
||||||
|
@ -31,7 +38,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { configPanelsFieldIsVisible } from '@/helpers/yunohostArguments'
|
import { filterObject } from '@/helpers/commons'
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
|
@ -55,8 +62,25 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
isVisible (expression, field) {
|
onApply () {
|
||||||
return configPanelsFieldIsVisible(expression, field, this.forms)
|
const panelId = this.panel.id
|
||||||
|
|
||||||
|
this.$emit('submit', {
|
||||||
|
id: panelId,
|
||||||
|
form: this.forms[panelId]
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
onAction (sectionId, actionId, actionFields) {
|
||||||
|
const panelId = this.panel.id
|
||||||
|
const actionFieldsKeys = Object.keys(actionFields)
|
||||||
|
|
||||||
|
this.$emit('submit', {
|
||||||
|
id: panelId,
|
||||||
|
form: filterObject(this.forms[panelId], ([key]) => actionFieldsKeys.includes(key)),
|
||||||
|
action: [panelId, sectionId, actionId].join('.'),
|
||||||
|
name: actionId
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -65,6 +89,9 @@ export default {
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card-title {
|
.card-title {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
border-bottom: solid 1px #aaa;
|
border-bottom: solid $border-width $gray-500;
|
||||||
|
}
|
||||||
|
::v-deep .panel-section:not(:last-child) {
|
||||||
|
margin-bottom: 3rem;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,9 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<routable-tabs
|
<routable-tabs
|
||||||
:routes="routes_"
|
:routes="routes_"
|
||||||
v-bind="{ panels, forms, v: $v }"
|
v-bind="{ panels, forms, v: $v, ...$attrs }"
|
||||||
v-on="$listeners"
|
v-on="$listeners"
|
||||||
/>
|
>
|
||||||
|
<slot name="tab-top" slot="tab-top"></slot>
|
||||||
|
<slot name="tab-before" slot="tab-before"></slot>
|
||||||
|
<slot name="tab-after" slot="tab-after"></slot>
|
||||||
|
</routable-tabs>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
@ -49,10 +53,3 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.card-title {
|
|
||||||
margin-bottom: 1em;
|
|
||||||
border-bottom: solid 1px #aaa;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
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>
|
</b-card-header>
|
||||||
|
|
||||||
<!-- Bind extra props to the child view and forward child events to parent -->
|
<!-- Bind extra props to the child view and forward child events to parent -->
|
||||||
<router-view v-bind="$attrs" v-on="$listeners" />
|
<router-view v-bind="$attrs" v-on="$listeners">
|
||||||
|
<slot name="tab-top" slot="tab-top"></slot>
|
||||||
|
<slot name="tab-before" slot="tab-before"></slot>
|
||||||
|
<slot name="tab-after" slot="tab-after"></slot>
|
||||||
|
</router-view>
|
||||||
</b-card>
|
</b-card>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -6,6 +6,7 @@
|
||||||
<component :is="titleTag" class="custom-header-title">
|
<component :is="titleTag" class="custom-header-title">
|
||||||
<icon v-if="icon" :iname="icon" class="mr-2" />{{ title }}
|
<icon v-if="icon" :iname="icon" class="mr-2" />{{ title }}
|
||||||
</component>
|
</component>
|
||||||
|
<slot name="header-next" />
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }">
|
<div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }">
|
||||||
|
|
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 -->
|
<!-- Render description -->
|
||||||
<template v-if="description || link">
|
<template v-if="description || link">
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<b-link v-if="link" :to="link" :href="link.href"
|
<b-link
|
||||||
class="ml-auto"
|
v-if="link"
|
||||||
|
:to="link" :href="link.href" class="ml-auto"
|
||||||
>
|
>
|
||||||
{{ link.text }}
|
{{ link.text }}
|
||||||
</b-link>
|
</b-link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<vue-showdown :markdown="description" flavor="github" v-if="description"
|
<vue-showdown
|
||||||
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
|
v-if="description"
|
||||||
|
:markdown="description" flavor="github"
|
||||||
|
:class="{ ['alert p-1 px-2 alert-' + descriptionVariant]: descriptionVariant }"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
<!-- Slot available to overwrite the one above -->
|
<!-- Slot available to overwrite the one above -->
|
||||||
|
@ -76,8 +79,8 @@ export default {
|
||||||
if ('label' in attrs) {
|
if ('label' in attrs) {
|
||||||
const defaultAttrs = {
|
const defaultAttrs = {
|
||||||
'label-cols-md': 4,
|
'label-cols-md': 4,
|
||||||
'label-cols-lg': 2,
|
'label-cols-lg': 3,
|
||||||
'label-class': 'font-weight-bold'
|
'label-class': ['font-weight-bold', 'py-0']
|
||||||
}
|
}
|
||||||
if (!('label-cols' in attrs)) {
|
if (!('label-cols' in attrs)) {
|
||||||
for (const attr in defaultAttrs) {
|
for (const attr in defaultAttrs) {
|
||||||
|
|
|
@ -1,12 +1,13 @@
|
||||||
<template>
|
<template>
|
||||||
<span :class="'icon fa fa-' + iname" aria-hidden="true" />
|
<span :class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']" aria-hidden="true" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
export default {
|
export default {
|
||||||
name: 'Icon',
|
name: 'Icon',
|
||||||
props: {
|
props: {
|
||||||
iname: { type: String, required: true }
|
iname: { type: String, required: true },
|
||||||
|
variant: { type: String, default: null }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
@ -26,5 +27,21 @@ export default {
|
||||||
&.fs-sm {
|
&.fs-sm {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.variant {
|
||||||
|
font-size: .8rem;
|
||||||
|
width: 1.35rem;
|
||||||
|
min-width: 1.35rem;
|
||||||
|
height: 1.35rem;
|
||||||
|
line-height: 165%;
|
||||||
|
border-radius: 50rem;
|
||||||
|
|
||||||
|
@each $color, $value in $theme-colors {
|
||||||
|
&.#{$color} {
|
||||||
|
background-color: $value;
|
||||||
|
color: color-yiq($value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
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>
|
<template>
|
||||||
<b-button-group class="w-100">
|
<b-button-group class="w-100">
|
||||||
<b-button @click="clearFiles" variant="danger" v-if="!this.required && this.value !== null && !this.value._removed">
|
<b-button
|
||||||
|
v-if="!this.required && this.value.file !== null"
|
||||||
|
@click="clearFiles" variant="danger"
|
||||||
|
>
|
||||||
|
<span class="sr-only">{{ $t('delete') }}</span>
|
||||||
<icon iname="trash" />
|
<icon iname="trash" />
|
||||||
</b-button>
|
</b-button>
|
||||||
|
|
||||||
<b-form-file
|
<b-form-file
|
||||||
v-model="file"
|
:value="value.file"
|
||||||
ref="input-file"
|
ref="input-file"
|
||||||
:id="id"
|
:id="id"
|
||||||
v-on="$listeners"
|
|
||||||
:required="required"
|
:required="required"
|
||||||
:placeholder="_placeholder"
|
:placeholder="_placeholder"
|
||||||
:accept="accept"
|
:accept="accept"
|
||||||
:drop-placeholder="dropPlaceholder"
|
:drop-placeholder="dropPlaceholder"
|
||||||
:state="state"
|
:state="state"
|
||||||
:browse-text="$t('words.browse')"
|
:browse-text="$t('words.browse')"
|
||||||
|
@input="onInput"
|
||||||
@blur="$parent.$emit('touch', name)"
|
@blur="$parent.$emit('touch', name)"
|
||||||
@focusout.native="$parent.$emit('touch', name)"
|
@focusout.native="$parent.$emit('touch', name)"
|
||||||
/>
|
/>
|
||||||
|
@ -21,18 +26,14 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { getFileContent } from '@/helpers/commons'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'FileItem',
|
name: 'FileItem',
|
||||||
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
file: this.value
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
id: { type: String, default: null },
|
id: { type: String, default: null },
|
||||||
value: { type: [File, null], default: null },
|
value: { type: Object, default: () => ({ file: null }) },
|
||||||
placeholder: { type: String, default: 'Choose a file or drop it here...' },
|
placeholder: { type: String, default: 'Choose a file or drop it here...' },
|
||||||
dropPlaceholder: { type: String, default: null },
|
dropPlaceholder: { type: String, default: null },
|
||||||
accept: { type: String, default: null },
|
accept: { type: String, default: null },
|
||||||
|
@ -43,22 +44,35 @@ export default {
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
_placeholder: function () {
|
_placeholder: function () {
|
||||||
return (this.value === null) ? this.placeholder : this.value.name
|
return this.value.file === null ? this.placeholder : this.value.file.name
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
clearFiles () {
|
onInput (file) {
|
||||||
const f = new File([''], this.placeholder)
|
const value = {
|
||||||
f._removed = true
|
file,
|
||||||
if (this.value && this.value.currentfile) {
|
content: '',
|
||||||
this.$refs['input-file'].reset()
|
current: false,
|
||||||
this.$emit('input', f)
|
removed: false
|
||||||
} else {
|
|
||||||
this.$refs['input-file'].setFiles([f])
|
|
||||||
this.file = f
|
|
||||||
this.$emit('input', f)
|
|
||||||
}
|
}
|
||||||
|
// Update the value with the new File and an empty content for now
|
||||||
|
this.$emit('input', value)
|
||||||
|
|
||||||
|
// Asynchronously load the File content and update the value again
|
||||||
|
getFileContent(file).then(content => {
|
||||||
|
this.$emit('input', { ...value, content })
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
clearFiles () {
|
||||||
|
this.$refs['input-file'].reset()
|
||||||
|
this.$emit('input', {
|
||||||
|
file: null,
|
||||||
|
content: '',
|
||||||
|
current: false,
|
||||||
|
removed: true
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,7 +2,6 @@
|
||||||
<b-input
|
<b-input
|
||||||
:value="value"
|
:value="value"
|
||||||
:id="id"
|
:id="id"
|
||||||
v-on="$listeners"
|
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:type="type"
|
:type="type"
|
||||||
:state="state"
|
:state="state"
|
||||||
|
@ -12,6 +11,7 @@
|
||||||
:step="step"
|
:step="step"
|
||||||
:trim="trim"
|
:trim="trim"
|
||||||
:autocomplete="autocomplete_"
|
:autocomplete="autocomplete_"
|
||||||
|
v-on="$listeners"
|
||||||
@blur="$parent.$emit('touch', name)"
|
@blur="$parent.$emit('touch', name)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
@ -21,11 +21,6 @@
|
||||||
export default {
|
export default {
|
||||||
name: 'InputItem',
|
name: 'InputItem',
|
||||||
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
props: {
|
props: {
|
||||||
value: { type: [String, Number], default: null },
|
value: { type: [String, Number], default: null },
|
||||||
id: { type: String, default: null },
|
id: { type: String, default: null },
|
||||||
|
@ -40,6 +35,12 @@ export default {
|
||||||
autocomplete: { type: String, default: null },
|
autocomplete: { type: String, default: null },
|
||||||
pattern: { type: Object, default: null },
|
pattern: { type: Object, default: null },
|
||||||
name: { type: String, default: null }
|
name: { type: String, default: null }
|
||||||
|
},
|
||||||
|
|
||||||
|
data () {
|
||||||
|
return {
|
||||||
|
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -12,4 +12,3 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
<template>
|
<template>
|
||||||
<b-alert class="d-flex" :variant="type" show>
|
<b-alert class="d-flex flex-column flex-md-row align-items-center" :variant="type" show>
|
||||||
<icon :iname="icon_" class="mr-1 mt-1" />
|
<icon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" />
|
||||||
<vue-showdown :markdown="label" flavor="github"
|
|
||||||
tag="span" class="markdown"
|
<vue-showdown
|
||||||
|
:markdown="label" flavor="github"
|
||||||
|
tag="span" class="markdown"
|
||||||
/>
|
/>
|
||||||
</b-alert>
|
</b-alert>
|
||||||
</template>
|
</template>
|
||||||
|
@ -11,29 +13,23 @@
|
||||||
export default {
|
export default {
|
||||||
name: 'ReadOnlyAlertItem',
|
name: 'ReadOnlyAlertItem',
|
||||||
|
|
||||||
data () {
|
|
||||||
const icons = {
|
|
||||||
success: 'thumbs-up',
|
|
||||||
info: 'info-circle',
|
|
||||||
warning: 'warning',
|
|
||||||
danger: 'times'
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
icon_: (this.icon) ? this.icon : icons[this.type]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
id: { type: String, default: null },
|
id: { type: String, default: null },
|
||||||
label: { type: String, default: null },
|
label: { type: String, default: null },
|
||||||
type: { type: String, default: null },
|
type: { type: String, default: null },
|
||||||
icon: { type: String, default: null }
|
icon: { type: String, default: null }
|
||||||
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
icon_ () {
|
||||||
|
const icons = {
|
||||||
|
success: 'thumbs-up',
|
||||||
|
info: 'info',
|
||||||
|
warning: 'exclamation',
|
||||||
|
danger: 'times'
|
||||||
|
}
|
||||||
|
return this.icon || icons[this.type]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss">
|
|
||||||
.alert p:last-child {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -12,7 +12,6 @@
|
||||||
@remove="onRemoveTag({ option: tag, removeTag })"
|
@remove="onRemoveTag({ option: tag, removeTag })"
|
||||||
:title="tag"
|
:title="tag"
|
||||||
:disabled="disabled || disabledItems.includes(tag)"
|
:disabled="disabled || disabledItems.includes(tag)"
|
||||||
variant="light"
|
|
||||||
class="border border-dark mb-2"
|
class="border border-dark mb-2"
|
||||||
>
|
>
|
||||||
<icon v-if="tagIcon" :iname="tagIcon" /> {{ tag }}
|
<icon v-if="tagIcon" :iname="tagIcon" /> {{ tag }}
|
||||||
|
@ -151,7 +150,7 @@ export default {
|
||||||
padding-top: .5rem;
|
padding-top: .5rem;
|
||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
background-color: white;
|
background-color: $white;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<b-form-textarea
|
<b-form-textarea
|
||||||
v-model="value"
|
:value="value"
|
||||||
:id="id"
|
:id="id"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:required="required"
|
:required="required"
|
||||||
|
|
|
@ -63,6 +63,19 @@ export function flattenObjectLiteral (obj, flattened = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns an new Object filtered with passed filter function.
|
||||||
|
* Each entry `[key, value]` will be forwarded to the `filter` function.
|
||||||
|
*
|
||||||
|
* @param {Object} obj - object to filter.
|
||||||
|
* @param {Function} filter - the filter function to call for each entry.
|
||||||
|
* @return {Object}
|
||||||
|
*/
|
||||||
|
export function filterObject (obj, filter) {
|
||||||
|
return Object.fromEntries(Object.entries(obj).filter((...args) => filter(...args)))
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an new array containing items that are in first array but not in the other.
|
* Returns an new array containing items that are in first array but not in the other.
|
||||||
*
|
*
|
||||||
|
@ -100,3 +113,26 @@ export function escapeHtml (unsafe) {
|
||||||
export function randint (min, max) {
|
export function randint (min, max) {
|
||||||
return Math.floor(Math.random() * (max - min + 1)) + min
|
return Math.floor(Math.random() * (max - min + 1)) + min
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a File content.
|
||||||
|
*
|
||||||
|
* @param {File} file
|
||||||
|
* @param {Object} [extraParams] - Optionnal params
|
||||||
|
* @param {Boolean} [extraParams.base64] - returns a base64 representation of the file.
|
||||||
|
* @return {Promise<String>}
|
||||||
|
*/
|
||||||
|
export function getFileContent (file, { base64 = false } = {}) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onerror = reject
|
||||||
|
reader.onload = () => resolve(reader.result)
|
||||||
|
|
||||||
|
if (base64) {
|
||||||
|
reader.readAsDataURL(file)
|
||||||
|
} else {
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
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 store from '@/store'
|
||||||
import evaluate from 'simple-evaluate'
|
import evaluate from 'simple-evaluate'
|
||||||
import * as validators from '@/helpers/validators'
|
import * as validators from '@/helpers/validators'
|
||||||
import { isObjectLiteral, isEmptyValue, flattenObjectLiteral } from '@/helpers/commons'
|
import {
|
||||||
|
isObjectLiteral,
|
||||||
|
isEmptyValue,
|
||||||
|
flattenObjectLiteral,
|
||||||
|
getFileContent
|
||||||
|
} from '@/helpers/commons'
|
||||||
|
|
||||||
|
|
||||||
|
const NO_VALUE_FIELDS = [
|
||||||
|
'ReadOnlyField',
|
||||||
|
'ReadOnlyAlertItem',
|
||||||
|
'MarkdownItem',
|
||||||
|
'DisplayTextItem',
|
||||||
|
'ButtonItem'
|
||||||
|
]
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Tries to find a translation corresponding to the user's locale/fallback locale in a
|
* Tries to find a translation corresponding to the user's locale/fallback locale in a
|
||||||
* Yunohost argument or simply return the string if it's not an object literal.
|
* Yunohost argument or simply return the string if it's not an object literal.
|
||||||
|
@ -49,6 +62,49 @@ export function adressToFormValue (address) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Evaluate config panel string expression that can contain regular expressions.
|
||||||
|
* Expression are evaluated with the config panel form as context.
|
||||||
|
*
|
||||||
|
* @param {String} expression - A String to evaluate.
|
||||||
|
* @param {Object} forms - A nested form used in config panels.
|
||||||
|
* @return {Boolean} - expression evaluation result.
|
||||||
|
*/
|
||||||
|
export function evaluateExpression (expression, forms) {
|
||||||
|
if (!expression) return true
|
||||||
|
if (expression === '"false"') return false
|
||||||
|
|
||||||
|
const context = Object.values(forms).reduce((ctx, args) => {
|
||||||
|
Object.entries(args).forEach(([name, value]) => {
|
||||||
|
ctx[name] = isObjectLiteral(value) && 'file' in value ? value.content : value
|
||||||
|
})
|
||||||
|
return ctx
|
||||||
|
}, {})
|
||||||
|
|
||||||
|
// Allow to use match(var,regexp) function
|
||||||
|
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
|
||||||
|
for (const matched of expression.matchAll(matchRe)) {
|
||||||
|
const [fullMatch, varMatch, regExpMatch] = matched
|
||||||
|
const varName = varMatch + '__re' + matched.index
|
||||||
|
context[varName] = new RegExp(regExpMatch, 'm').test(context[varMatch])
|
||||||
|
expression = expression.replace(fullMatch, varName)
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return !!evaluate(context, expression)
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Adds a property to an Object that will dynamically returns a expression evaluation result.
|
||||||
|
function addEvaluationGetter (prop, obj, expr, ctx) {
|
||||||
|
Object.defineProperty(obj, prop, {
|
||||||
|
get: () => evaluateExpression(expr, ctx)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format app install, actions and config panel argument into a data structure that
|
* Format app install, actions and config panel argument into a data structure that
|
||||||
* will be automaticly transformed into a component on screen.
|
* will be automaticly transformed into a component on screen.
|
||||||
|
@ -62,22 +118,21 @@ export function formatYunoHostArgument (arg) {
|
||||||
const error = { message: null }
|
const error = { message: null }
|
||||||
arg.ask = formatI18nField(arg.ask)
|
arg.ask = formatI18nField(arg.ask)
|
||||||
const field = {
|
const field = {
|
||||||
component: undefined,
|
is: arg.readonly ? 'ReadOnlyField' : 'FormField',
|
||||||
label: arg.ask,
|
visible: [undefined, true, '"true"'].includes(arg.visible),
|
||||||
props: {}
|
props: {
|
||||||
|
label: arg.ask,
|
||||||
|
component: undefined,
|
||||||
|
props: {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultProps = ['id:name', 'placeholder:example']
|
const defaultProps = ['id:name', 'placeholder:example']
|
||||||
const components = [
|
const components = [
|
||||||
{
|
{
|
||||||
types: [undefined, 'string', 'path'],
|
types: ['string', 'path'],
|
||||||
name: 'InputItem',
|
name: 'InputItem',
|
||||||
props: defaultProps.concat(['autocomplete', 'trim', 'choices']),
|
props: defaultProps.concat(['autocomplete', 'trim', 'choices'])
|
||||||
callback: function () {
|
|
||||||
if (arg.choices && Object.keys(arg.choices).length) {
|
|
||||||
arg.type = 'select'
|
|
||||||
this.name = 'SelectItem'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
types: ['email', 'url', 'date', 'time', 'color'],
|
types: ['email', 'url', 'date', 'time', 'color'],
|
||||||
|
@ -90,7 +145,7 @@ export function formatYunoHostArgument (arg) {
|
||||||
props: defaultProps.concat(['type', 'autocomplete', 'trim']),
|
props: defaultProps.concat(['type', 'autocomplete', 'trim']),
|
||||||
callback: function () {
|
callback: function () {
|
||||||
if (!arg.help) {
|
if (!arg.help) {
|
||||||
arg.help = 'good_practices_about_admin_password'
|
arg.help = i18n.t('good_practices_about_admin_password')
|
||||||
}
|
}
|
||||||
arg.example = '••••••••••••'
|
arg.example = '••••••••••••'
|
||||||
validation.passwordLenght = validators.minLength(8)
|
validation.passwordLenght = validators.minLength(8)
|
||||||
|
@ -111,13 +166,13 @@ export function formatYunoHostArgument (arg) {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
types: ['select', 'user', 'domain', 'app'],
|
types: ['select', 'user', 'domain', 'app', 'group'],
|
||||||
name: 'SelectItem',
|
name: 'SelectItem',
|
||||||
props: ['id:name', 'choices'],
|
props: ['id:name', 'choices'],
|
||||||
callback: function () {
|
callback: function () {
|
||||||
if ((arg.type !== 'select')) {
|
if (arg.type !== 'select') {
|
||||||
field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
|
field.props.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -125,9 +180,12 @@ export function formatYunoHostArgument (arg) {
|
||||||
name: 'FileItem',
|
name: 'FileItem',
|
||||||
props: defaultProps.concat(['accept']),
|
props: defaultProps.concat(['accept']),
|
||||||
callback: function () {
|
callback: function () {
|
||||||
if (value) {
|
value = {
|
||||||
value = new File([''], value)
|
// in case of already defined file, we receive only the file path (not the actual file)
|
||||||
value.currentfile = true
|
file: value ? new File([''], value) : null,
|
||||||
|
content: '',
|
||||||
|
current: !!value,
|
||||||
|
removed: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
@ -141,11 +199,13 @@ export function formatYunoHostArgument (arg) {
|
||||||
name: 'TagsItem',
|
name: 'TagsItem',
|
||||||
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
|
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
|
||||||
callback: function () {
|
callback: function () {
|
||||||
if (arg.choices) {
|
if (arg.choices && arg.choices.length) {
|
||||||
this.name = 'TagsSelectizeItem'
|
this.name = 'TagsSelectizeItem'
|
||||||
field.props.auto = true
|
Object.assign(field.props.props, {
|
||||||
field.props.itemsName = ''
|
auto: true,
|
||||||
field.props.label = arg.placeholder
|
itemsName: '',
|
||||||
|
label: arg.placeholder
|
||||||
|
})
|
||||||
}
|
}
|
||||||
if (typeof value === 'string') {
|
if (typeof value === 'string') {
|
||||||
value = value.split(',')
|
value = value.split(',')
|
||||||
|
@ -170,53 +230,67 @@ export function formatYunoHostArgument (arg) {
|
||||||
types: ['alert'],
|
types: ['alert'],
|
||||||
name: 'ReadOnlyAlertItem',
|
name: 'ReadOnlyAlertItem',
|
||||||
props: ['type:style', 'label:ask', 'icon'],
|
props: ['type:style', 'label:ask', 'icon'],
|
||||||
readonly: true
|
renderSelf: true
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
types: ['markdown', 'display_text'],
|
types: ['markdown'],
|
||||||
name: 'MarkdownItem',
|
name: 'MarkdownItem',
|
||||||
props: ['label:ask'],
|
props: ['label:ask'],
|
||||||
readonly: true
|
renderSelf: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
types: ['display_text'],
|
||||||
|
name: 'DisplayTextItem',
|
||||||
|
props: ['label:ask'],
|
||||||
|
renderSelf: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
types: ['button'],
|
||||||
|
name: 'ButtonItem',
|
||||||
|
props: ['type:style', 'label:ask', 'icon', 'enabled'],
|
||||||
|
renderSelf: true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
// Default type management if no one is filled
|
// Default type management if no one is filled
|
||||||
if (arg.type === undefined) {
|
if (arg.type === undefined) {
|
||||||
arg.type = (arg.choices === undefined) ? 'string' : 'select'
|
arg.type = arg.choices && arg.choices.length ? 'select' : 'string'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Search the component bind to the type
|
// Search the component bind to the type
|
||||||
const component = components.find(element => element.types.includes(arg.type))
|
const component = components.find(element => element.types.includes(arg.type))
|
||||||
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
|
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
|
||||||
|
|
||||||
// Callback use for specific behaviour
|
// Callback use for specific behaviour
|
||||||
if (component.callback) component.callback()
|
if (component.callback) component.callback()
|
||||||
field.component = component.name
|
field.props.component = component.name
|
||||||
// Affect properties to the field Item
|
// Affect properties to the field Item
|
||||||
for (let prop of component.props) {
|
for (let prop of component.props) {
|
||||||
prop = prop.split(':')
|
prop = prop.split(':')
|
||||||
const propName = prop[0]
|
const propName = prop[0]
|
||||||
const argName = prop.slice(-1)[0]
|
const argName = prop.slice(-1)[0]
|
||||||
if (argName in arg) {
|
if (argName in arg) {
|
||||||
field.props[propName] = arg[argName]
|
field.props.props[propName] = arg[argName]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// We don't want to display a label html item as this kind or field contains
|
|
||||||
// already the text to display
|
|
||||||
if (component.readonly) delete field.label
|
|
||||||
// Required (no need for checkbox its value can't be null)
|
// Required (no need for checkbox its value can't be null)
|
||||||
else if (field.component !== 'CheckboxItem' && arg.optional !== true) {
|
if (!component.renderSelf && arg.type !== 'boolean' && arg.optional !== true) {
|
||||||
validation.required = validators.required
|
validation.required = validators.required
|
||||||
}
|
}
|
||||||
if (arg.pattern && arg.type !== 'tags') {
|
if (arg.pattern && arg.type !== 'tags') {
|
||||||
validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp))
|
validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp))
|
||||||
}
|
}
|
||||||
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`
|
// Default value if still `null`
|
||||||
if (value === null && arg.current_value) {
|
if (value === null && arg.current_value) {
|
||||||
value = arg.current_value
|
value = arg.current_value
|
||||||
|
@ -227,18 +301,17 @@ export function formatYunoHostArgument (arg) {
|
||||||
|
|
||||||
// Help message
|
// Help message
|
||||||
if (arg.help) {
|
if (arg.help) {
|
||||||
field.description = formatI18nField(arg.help)
|
field.props.description = formatI18nField(arg.help)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Help message
|
// Help message
|
||||||
if (arg.helpLink) {
|
if (arg.helpLink) {
|
||||||
field.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
|
field.props.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (arg.visible) {
|
if (component.renderSelf) {
|
||||||
field.visible = arg.visible
|
field.is = field.props.component
|
||||||
// Temporary value to wait visible expression to be evaluated
|
field.props = field.props.props
|
||||||
field.isVisible = true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -256,30 +329,29 @@ export function formatYunoHostArgument (arg) {
|
||||||
* as v-model values, fields that can be passed to a FormField component and validations.
|
* as v-model values, fields that can be passed to a FormField component and validations.
|
||||||
*
|
*
|
||||||
* @param {Array} args - a yunohost arg array written by a packager.
|
* @param {Array} args - a yunohost arg array written by a packager.
|
||||||
* @param {String} name - (temp) an app name to build a label field in case of manifest install args
|
* @param {Object|null} forms - nested form used as the expression evualuations context.
|
||||||
* @return {Object} an object containing all parsed values to be used in vue views.
|
* @return {Object} an object containing all parsed values to be used in vue views.
|
||||||
*/
|
*/
|
||||||
export function formatYunoHostArguments (args, name = null) {
|
export function formatYunoHostArguments (args, forms) {
|
||||||
const form = {}
|
const form = {}
|
||||||
const fields = {}
|
const fields = {}
|
||||||
const validations = {}
|
const validations = {}
|
||||||
const errors = {}
|
const errors = {}
|
||||||
|
|
||||||
// FIXME yunohost should add the label field by default
|
|
||||||
if (name) {
|
|
||||||
args.unshift({
|
|
||||||
ask: i18n.t('label_for_manifestname', { name }),
|
|
||||||
default: name,
|
|
||||||
name: 'label'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const arg of args) {
|
for (const arg of args) {
|
||||||
const { value, field, validation, error } = formatYunoHostArgument(arg)
|
const { value, field, validation, error } = formatYunoHostArgument(arg)
|
||||||
fields[arg.name] = field
|
fields[arg.name] = field
|
||||||
form[arg.name] = value
|
form[arg.name] = value
|
||||||
if (validation) validations[arg.name] = validation
|
if (validation) validations[arg.name] = validation
|
||||||
errors[arg.name] = error
|
errors[arg.name] = error
|
||||||
|
|
||||||
|
if ('visible' in arg && ![false, '"false"'].includes(arg.visible)) {
|
||||||
|
addEvaluationGetter('visible', field, arg.visible, forms)
|
||||||
|
}
|
||||||
|
|
||||||
|
if ('enabled' in arg) {
|
||||||
|
addEvaluationGetter('enabled', field.props, arg.enabled, forms)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { form, fields, validations, errors }
|
return { form, fields, validations, errors }
|
||||||
|
@ -295,7 +367,7 @@ export function formatYunoHostConfigPanels (data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const { id: panelId, name, help, sections } of data.panels) {
|
for (const { id: panelId, name, help, sections } of data.panels) {
|
||||||
const panel = { id: panelId, sections: [], serverError: '' }
|
const panel = { id: panelId, sections: [], serverError: '', hasApplyButton: false }
|
||||||
result.forms[panelId] = {}
|
result.forms[panelId] = {}
|
||||||
result.validations[panelId] = {}
|
result.validations[panelId] = {}
|
||||||
result.errors[panelId] = {}
|
result.errors[panelId] = {}
|
||||||
|
@ -303,17 +375,34 @@ export function formatYunoHostConfigPanels (data) {
|
||||||
if (name) panel.name = formatI18nField(name)
|
if (name) panel.name = formatI18nField(name)
|
||||||
if (help) panel.help = formatI18nField(help)
|
if (help) panel.help = formatI18nField(help)
|
||||||
|
|
||||||
for (const { id: sectionId, name, help, visible, options } of sections) {
|
for (const _section of sections) {
|
||||||
const section = { id: sectionId, visible, isVisible: false }
|
const section = {
|
||||||
if (help) section.help = formatI18nField(help)
|
id: _section.id,
|
||||||
if (name) section.name = formatI18nField(name)
|
isActionSection: _section.is_action_section,
|
||||||
const { form, fields, validations, errors } = formatYunoHostArguments(options)
|
visible: [undefined, true, '"true"'].includes(_section.visible)
|
||||||
|
}
|
||||||
|
if (_section.help) section.help = formatI18nField(_section.help)
|
||||||
|
if (_section.name) section.name = formatI18nField(_section.name)
|
||||||
|
if (_section.visible && ![false, '"false"'].includes(_section.visible)) {
|
||||||
|
addEvaluationGetter('visible', section, _section.visible, result.forms)
|
||||||
|
}
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
fields,
|
||||||
|
validations,
|
||||||
|
errors
|
||||||
|
} = formatYunoHostArguments(_section.options, result.forms)
|
||||||
// Merge all sections forms to the panel to get a unique form
|
// Merge all sections forms to the panel to get a unique form
|
||||||
Object.assign(result.forms[panelId], form)
|
Object.assign(result.forms[panelId], form)
|
||||||
Object.assign(result.validations[panelId], validations)
|
Object.assign(result.validations[panelId], validations)
|
||||||
Object.assign(result.errors[panelId], errors)
|
Object.assign(result.errors[panelId], errors)
|
||||||
section.fields = fields
|
section.fields = fields
|
||||||
panel.sections.push(section)
|
panel.sections.push(section)
|
||||||
|
|
||||||
|
if (!section.isActionSection && Object.values(fields).some((field) => !NO_VALUE_FIELDS.includes(field.is))) {
|
||||||
|
panel.hasApplyButton = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
result.panels.push(panel)
|
result.panels.push(panel)
|
||||||
|
@ -323,80 +412,65 @@ export function formatYunoHostConfigPanels (data) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function configPanelsFieldIsVisible (expression, field, forms) {
|
|
||||||
if (!expression || !field) return true
|
|
||||||
const context = {}
|
|
||||||
|
|
||||||
const promises = []
|
|
||||||
for (const args of Object.values(forms)) {
|
|
||||||
for (const shortname in args) {
|
|
||||||
if (args[shortname] instanceof File) {
|
|
||||||
if (expression.includes(shortname)) {
|
|
||||||
promises.push(pFileReader(args[shortname], context, shortname, false))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context[shortname] = args[shortname]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Allow to use match(var,regexp) function
|
|
||||||
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
|
|
||||||
let i = 0
|
|
||||||
Promise.all(promises).then(() => {
|
|
||||||
for (const matched of expression.matchAll(matchRe)) {
|
|
||||||
i++
|
|
||||||
const varName = matched[1] + '__re' + i.toString()
|
|
||||||
context[varName] = new RegExp(matched[2], 'm').test(context[matched[1]])
|
|
||||||
expression = expression.replace(matched[0], varName)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
field.isVisible = evaluate(context, expression)
|
|
||||||
} catch {
|
|
||||||
field.isVisible = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
return field.isVisible
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
export function pFileReader (file, output, key, base64 = true) {
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
const fr = new FileReader()
|
|
||||||
fr.onerror = reject
|
|
||||||
fr.onload = () => {
|
|
||||||
output[key] = fr.result
|
|
||||||
if (base64) {
|
|
||||||
output[key] = fr.result.replace(/data:[^;]*;base64,/, '')
|
|
||||||
}
|
|
||||||
output[key + '[name]'] = file.name
|
|
||||||
resolve()
|
|
||||||
}
|
|
||||||
if (base64) {
|
|
||||||
fr.readAsDataURL(file)
|
|
||||||
} else {
|
|
||||||
fr.readAsText(file)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Format helper for a form value.
|
* Parse a front-end value to its API equivalent. This function returns a Promise or an
|
||||||
* Convert Boolean to (1|0) and concatenate adresses.
|
* Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those
|
||||||
|
* objects must be merged to define the final sent form.
|
||||||
|
*
|
||||||
|
* Convert Boolean to '1' (true) or '0' (false),
|
||||||
|
* Concatenate two parts adresses (subdomain or email for example) into a single string,
|
||||||
|
* Convert File to its Base64 representation or set its value to '' to ask for a removal.
|
||||||
*
|
*
|
||||||
* @param {*} value
|
* @param {*} value
|
||||||
* @return {*}
|
* @return {*}
|
||||||
*/
|
*/
|
||||||
export function formatFormDataValue (value) {
|
export function formatFormDataValue (value, key = null) {
|
||||||
if (typeof value === 'boolean') {
|
if (Array.isArray(value)) {
|
||||||
return value ? 1 : 0
|
return Promise.all(
|
||||||
} else if (isObjectLiteral(value) && 'separator' in value) {
|
value.map(value_ => formatFormDataValue(value_))
|
||||||
return Object.values(value).join('')
|
).then(resolvedValues => ({ [key]: resolvedValues }))
|
||||||
}
|
}
|
||||||
return value
|
|
||||||
|
let result = value
|
||||||
|
if (typeof value === 'boolean') result = value ? 1 : 0
|
||||||
|
if (isObjectLiteral(value) && 'file' in value) {
|
||||||
|
// File has to be deleted
|
||||||
|
if (value.removed) result = ''
|
||||||
|
// File has not changed (will not be sent)
|
||||||
|
else if (value.current || value.file === null) result = null
|
||||||
|
else {
|
||||||
|
return getFileContent(value.file, { base64: true }).then(content => {
|
||||||
|
return {
|
||||||
|
[key]: content.replace(/data:[^;]*;base64,/, ''),
|
||||||
|
[key + '[name]']: value.file.name
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
} else if (isObjectLiteral(value) && 'separator' in value) {
|
||||||
|
result = Object.values(value).join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns a resolved Promise for non async values
|
||||||
|
return Promise.resolve(key ? { [key]: result } : result)
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convinient helper to properly parse a front-end form to its API equivalent.
|
||||||
|
* This parse each values asynchronously, allow to inject keys into the final form and
|
||||||
|
* make sure every async values resolves before resolving itself.
|
||||||
|
*
|
||||||
|
* @param {Object} formData
|
||||||
|
* @return {Object}
|
||||||
|
*/
|
||||||
|
function formatFormDataValues (formData) {
|
||||||
|
const promisedValues = Object.entries(formData).map(([key, value]) => {
|
||||||
|
return formatFormDataValue(value, key)
|
||||||
|
})
|
||||||
|
|
||||||
|
return Promise.all(promisedValues).then(resolvedValues => {
|
||||||
|
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {})
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -412,38 +486,28 @@ export function formatFormDataValue (value) {
|
||||||
*/
|
*/
|
||||||
export async function formatFormData (
|
export async function formatFormData (
|
||||||
formData,
|
formData,
|
||||||
{ extract = null, flatten = false, removeEmpty = true, removeNull = false, multipart = true } = {}
|
{ extract = null, flatten = false, removeEmpty = true, removeNull = false } = {}
|
||||||
) {
|
) {
|
||||||
const output = {
|
const output = {
|
||||||
data: {},
|
data: {},
|
||||||
extracted: {}
|
extracted: {}
|
||||||
}
|
}
|
||||||
const promises = []
|
|
||||||
for (const key in formData) {
|
|
||||||
const type = extract && extract.includes(key) ? 'extracted' : 'data'
|
|
||||||
const value = Array.isArray(formData[key])
|
|
||||||
? formData[key].map(item => formatFormDataValue(item))
|
|
||||||
: formatFormDataValue(formData[key])
|
|
||||||
|
|
||||||
|
const values = await formatFormDataValues(formData)
|
||||||
|
for (const key in values) {
|
||||||
|
const type = extract && extract.includes(key) ? 'extracted' : 'data'
|
||||||
|
const value = values[key]
|
||||||
if (removeEmpty && isEmptyValue(value)) {
|
if (removeEmpty && isEmptyValue(value)) {
|
||||||
continue
|
continue
|
||||||
} else if (removeNull && (value === null || value === undefined)) {
|
} else if (removeNull && [null, undefined].includes(value)) {
|
||||||
continue
|
continue
|
||||||
} else if (value instanceof File && !multipart) {
|
|
||||||
if (value.currentfile) {
|
|
||||||
continue
|
|
||||||
} else if (value._removed) {
|
|
||||||
output[type][key] = ''
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
promises.push(pFileReader(value, output[type], key))
|
|
||||||
} else if (flatten && isObjectLiteral(value)) {
|
} else if (flatten && isObjectLiteral(value)) {
|
||||||
flattenObjectLiteral(value, output[type])
|
flattenObjectLiteral(value, output[type])
|
||||||
} else {
|
} else {
|
||||||
output[type][key] = value
|
output[type][key] = value
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (promises.length) await Promise.all(promises)
|
|
||||||
const { data, extracted } = output
|
const { data, extracted } = output
|
||||||
return extract ? { data, ...extracted } : data
|
return extract ? { data, ...extracted } : data
|
||||||
}
|
}
|
||||||
|
|
|
@ -80,7 +80,7 @@ function initDefaultLocales () {
|
||||||
|
|
||||||
store.dispatch('UPDATE_LOCALE', locale)
|
store.dispatch('UPDATE_LOCALE', locale)
|
||||||
store.dispatch('UPDATE_FALLBACKLOCALE', fallbackLocale || 'en')
|
store.dispatch('UPDATE_FALLBACKLOCALE', fallbackLocale || 'en')
|
||||||
loadLocaleMessages('en')
|
return loadLocaleMessages('en')
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
|
|
@ -54,8 +54,6 @@
|
||||||
"api_not_found": "Seems like the web-admin tried to query something that doesn't exist.",
|
"api_not_found": "Seems like the web-admin tried to query something that doesn't exist.",
|
||||||
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?",
|
"api_not_responding": "The YunoHost API is not responding. Maybe 'yunohost-api' is down or got restarted?",
|
||||||
"api_waiting": "Waiting for the server's response...",
|
"api_waiting": "Waiting for the server's response...",
|
||||||
"app_actions": "Actions",
|
|
||||||
"app_actions_label": "Perform actions",
|
|
||||||
"app_choose_category": "Choose a category",
|
"app_choose_category": "Choose a category",
|
||||||
"app_config_panel": "Config panel",
|
"app_config_panel": "Config panel",
|
||||||
"app_config_panel_label": "Configure this app",
|
"app_config_panel_label": "Configure this app",
|
||||||
|
@ -69,18 +67,14 @@
|
||||||
"app_install_parameters": "Install settings",
|
"app_install_parameters": "Install settings",
|
||||||
"app_manage_label_and_tiles": "Manage label and tiles",
|
"app_manage_label_and_tiles": "Manage label and tiles",
|
||||||
"app_make_default": "Make default",
|
"app_make_default": "Make default",
|
||||||
"app_no_actions": "This application doesn't have any actions",
|
|
||||||
"app_show_categories": "Show categories",
|
"app_show_categories": "Show categories",
|
||||||
|
"app_state_broken": "broken",
|
||||||
|
"app_state_broken_explanation": "This application is currently broken and not installable according to YunoHost's automatic tests",
|
||||||
"app_state_inprogress": "not yet working",
|
"app_state_inprogress": "not yet working",
|
||||||
"app_state_inprogress_explanation": "This maintainer of this app declared that this application is not ready yet for production use. BE CAREFUL!",
|
"app_state_inprogress_explanation": "This maintainer of this app declared that this application is not ready yet for production use. BE CAREFUL!",
|
||||||
"app_state_notworking": "not working",
|
|
||||||
"app_state_notworking_explanation": "This maintainer of this app declared it as 'not working'. IT WILL BREAK YOUR SYSTEM!",
|
|
||||||
"app_state_lowquality": "low quality",
|
"app_state_lowquality": "low quality",
|
||||||
"app_state_lowquality_explanation": "This app may be functional, but may still contain issues, or is not fully integrated with YunoHost, or it does not respect the good practices.",
|
"app_state_lowquality_explanation": "This app may be functional, but may still contain issues, or is not fully integrated with YunoHost, or it does not respect the good practices.",
|
||||||
"app_state_highquality": "high quality",
|
|
||||||
"app_state_highquality_explanation": "This app is well-integrated with YunoHost since at least a year.",
|
"app_state_highquality_explanation": "This app is well-integrated with YunoHost since at least a year.",
|
||||||
"app_state_working": "working",
|
|
||||||
"app_state_working_explanation": "The maintainer of this app declared it as 'working'. It means that it should be functional (c.f. application level) but is not necessarily peer-reviewed, it may still contain issues or is not fully integrated with YunoHost.",
|
|
||||||
"applications": "Applications",
|
"applications": "Applications",
|
||||||
"archive_empty": "Empty archive",
|
"archive_empty": "Empty archive",
|
||||||
"backup": "Backup",
|
"backup": "Backup",
|
||||||
|
@ -110,6 +104,7 @@
|
||||||
"confirm_install_custom_app": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
|
"confirm_install_custom_app": "WARNING! Installing 3rd party applications may compromise the integrity and security of your system. You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
|
||||||
"confirm_install_domain_root": "Are you sure you want to install this application on '/'? You will not be able to install any other app on {domain}",
|
"confirm_install_domain_root": "Are you sure you want to install this application on '/'? You will not be able to install any other app on {domain}",
|
||||||
"confirm_app_install": "Are you sure you want to install this application?",
|
"confirm_app_install": "Are you sure you want to install this application?",
|
||||||
|
"confirm_install_app_broken": "WARNING! This application is broken according to YunoHost's automatic tests and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
|
||||||
"confirm_install_app_lowquality": "Warning: this application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available.",
|
"confirm_install_app_lowquality": "Warning: this application may work but is not well-integrated in YunoHost. Some features such as single sign-on and backup/restore might not be available.",
|
||||||
"confirm_install_app_inprogress": "WARNING! This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
|
"confirm_install_app_inprogress": "WARNING! This application is still experimental (if not explicitly not working) and it is likely to break your system! You should probably NOT install it unless you know what you are doing. Are you willing to take that risk?",
|
||||||
"confirm_migrations_skip": "Skipping migrations is not recommended. Are you sure you want to do that?",
|
"confirm_migrations_skip": "Skipping migrations is not recommended. Are you sure you want to do that?",
|
||||||
|
@ -134,6 +129,7 @@
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
"description": "Description",
|
"description": "Description",
|
||||||
"details": "Details",
|
"details": "Details",
|
||||||
|
"details_about": "Show more details about {subject}",
|
||||||
"domain_dns_conf_is_just_a_recommendation": "This page shows you the *recommended* configuration. It does *not* configure the DNS for you. It is your responsibility to configure your DNS zone in your DNS registrar according to this recommendation.",
|
"domain_dns_conf_is_just_a_recommendation": "This page shows you the *recommended* configuration. It does *not* configure the DNS for you. It is your responsibility to configure your DNS zone in your DNS registrar according to this recommendation.",
|
||||||
"diagnosis": "Diagnosis",
|
"diagnosis": "Diagnosis",
|
||||||
"diagnosis_first_run": "The diagnosis feature will attempt to identify common issues on the different aspects of your server to make sure everything runs smoothly. Please do not panic if you see a bunch of errors right after setting up your server: it is precisely meant to help you to identify issues and guide you to fix them. The diagnosis will also run automatically twice a day and an email is sent to the administrator if issues are found.",
|
"diagnosis_first_run": "The diagnosis feature will attempt to identify common issues on the different aspects of your server to make sure everything runs smoothly. Please do not panic if you see a bunch of errors right after setting up your server: it is precisely meant to help you to identify issues and guide you to fix them. The diagnosis will also run automatically twice a day and an email is sent to the administrator if issues are found.",
|
||||||
|
@ -143,6 +139,14 @@
|
||||||
"disabled": "Disabled",
|
"disabled": "Disabled",
|
||||||
"dns": "DNS",
|
"dns": "DNS",
|
||||||
"domain": {
|
"domain": {
|
||||||
|
"cert": {
|
||||||
|
"types": {
|
||||||
|
"selfsigned": "Self-signed",
|
||||||
|
"letsencrypt": "Let's Encrypt",
|
||||||
|
"other": "Other/Unknown"
|
||||||
|
},
|
||||||
|
"valid_for": "valid for {days}"
|
||||||
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"edit": "Edit domain configuration",
|
"edit": "Edit domain configuration",
|
||||||
"title": "Domain configuration"
|
"title": "Domain configuration"
|
||||||
|
@ -152,6 +156,13 @@
|
||||||
"auto_config_ignored": "ignored, won't be changed by YunoHost unless you check the overwrite option",
|
"auto_config_ignored": "ignored, won't be changed by YunoHost unless you check the overwrite option",
|
||||||
"auto_config_ok": "Automatic configuration seems to be OK!",
|
"auto_config_ok": "Automatic configuration seems to be OK!",
|
||||||
"auto_config_zone": "Current DNS zone",
|
"auto_config_zone": "Current DNS zone",
|
||||||
|
"methods": {
|
||||||
|
"auto": "Automatic",
|
||||||
|
"handled_in_parent": "Handled in parent domain",
|
||||||
|
"manual": "Manual",
|
||||||
|
"none": "None",
|
||||||
|
"semi_auto": "Semi-automatic"
|
||||||
|
},
|
||||||
"edit": "Edit DNS configuration",
|
"edit": "Edit DNS configuration",
|
||||||
"info": "The automatic DNS records configuration is an experimental feature. <br>Consider saving your current DNS zone from your DNS registrar's interface before pushing records from here.",
|
"info": "The automatic DNS records configuration is an experimental feature. <br>Consider saving your current DNS zone from your DNS registrar's interface before pushing records from here.",
|
||||||
"manual_config": "Suggested DNS records for manual configuration",
|
"manual_config": "Suggested DNS records for manual configuration",
|
||||||
|
@ -159,7 +170,20 @@
|
||||||
"push_force": "Overwrite existing records",
|
"push_force": "Overwrite existing records",
|
||||||
"push_force_confirm": "Are you sure you want to force push all suggested dns records? Be aware that it may overwrite manually or important default records set by you or your registrar.",
|
"push_force_confirm": "Are you sure you want to force push all suggested dns records? Be aware that it may overwrite manually or important default records set by you or your registrar.",
|
||||||
"push_force_warning": "It looks like some DNS records that YunoHost would have set are already in the registrar configuration. You can use the overwrite option if you know what you are doing."
|
"push_force_warning": "It looks like some DNS records that YunoHost would have set are already in the registrar configuration. You can use the overwrite option if you know what you are doing."
|
||||||
}
|
},
|
||||||
|
"explain": {
|
||||||
|
"main_domain": "The main domain is the domain from which users can connect to the portal (via \"{domain}/yunohost/sso\").<br>Therefore, it is not possible to delete it.<br>If you want to delete \"{domain}\", you will first have to choose or add another domain and set it as the main domain."
|
||||||
|
},
|
||||||
|
"info": {
|
||||||
|
"apps_on_domain": "Apps installed on domain",
|
||||||
|
"certificate_authority": "SSL Certification authority",
|
||||||
|
"registrar": "Registrar"
|
||||||
|
},
|
||||||
|
"see_parent_domain": "See parent domain",
|
||||||
|
"types": {
|
||||||
|
"main_domain": "Main domain"
|
||||||
|
},
|
||||||
|
"toggle_subdomains": "Toggle subdomains"
|
||||||
},
|
},
|
||||||
"domain_add": "Add domain",
|
"domain_add": "Add domain",
|
||||||
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns_config' target='_blank'>set my DNS correctly</a>.",
|
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns_config' target='_blank'>set my DNS correctly</a>.",
|
||||||
|
@ -177,8 +201,6 @@
|
||||||
"domain_dns_push_managed_in_parent_domain": "The automatic DNS records feature is managed in the parent domain <a href='#/domains/{parent_domain}/dns'>{parent_domain}</a>.",
|
"domain_dns_push_managed_in_parent_domain": "The automatic DNS records feature is managed in the parent domain <a href='#/domains/{parent_domain}/dns'>{parent_domain}</a>.",
|
||||||
"domain_dns_push_not_applicable": "The automatic DNS records feature is not applicable to domain {domain},<br> You should manually configure your DNS records following the <a href='https://yunohost.org/dns'>documentation</a> and the suggested configuration below.",
|
"domain_dns_push_not_applicable": "The automatic DNS records feature is not applicable to domain {domain},<br> You should manually configure your DNS records following the <a href='https://yunohost.org/dns'>documentation</a> and the suggested configuration below.",
|
||||||
"domain_name": "Domain name",
|
"domain_name": "Domain name",
|
||||||
"domain_visit": "Visit",
|
|
||||||
"domain_visit_url": "Visit {url}",
|
|
||||||
"domains": "Domains",
|
"domains": "Domains",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
|
@ -227,14 +249,16 @@
|
||||||
"group_name": "Group name",
|
"group_name": "Group name",
|
||||||
"group_all_users": "All users",
|
"group_all_users": "All users",
|
||||||
"group_visitors": "Visitors",
|
"group_visitors": "Visitors",
|
||||||
|
"group_admins": "Admins",
|
||||||
"group_format_name_help": "You can use alpha-numeric chars and underscore",
|
"group_format_name_help": "You can use alpha-numeric chars and underscore",
|
||||||
"group_add_member": "Add a user",
|
"group_add_member": "Add a user",
|
||||||
"group_add_permission": "Add a permission",
|
"group_add_permission": "Add a permission",
|
||||||
"group_new": "New group",
|
"group_new": "New group",
|
||||||
|
"group_explain_admins": "This is a special group corresponding to admin users. Users in this group can access YunoHost's webadmin, connect to the server with SSH and use the `sudo` command. You should only add people you absolutely trust in this group!",
|
||||||
"group_explain_all_users": "This is a special group containing all users accounts on the server",
|
"group_explain_all_users": "This is a special group containing all users accounts on the server",
|
||||||
"group_explain_visitors": "This is a special group representing anonymous visitors",
|
"group_explain_visitors": "This is a special group representing anonymous visitors",
|
||||||
"group_explain_visitors_needed_for_external_client": "Be careful that you need to keep some applications allowed to visitors if you intend to use them with external clients. For example, this is the case for Nextcloud if you intend to use a synchronization client on your smartphone or desktop computer.",
|
"group_explain_visitors_needed_for_external_client": "Be careful that you need to keep some applications allowed to visitors if you intend to use them with external clients. For example, this is the case for Nextcloud if you intend to use a synchronization client on your smartphone or desktop computer.",
|
||||||
"group_specific_permissions": "User specific permissions",
|
"group_specific_permissions": "Individual user permissions",
|
||||||
"groups_and_permissions": "Groups and permissions",
|
"groups_and_permissions": "Groups and permissions",
|
||||||
"groups_and_permissions_manage": "Manage groups and permissions",
|
"groups_and_permissions_manage": "Manage groups and permissions",
|
||||||
"permissions": "Permissions",
|
"permissions": "Permissions",
|
||||||
|
@ -288,7 +312,8 @@
|
||||||
"items_verbose_count": "There are {items}. | There is 1 {items}. | There are {items}.",
|
"items_verbose_count": "There are {items}. | There is 1 {items}. | There are {items}.",
|
||||||
"items_verbose_items_left": "There are {items} left. | There is 1 {items} left. | There are {items} left.",
|
"items_verbose_items_left": "There are {items} left. | There is 1 {items} left. | There are {items} left.",
|
||||||
"label": "Label",
|
"label": "Label",
|
||||||
"label_for_manifestname": "Label for {name} (name displayed in the user portal)",
|
"label_for_manifestname": "Label for {name}",
|
||||||
|
"label_for_manifestname_help": "This is the name displayed in the user portal. This can be changed later.",
|
||||||
"last_ran": "Last time ran:",
|
"last_ran": "Last time ran:",
|
||||||
"license": "License",
|
"license": "License",
|
||||||
"local_archives": "Local archives",
|
"local_archives": "Local archives",
|
||||||
|
@ -300,6 +325,7 @@
|
||||||
"manage_apps": "Manage apps",
|
"manage_apps": "Manage apps",
|
||||||
"manage_domains": "Manage domains",
|
"manage_domains": "Manage domains",
|
||||||
"manage_users": "Manage users",
|
"manage_users": "Manage users",
|
||||||
|
"manage_groups": "Manage groups",
|
||||||
"migrations": "Migrations",
|
"migrations": "Migrations",
|
||||||
"migrations_pending": "Pending migrations",
|
"migrations_pending": "Pending migrations",
|
||||||
"migrations_done": "Previous migrations",
|
"migrations_done": "Previous migrations",
|
||||||
|
@ -327,9 +353,10 @@
|
||||||
"path": "Path",
|
"path": "Path",
|
||||||
"perform": "Perform",
|
"perform": "Perform",
|
||||||
"placeholder": {
|
"placeholder": {
|
||||||
"username": "johndoe",
|
"username": "samsmith",
|
||||||
"firstname": "John",
|
"fullname": "Sam Smith",
|
||||||
"lastname": "Doe",
|
"firstname": "Sam",
|
||||||
|
"lastname": "Smith",
|
||||||
"groupname": "My group name",
|
"groupname": "My group name",
|
||||||
"domain": "my-domain.com",
|
"domain": "my-domain.com",
|
||||||
"file": "Browse a file or drag and drop it"
|
"file": "Browse a file or drag and drop it"
|
||||||
|
@ -359,15 +386,17 @@
|
||||||
"ports": "Ports",
|
"ports": "Ports",
|
||||||
"postinstall": {
|
"postinstall": {
|
||||||
"force": "Force the post-install",
|
"force": "Force the post-install",
|
||||||
"title": "Postinstall"
|
"title": "Postinstall",
|
||||||
|
"user": {
|
||||||
|
"title": "Create first admin user",
|
||||||
|
"first_user_help": "This user will be granted admin privileges and will be allowed to connect to this administration interface as well as directly to the server via SSH.\nAs it is a regular user, you will also be able to connect to the user portal (SSO) with its credentials.\nOnce the post-installation is complete, you will be able to create other admin users by adding them into the 'admins' group."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"postinstall_domain": "This is the first domain name linked to your YunoHost server, but also the one which will be used by your server's users to access the authentication portal. Accordingly, it will be visible by everyone, so choose it carefully.",
|
"postinstall_domain": "This is the first domain name linked to your YunoHost server, but also the one which will be used by your server's users to access the authentication portal. Accordingly, it will be visible by everyone, so choose it carefully.",
|
||||||
"postinstall_intro_1": "Congratulations! YunoHost has been successfully installed.",
|
"postinstall_intro_1": "Congratulations! YunoHost has been successfully installed.",
|
||||||
"postinstall_intro_2": "Two more configuration steps are required to activate you server's services.",
|
"postinstall_intro_2": "Two more configuration steps are required to activate you server's services.",
|
||||||
"postinstall_intro_3": "You can obtain more information by visiting the <a href='//yunohost.org/en/install/hardware:vps_debian#fa-cog-proceed-with-the-initial-configuration' target='_blank'>appropriate documentation page</a>",
|
"postinstall_intro_3": "You can obtain more information by visiting the <a href='//yunohost.org/en/install/hardware:vps_debian#fa-cog-proceed-with-the-initial-configuration' target='_blank'>appropriate documentation page</a>",
|
||||||
"postinstall_password": "This password will be used to manage everything on your server. Take the time to choose it wisely.",
|
|
||||||
"postinstall_set_domain": "Set main domain",
|
"postinstall_set_domain": "Set main domain",
|
||||||
"postinstall_set_password": "Set administration password",
|
|
||||||
"previous": "Previous",
|
"previous": "Previous",
|
||||||
"protocol": "Protocol",
|
"protocol": "Protocol",
|
||||||
"readme": "Readme",
|
"readme": "Readme",
|
||||||
|
@ -376,15 +405,14 @@
|
||||||
"restart": "Restart",
|
"restart": "Restart",
|
||||||
"retry": "Retry",
|
"retry": "Retry",
|
||||||
"human_routes": {
|
"human_routes": {
|
||||||
"adminpw": "Change admin password",
|
|
||||||
"apps": {
|
"apps": {
|
||||||
|
"action_config": "Run action '{action}' of app '{name}' configuration",
|
||||||
"change_label": "Change label of '{prevName}' for '{nextName}'",
|
"change_label": "Change label of '{prevName}' for '{nextName}'",
|
||||||
"change_url": "Change access URL of '{name}'",
|
"change_url": "Change access URL of '{name}'",
|
||||||
"install": "Install app '{name}'",
|
"install": "Install app '{name}'",
|
||||||
"set_default": "Redirect '{domain}' domain root to '{name}'",
|
"set_default": "Redirect '{domain}' domain root to '{name}'",
|
||||||
"perform_action": "Perform action '{action}' of app '{name}'",
|
|
||||||
"uninstall": "Uninstall app '{name}'",
|
"uninstall": "Uninstall app '{name}'",
|
||||||
"update_config": "Update app '{name}' configuration"
|
"update_config": "Update panel '{id}' of app '{name}' configuration"
|
||||||
},
|
},
|
||||||
"backups": {
|
"backups": {
|
||||||
"create": "Create a backup",
|
"create": "Create a backup",
|
||||||
|
@ -406,13 +434,11 @@
|
||||||
"domains": {
|
"domains": {
|
||||||
"add": "Add domain '{name}'",
|
"add": "Add domain '{name}'",
|
||||||
"delete": "Delete domain '{name}'",
|
"delete": "Delete domain '{name}'",
|
||||||
"install_LE": "Install certificate for '{name}'",
|
"cert_install": "Install certificate for '{name}'",
|
||||||
"manual_renew_LE": "Renew certificate for '{name}'",
|
"cert_renew": "Renew certificate for '{name}'",
|
||||||
"push_dns_changes": "Push DNS records to registrar for '{name}'",
|
"push_dns_changes": "Push DNS records to registrar for '{name}'",
|
||||||
"regen_selfsigned": "Renew self-signed certificate for '{name}'",
|
|
||||||
"revert_to_selfsigned": "Revert to self-signed certificate for '{name}'",
|
|
||||||
"set_default": "Set '{name}' as default domain",
|
"set_default": "Set '{name}' as default domain",
|
||||||
"update_config": "Update '{name}' configuration"
|
"update_config": "Update panel '{id}' of domain '{name}' configuration"
|
||||||
},
|
},
|
||||||
"firewall": {
|
"firewall": {
|
||||||
"ports": "{action} port {port} ({protocol}, {connection})",
|
"ports": "{action} port {port} ({protocol}, {connection})",
|
||||||
|
@ -452,6 +478,9 @@
|
||||||
"create": "Create user '{name}'",
|
"create": "Create user '{name}'",
|
||||||
"delete": "Delete user '{name}'",
|
"delete": "Delete user '{name}'",
|
||||||
"update": "Update user '{name}'"
|
"update": "Update user '{name}'"
|
||||||
|
},
|
||||||
|
"settings": {
|
||||||
|
"update": "Update '{panel}' global settings"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"run": "Run",
|
"run": "Run",
|
||||||
|
@ -483,9 +512,6 @@
|
||||||
"text_selection_is_disabled": "Text selection is disabled. If you want to share this log, please share the *full* log with the 'Share with Yunopaste' button.<br/><small>Or if you really really want to select text, press these keys: ↓↓↑↑.</small>",
|
"text_selection_is_disabled": "Text selection is disabled. If you want to share this log, please share the *full* log with the 'Share with Yunopaste' button.<br/><small>Or if you really really want to select text, press these keys: ↓↓↑↑.</small>",
|
||||||
"tip_about_user_email": "Users are created with an associated email address (and XMPP account) with the format username@domain.tld. Additional email aliases and email forwards can later be added by the admin and the user.",
|
"tip_about_user_email": "Users are created with an associated email address (and XMPP account) with the format username@domain.tld. Additional email aliases and email forwards can later be added by the admin and the user.",
|
||||||
"tools": "Tools",
|
"tools": "Tools",
|
||||||
"tools_adminpw": "Change administration password",
|
|
||||||
"tools_adminpw_current": "Current password",
|
|
||||||
"tools_adminpw_current_placeholder": "Enter your current password",
|
|
||||||
"tools_reboot": "Reboot your server",
|
"tools_reboot": "Reboot your server",
|
||||||
"tools_reboot_btn": "Reboot",
|
"tools_reboot_btn": "Reboot",
|
||||||
"tools_shutdown": "Shutdown your server",
|
"tools_shutdown": "Shutdown your server",
|
||||||
|
@ -499,8 +525,10 @@
|
||||||
"cache_description": "Consider disabling the cache if you plan on working with the CLI while also navigating in this web-admin.",
|
"cache_description": "Consider disabling the cache if you plan on working with the CLI while also navigating in this web-admin.",
|
||||||
"experimental": "Experimental mode",
|
"experimental": "Experimental mode",
|
||||||
"experimental_description": "Gives you access to experimental features. These are considered unstable and may break your system.<br> Enable this only if you know what you are doing.",
|
"experimental_description": "Gives you access to experimental features. These are considered unstable and may break your system.<br> Enable this only if you know what you are doing.",
|
||||||
"transitions": "Page transition animations"
|
"transitions": "Page transition animations",
|
||||||
|
"theme": "Toggle dark mode"
|
||||||
},
|
},
|
||||||
|
"tools_yunohost_settings": "YunoHost settings",
|
||||||
"tools_webadmin_settings": "Web-admin settings",
|
"tools_webadmin_settings": "Web-admin settings",
|
||||||
"traceback": "Traceback",
|
"traceback": "Traceback",
|
||||||
"udp": "UDP",
|
"udp": "UDP",
|
||||||
|
@ -508,8 +536,6 @@
|
||||||
"unignore": "Unignore",
|
"unignore": "Unignore",
|
||||||
"uninstall": "Uninstall",
|
"uninstall": "Uninstall",
|
||||||
"unknown": "Unknown",
|
"unknown": "Unknown",
|
||||||
"unmaintained": "Unmaintained",
|
|
||||||
"unmaintained_details": "This app has not been updated for quite a while and the previous maintainer has gone away or does not have time to maintain this app. Feel free to check the app repository to provide your help",
|
|
||||||
"upnp": "UPnP",
|
"upnp": "UPnP",
|
||||||
"upnp_disabled": "UPnP is disabled.",
|
"upnp_disabled": "UPnP is disabled.",
|
||||||
"upnp_enabled": "UPnP is enabled.",
|
"upnp_enabled": "UPnP is enabled.",
|
||||||
|
@ -544,37 +570,18 @@
|
||||||
"words": {
|
"words": {
|
||||||
"browse": "Browse",
|
"browse": "Browse",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"default": "Default"
|
"default": "Default",
|
||||||
|
"link": "Link",
|
||||||
|
"none": "None",
|
||||||
|
"separator": ", ",
|
||||||
|
"valid": "Valid"
|
||||||
},
|
},
|
||||||
"wrong_password": "Wrong password",
|
"wrong_password_or_username": "Wrong password or username",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
"yunohost_admin": "YunoHost Admin",
|
"yunohost_admin": "YunoHost Admin",
|
||||||
"certificate_alert_not_valid": "CRITICAL: Current certificate is not valid! HTTPS won't work at all!",
|
|
||||||
"certificate_alert_selfsigned": "WARNING: Current certificate is self-signed. Browsers will display a spooky warning to new visitors!",
|
|
||||||
"certificate_alert_letsencrypt_about_to_expire": "Current certificate is about to expire. It should soon be renewed automatically.",
|
|
||||||
"certificate_alert_about_to_expire": "WARNING: Current certificate is about to expire! It will NOT be renewed automatically!",
|
|
||||||
"certificate_alert_good": "Okay, current certificate looks good!",
|
|
||||||
"certificate_alert_great": "Great! You're using a valid Let's Encrypt certificate!",
|
|
||||||
"certificate_alert_unknown": "Unknown status",
|
|
||||||
"certificate_manage": "Manage SSL certificate",
|
"certificate_manage": "Manage SSL certificate",
|
||||||
"ssl_certificate": "SSL certificate",
|
"ssl_certificate": "SSL certificate",
|
||||||
"confirm_cert_install_LE": "Are you sure you want to install a Let's Encrypt certificate for this domain?",
|
|
||||||
"confirm_cert_regen_selfsigned": "Are you sure you want to regenerate a self-signed certificate for this domain?",
|
|
||||||
"confirm_cert_manual_renew_LE": "Are you sure you want to manually renew the Let's Encrypt certificate for this domain now?",
|
|
||||||
"confirm_cert_revert_to_selfsigned": "Are you sure you want to revert this domain to a self-signed certificate?",
|
|
||||||
"certificate": "Certificate",
|
"certificate": "Certificate",
|
||||||
"certificate_status": "Certificate status",
|
|
||||||
"certificate_authority": "Certification authority",
|
|
||||||
"validity": "Validity",
|
|
||||||
"domain_is_eligible_for_ACME": "This domain seems correctly configured to install a Let's Encrypt certificate!",
|
|
||||||
"domain_not_eligible_for_ACME": "This domain doesn't seem ready for a Let's Encrypt certificate. Please check your DNS configuration and HTTP server reachability. The 'DNS records' and 'Web' section in <a href='#/diagnosis'>the diagnosis page</a> can help you understand what is misconfigured.",
|
|
||||||
"install_letsencrypt_cert": "Install a Let's Encrypt certificate",
|
|
||||||
"manually_renew_letsencrypt_message": "Certificate will be automatically renewed during the last 15 days of validity. You can manually renew it if you want to. (Not recommended).",
|
|
||||||
"manually_renew_letsencrypt": "Manually renew now",
|
|
||||||
"regenerate_selfsigned_cert_message": "If you want, you can regenerate the self-signed certificate.",
|
|
||||||
"regenerate_selfsigned_cert": "Regenerate self-signed certificate",
|
|
||||||
"revert_to_selfsigned_cert_message": "If you really want to, you can reinstall a self-signed certificate. (Not recommended)",
|
|
||||||
"revert_to_selfsigned_cert": "Revert to a self-signed certificate",
|
|
||||||
"purge_user_data_checkbox": "Purge {name}'s data? (This will remove the content of its home and mail directories.)",
|
"purge_user_data_checkbox": "Purge {name}'s data? (This will remove the content of its home and mail directories.)",
|
||||||
"purge_user_data_warning": "Purging user's data is not reversible. Be sure you know what you're doing!"
|
"purge_user_data_warning": "Purging user's data is not reversible. Be sure you know what you're doing!"
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
"app_state_working": "Fonctionnelle",
|
"app_state_working": "Fonctionnelle",
|
||||||
"applications": "Applications",
|
"applications": "Applications",
|
||||||
"archive_empty": "L’archive est vide",
|
"archive_empty": "L’archive est vide",
|
||||||
"backup": "Sauvegarde",
|
"backup": "Sauvegardes",
|
||||||
"backup_action": "Sauvegarder",
|
"backup_action": "Sauvegarder",
|
||||||
"backup_content": "Contenu de la sauvegarde",
|
"backup_content": "Contenu de la sauvegarde",
|
||||||
"backup_create": "Créer une sauvegarde",
|
"backup_create": "Créer une sauvegarde",
|
||||||
|
@ -267,7 +267,7 @@
|
||||||
"group_new": "Nouveau groupe",
|
"group_new": "Nouveau groupe",
|
||||||
"group_explain_all_users": "Ceci est un groupe spécial contenant tous les comptes d'utilisateurs sur le serveur",
|
"group_explain_all_users": "Ceci est un groupe spécial contenant tous les comptes d'utilisateurs sur le serveur",
|
||||||
"group_explain_visitors": "Ceci est un groupe spécial représentant les visiteurs anonymes",
|
"group_explain_visitors": "Ceci est un groupe spécial représentant les visiteurs anonymes",
|
||||||
"group_specific_permissions": "Autorisations spécifiques à l'utilisateur",
|
"group_specific_permissions": "Autorisations pour des utilisateurs individuels",
|
||||||
"groups_and_permissions": "Groupes et autorisations",
|
"groups_and_permissions": "Groupes et autorisations",
|
||||||
"groups_and_permissions_manage": "Gérer les groupes et les autorisations",
|
"groups_and_permissions_manage": "Gérer les groupes et les autorisations",
|
||||||
"permissions": "Permissions",
|
"permissions": "Permissions",
|
||||||
|
@ -335,8 +335,9 @@
|
||||||
"domain": "mon-domaine.fr",
|
"domain": "mon-domaine.fr",
|
||||||
"groupname": "Le nom de mon groupe",
|
"groupname": "Le nom de mon groupe",
|
||||||
"lastname": "Dupont",
|
"lastname": "Dupont",
|
||||||
"firstname": "Jean",
|
"firstname": "Camille",
|
||||||
"username": "jeandupont",
|
"fullname": "Camille Dupont",
|
||||||
|
"username": "camilledupont",
|
||||||
"file": "Parcourir un fichier ou le faire glisser et déposer"
|
"file": "Parcourir un fichier ou le faire glisser et déposer"
|
||||||
},
|
},
|
||||||
"perform": "Exécuter",
|
"perform": "Exécuter",
|
||||||
|
|
|
@ -55,13 +55,13 @@ requireComponent.keys().forEach((fileName) => {
|
||||||
registerGlobalErrorHandlers()
|
registerGlobalErrorHandlers()
|
||||||
|
|
||||||
// Load default locales translations files and setup store data
|
// Load default locales translations files and setup store data
|
||||||
initDefaultLocales()
|
initDefaultLocales().then(() => {
|
||||||
|
const app = new Vue({
|
||||||
|
store,
|
||||||
|
router,
|
||||||
|
i18n,
|
||||||
|
render: h => h(App)
|
||||||
|
})
|
||||||
|
|
||||||
const app = new Vue({
|
app.$mount('#app')
|
||||||
store,
|
|
||||||
router,
|
|
||||||
i18n,
|
|
||||||
render: h => h(App)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
app.$mount('#app')
|
|
||||||
|
|
|
@ -141,54 +141,23 @@ const routes = [
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: 'domain-info',
|
|
||||||
path: '/domains/:name',
|
path: '/domains/:name',
|
||||||
component: () => import(/* webpackChunkName: "views/domain/info" */ '@/views/domain/DomainInfo'),
|
component: () => import(/* webpackChunkName: "views/domain/info" */ '@/views/domain/DomainInfo'),
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
|
||||||
args: { param: 'name' },
|
|
||||||
breadcrumb: ['domain-list', 'domain-info']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
// no need for name here, only children are visited
|
|
||||||
path: '/domains/:name/config',
|
|
||||||
component: () => import(/* webpackChunkName: "views/domain/config" */ '@/views/domain/DomainConfig'),
|
|
||||||
props: true,
|
|
||||||
children: [
|
children: [
|
||||||
{
|
{
|
||||||
name: 'domain-config',
|
name: 'domain-info',
|
||||||
path: ':tabId?',
|
path: ':tabId?',
|
||||||
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
|
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
|
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
|
||||||
args: { trad: 'config' },
|
args: { param: 'name' },
|
||||||
breadcrumb: ['domain-list', 'domain-info', 'domain-config']
|
breadcrumb: ['domain-list', 'domain-info']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'domain-dns',
|
|
||||||
path: '/domains/:name/dns',
|
|
||||||
component: () => import(/* webpackChunkName: "views/domain/dns" */ '@/views/domain/DomainDns'),
|
|
||||||
props: true,
|
|
||||||
meta: {
|
|
||||||
args: { trad: 'dns' },
|
|
||||||
breadcrumb: ['domain-list', 'domain-info', 'domain-dns']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: 'domain-cert',
|
|
||||||
path: '/domains/:name/cert-management',
|
|
||||||
component: () => import(/* webpackChunkName: "views/domain/cert" */ '@/views/domain/DomainCert'),
|
|
||||||
props: true,
|
|
||||||
meta: {
|
|
||||||
args: { trad: 'certificate' },
|
|
||||||
breadcrumb: ['domain-list', 'domain-info', 'domain-cert']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ───────╮
|
/* ───────╮
|
||||||
│ APPS │
|
│ APPS │
|
||||||
|
@ -241,16 +210,6 @@ const routes = [
|
||||||
breadcrumb: ['app-list', 'app-info']
|
breadcrumb: ['app-list', 'app-info']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'app-actions',
|
|
||||||
path: '/apps/:id/actions',
|
|
||||||
component: () => import(/* webpackChunkName: "views/apps/actions" */ '@/views/app/AppActions'),
|
|
||||||
props: true,
|
|
||||||
meta: {
|
|
||||||
args: { trad: 'app_actions' },
|
|
||||||
breadcrumb: ['app-list', 'app-info', 'app-actions']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
// no need for name here, only children are visited
|
// no need for name here, only children are visited
|
||||||
path: '/apps/:id/config-panel',
|
path: '/apps/:id/config-panel',
|
||||||
|
@ -293,7 +252,7 @@ const routes = [
|
||||||
component: () => import(/* webpackChunkName: "views/service/list" */ '@/views/service/ServiceList'),
|
component: () => import(/* webpackChunkName: "views/service/list" */ '@/views/service/ServiceList'),
|
||||||
meta: {
|
meta: {
|
||||||
args: { trad: 'services' },
|
args: { trad: 'services' },
|
||||||
breadcrumb: ['service-list']
|
breadcrumb: ['tool-list', 'service-list']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
@ -303,7 +262,7 @@ const routes = [
|
||||||
props: true,
|
props: true,
|
||||||
meta: {
|
meta: {
|
||||||
args: { param: 'name' },
|
args: { param: 'name' },
|
||||||
breadcrumb: ['service-list', 'service-info']
|
breadcrumb: ['tool-list', 'service-list', 'service-info']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -356,15 +315,6 @@ const routes = [
|
||||||
breadcrumb: ['tool-list', 'tool-firewall']
|
breadcrumb: ['tool-list', 'tool-firewall']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: 'tool-adminpw',
|
|
||||||
path: '/tools/adminpw',
|
|
||||||
component: () => import(/* webpackChunkName: "views/tools/adminpw" */ '@/views/tool/ToolAdminpw'),
|
|
||||||
meta: {
|
|
||||||
args: { trad: 'tools_adminpw' },
|
|
||||||
breadcrumb: ['tool-list', 'tool-adminpw']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: 'tool-webadmin',
|
name: 'tool-webadmin',
|
||||||
path: '/tools/webadmin',
|
path: '/tools/webadmin',
|
||||||
|
@ -374,6 +324,23 @@ const routes = [
|
||||||
breadcrumb: ['tool-list', 'tool-webadmin']
|
breadcrumb: ['tool-list', 'tool-webadmin']
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/tools/settings',
|
||||||
|
component: () => import(/* webpackChunkName: "views/tools/settings" */ '@/views/tool/ToolSettings'),
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
name: 'tool-settings',
|
||||||
|
path: ':tabId?',
|
||||||
|
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
|
||||||
|
props: true,
|
||||||
|
meta: {
|
||||||
|
routerParams: [],
|
||||||
|
args: { trad: 'tools_yunohost_settings' },
|
||||||
|
breadcrumb: ['tool-list', 'tool-settings']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
name: 'tool-power',
|
name: 'tool-power',
|
||||||
path: '/tools/power',
|
path: '/tools/power',
|
||||||
|
|
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-size-base: .9rem;
|
||||||
$font-weight-bold: 500;
|
$font-weight-bold: 500;
|
||||||
|
|
||||||
$blue: #2f7ed2;
|
$white: var(--white);
|
||||||
$purple: #9932cc;
|
$gray-100: var(--gray-100);
|
||||||
$yellow: #ffd452;
|
$gray-200: var(--gray-200);
|
||||||
|
$gray-300: var(--gray-300);
|
||||||
|
$gray-400: var(--gray-400);
|
||||||
|
$gray-500: var(--gray-500);
|
||||||
|
$gray-600: var(--gray-600);
|
||||||
|
$gray-700: var(--gray-700);
|
||||||
|
$gray-800: var(--gray-800);
|
||||||
|
$gray-900: var(--gray-900);
|
||||||
|
$black: var(--black);
|
||||||
|
|
||||||
|
$blue: var(--blue);
|
||||||
|
$indigo: var(--indigo);
|
||||||
|
$purple: var(--purple);
|
||||||
|
$pink: var(--pink);
|
||||||
|
$red: var(--red);
|
||||||
|
$orange: var(--orange);
|
||||||
|
$yellow: var(--yellow);
|
||||||
|
$green: var(--green);
|
||||||
|
$teal: var(--teal);
|
||||||
|
$cyan: var(--cyan);
|
||||||
|
|
||||||
$theme-colors: (
|
$theme-colors: (
|
||||||
'best': $purple
|
'best': $purple,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
$yiq-contrasted-threshold: var(--yiq-contrasted-threshold);
|
||||||
|
|
||||||
|
$alert-bg-level: -10;
|
||||||
|
$alert-border-level: -9;
|
||||||
|
$alert-color-level: 5;
|
||||||
|
|
||||||
|
$list-group-item-bg-level: -11;
|
||||||
|
$list-group-item-color-level: 6;
|
||||||
|
|
||||||
|
$code-color: var(--code-color);
|
||||||
|
|
||||||
// Replace font-weight 300 with 200
|
// Replace font-weight 300 with 200
|
||||||
$font-weight-light: 200;
|
$font-weight-light: 200;
|
||||||
$display1-weight: 200;
|
$display1-weight: 200;
|
||||||
|
@ -62,15 +92,17 @@ $card-spacer-x: 1rem;
|
||||||
|
|
||||||
$list-group-item-padding-x: 1rem;
|
$list-group-item-padding-x: 1rem;
|
||||||
|
|
||||||
|
// Hard coded for scss compilation to pass
|
||||||
|
$b-toast-background-opacity: 100%;
|
||||||
|
|
||||||
// Import default variables after the above setup to compute all other variables.
|
// Import default variables after the above setup to compute all other variables.
|
||||||
@import '~bootstrap/scss/functions.scss';
|
@import '~bootstrap/scss/functions.scss';
|
||||||
|
@import '_functions-override.scss';
|
||||||
@import '~bootstrap/scss/variables';
|
@import '~bootstrap/scss/variables';
|
||||||
@import '~bootstrap/scss/mixins.scss';
|
@import '~bootstrap/scss/mixins.scss';
|
||||||
@import '~bootstrap-vue/src/variables';
|
@import '~bootstrap-vue/src/variables';
|
||||||
|
|
||||||
|
|
||||||
$body-color: $gray-800;
|
|
||||||
|
|
||||||
$hr-border-color: $gray-200;
|
$hr-border-color: $gray-200;
|
||||||
|
|
||||||
$list-group-action-color: $gray-800;
|
$list-group-action-color: $gray-800;
|
||||||
|
@ -97,3 +129,7 @@ $fa-font-size-base: $font-size-base;
|
||||||
// ╰────────────────────╯
|
// ╰────────────────────╯
|
||||||
|
|
||||||
$thin-border: $hr-border-width solid $hr-border-color;
|
$thin-border: $hr-border-width solid $hr-border-color;
|
||||||
|
|
||||||
|
$btn-padding-y-xs: .25rem;
|
||||||
|
$btn-padding-x-xs: .35rem;
|
||||||
|
$btn-line-height-xs: 1.5;
|
||||||
|
|
|
@ -8,13 +8,131 @@
|
||||||
|
|
||||||
// Dependencies SCSS imports
|
// Dependencies SCSS imports
|
||||||
// `~` allow to import a node_modules folder (resolved by Webpack)
|
// `~` allow to import a node_modules folder (resolved by Webpack)
|
||||||
@import '~bootstrap/scss/bootstrap.scss';
|
// @import "~bootstrap/scss/root";
|
||||||
|
@import "~bootstrap/scss/reboot";
|
||||||
|
@import "~bootstrap/scss/type";
|
||||||
|
@import "~bootstrap/scss/images";
|
||||||
|
@import "~bootstrap/scss/code";
|
||||||
|
@import "~bootstrap/scss/grid";
|
||||||
|
@import "~bootstrap/scss/tables";
|
||||||
|
@import "~bootstrap/scss/forms";
|
||||||
|
@import "~bootstrap/scss/buttons";
|
||||||
|
@import "~bootstrap/scss/transitions";
|
||||||
|
@import "~bootstrap/scss/dropdown";
|
||||||
|
@import "~bootstrap/scss/button-group";
|
||||||
|
@import "~bootstrap/scss/input-group";
|
||||||
|
@import "~bootstrap/scss/custom-forms";
|
||||||
|
@import "~bootstrap/scss/nav";
|
||||||
|
@import "~bootstrap/scss/navbar";
|
||||||
|
@import "~bootstrap/scss/card";
|
||||||
|
@import "~bootstrap/scss/breadcrumb";
|
||||||
|
// @import "~bootstrap/scss/pagination";
|
||||||
|
@import "~bootstrap/scss/badge";
|
||||||
|
// @import "~bootstrap/scss/jumbotron";
|
||||||
|
@import "~bootstrap/scss/alert";
|
||||||
|
@import "~bootstrap/scss/progress";
|
||||||
|
// @import "~bootstrap/scss/media";
|
||||||
|
@import "~bootstrap/scss/list-group";
|
||||||
|
@import "~bootstrap/scss/close";
|
||||||
|
// @import "~bootstrap/scss/toasts";
|
||||||
|
@import "~bootstrap/scss/modal";
|
||||||
|
@import "~bootstrap/scss/tooltip";
|
||||||
|
@import "~bootstrap/scss/popover";
|
||||||
|
// @import "~bootstrap/scss/carousel";
|
||||||
|
@import "~bootstrap/scss/spinners";
|
||||||
|
@import "~bootstrap/scss/utilities";
|
||||||
|
// @import "~bootstrap/scss/print";
|
||||||
|
|
||||||
@import '~bootstrap-vue/src/index.scss';
|
@import '~bootstrap-vue/src/index.scss';
|
||||||
|
|
||||||
// Import fonts
|
// Import fonts
|
||||||
@import 'font';
|
@import 'font';
|
||||||
@import '~fork-awesome/scss/fork-awesome.scss';
|
@import '~fork-awesome/scss/fork-awesome.scss';
|
||||||
|
|
||||||
|
// helper to set the required 4 CSS variables per color to allow `calc` computation of variants and states
|
||||||
|
@mixin hsl-color($name, $h, $s, $l) {
|
||||||
|
--#{$name}: hsl(#{$h}, #{$s}, #{$l});
|
||||||
|
--#{$name}-h: #{$h};
|
||||||
|
--#{$name}-s: #{$s};
|
||||||
|
--#{$name}-l: #{$l};
|
||||||
|
}
|
||||||
|
|
||||||
|
:root {
|
||||||
|
color-scheme: light;
|
||||||
|
|
||||||
|
--yiq-contrasted-threshold: 150;
|
||||||
|
|
||||||
|
@include hsl-color('white', 0, 0%, 100%);
|
||||||
|
@include hsl-color('black', 0, 0%, 0%);
|
||||||
|
@include hsl-color('blue', 211, 64%, 50%);
|
||||||
|
@include hsl-color('indigo', 263, 90%, 51%);
|
||||||
|
@include hsl-color('purple', 280, 61%, 50%);
|
||||||
|
@include hsl-color('pink', 332, 79%, 58%);
|
||||||
|
@include hsl-color('red', 354, 70%, 54%);
|
||||||
|
@include hsl-color('orange', 27, 98%, 54%);
|
||||||
|
@include hsl-color('yellow', 45, 100%, 66%);
|
||||||
|
@include hsl-color('green', 134, 61%, 41%);
|
||||||
|
@include hsl-color('teal', 162, 73%, 46%);
|
||||||
|
@include hsl-color('cyan', 188, 78%, 41%);
|
||||||
|
|
||||||
|
@include hsl-color('gray-100', 210, 17%, 98%);
|
||||||
|
@include hsl-color('gray-200', 210, 16%, 93%);
|
||||||
|
@include hsl-color('gray-300', 210, 14%, 89%);
|
||||||
|
@include hsl-color('gray-400', 210, 14%, 83%);
|
||||||
|
@include hsl-color('gray-500', 210, 11%, 71%);
|
||||||
|
@include hsl-color('gray-600', 208, 7%, 46%);
|
||||||
|
@include hsl-color('gray-700', 210, 9%, 31%);
|
||||||
|
@include hsl-color('gray-800', 210, 10%, 23%);
|
||||||
|
@include hsl-color('gray-900', 210, 11%, 15%);
|
||||||
|
|
||||||
|
--code-color: var(--pink);
|
||||||
|
|
||||||
|
// Overwrite list-group-item variants to lighter ones (used in diagnosis for example)
|
||||||
|
@each $color, $value in $theme-colors {
|
||||||
|
@include list-group-item-variant($color, theme-color-level($color, $list-group-item-bg-level), theme-color-level($color, $list-group-item-color-level));
|
||||||
|
|
||||||
|
.btn-#{$color} {
|
||||||
|
&:focus,
|
||||||
|
&.focus {
|
||||||
|
box-shadow: 0 0 0 $btn-focus-width rgba($value, .3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
[dark-theme="true"] {
|
||||||
|
color-scheme: dark; // Ask browser to use dark mode native styling
|
||||||
|
|
||||||
|
--yiq-contrasted-threshold: 120;
|
||||||
|
|
||||||
|
@include hsl-color('white', 256, 0%, 12.5%);
|
||||||
|
@include hsl-color('black', 256, 0%, 100%);
|
||||||
|
@include hsl-color('blue', 210.7, 95.5%, 65.5%);
|
||||||
|
@include hsl-color('purple', 280, 77.8%, 62.9%);
|
||||||
|
@include hsl-color('red', 0, 100%, 67.6%);
|
||||||
|
@include hsl-color('green', 134.3, 74.4%, 67.8%);
|
||||||
|
@include hsl-color('cyan', 188.4, 91.4%, 72.5%);
|
||||||
|
|
||||||
|
@include hsl-color('gray-900', 256, 0%, 98%);
|
||||||
|
@include hsl-color('gray-800', 256, 0%, 93%);
|
||||||
|
@include hsl-color('gray-700', 256, 0%, 89%);
|
||||||
|
@include hsl-color('gray-600', 256, 0%, 83%);
|
||||||
|
@include hsl-color('gray-500', 256, 0%, 71%);
|
||||||
|
@include hsl-color('gray-400', 256, 0%, 46%);
|
||||||
|
@include hsl-color('gray-300', 256, 0%, 31%);
|
||||||
|
@include hsl-color('gray-200', 256, 0%, 23%);
|
||||||
|
@include hsl-color('gray-100', 256, 0%, 15%);
|
||||||
|
|
||||||
|
--code-color: var(--gray-800);
|
||||||
|
|
||||||
|
@each $color, $value in $theme-colors {
|
||||||
|
@include list-group-item-variant($color, theme-color-level($color, -6), theme-color-level($color, 2));
|
||||||
|
|
||||||
|
.alert-#{$color} {
|
||||||
|
@include alert-variant(theme-color-level($color, -6), theme-color-level($color, -5), theme-color-level($color, 2));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Style overrides happens after dependencies imports
|
// Style overrides happens after dependencies imports
|
||||||
|
|
||||||
|
@ -30,7 +148,7 @@ body {
|
||||||
#app {
|
#app {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 100vh
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
.menu-list .list-group-item {
|
.menu-list .list-group-item {
|
||||||
|
@ -40,13 +158,6 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
// Bootstrap overrides
|
|
||||||
|
|
||||||
// Overwrite list-group-item variants to lighter ones (used in diagnosis for example)
|
|
||||||
@each $color, $value in $theme-colors {
|
|
||||||
@include list-group-item-variant($color, theme-color-level($color, -11), theme-color-level($color, 6));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Add breakpoints for w-*
|
// Add breakpoints for w-*
|
||||||
@each $breakpoint in map-keys($grid-breakpoints) {
|
@each $breakpoint in map-keys($grid-breakpoints) {
|
||||||
@each $size, $length in $sizes {
|
@each $size, $length in $sizes {
|
||||||
|
@ -58,16 +169,24 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add xs sized btn
|
||||||
|
.btn-xs {
|
||||||
|
@include button-size($btn-padding-y-xs, $btn-padding-x-xs, $btn-font-size-sm, $btn-line-height-xs, $btn-border-radius-sm);
|
||||||
|
}
|
||||||
|
|
||||||
// Allow state of input group to be displayed under the group
|
// Allow state of input group to be displayed under the group
|
||||||
.input-group .is-invalid ~ .invalid-feedback {
|
.input-group .is-invalid ~ .invalid-feedback {
|
||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
.tooltip { top: 0; }
|
||||||
// Descriptive list (<b-row /> elems with <b-col> inside)
|
// Descriptive list (<b-row /> elems with <b-col> inside)
|
||||||
|
// FIXME REMOVE when every infos switch to `DescriptionRow`
|
||||||
.row-line {
|
.row-line {
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: rgba(0, 0, 0, 0.05);
|
background-color: rgba($black, 0.05);
|
||||||
border-radius: 0.2rem;
|
border-radius: 0.2rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -102,6 +221,11 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
h3.card-title {
|
||||||
|
margin-bottom: 1em;
|
||||||
|
border-bottom: solid 1px $hr-border-color;
|
||||||
|
}
|
||||||
|
|
||||||
// collapse icon
|
// collapse icon
|
||||||
.not-collapsed > .icon {
|
.not-collapsed > .icon {
|
||||||
transform: rotate(-90deg);
|
transform: rotate(-90deg);
|
||||||
|
@ -165,8 +289,18 @@ body {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
code {
|
code {
|
||||||
background: ghostwhite;
|
background: $light;
|
||||||
|
padding: .15rem .25rem;
|
||||||
|
border-radius: $border-radius;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre code {
|
||||||
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.log {
|
.log {
|
||||||
|
|
|
@ -2,12 +2,27 @@ import Vue from 'vue'
|
||||||
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { isEmptyValue } from '@/helpers/commons'
|
import { isEmptyValue } from '@/helpers/commons'
|
||||||
|
import { stratify } from '@/helpers/data/tree'
|
||||||
|
|
||||||
|
|
||||||
|
export function getParentDomain (domain, domains, highest = false) {
|
||||||
|
const method = highest ? 'lastIndexOf' : 'indexOf'
|
||||||
|
let i = domain[method]('.')
|
||||||
|
while (i !== -1) {
|
||||||
|
const dn = domain.slice(i + 1)
|
||||||
|
if (domains.includes(dn)) return dn
|
||||||
|
i = domain[method]('.', i + (highest ? -1 : 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
state: () => ({
|
state: () => ({
|
||||||
domains: undefined, // Array
|
|
||||||
main_domain: undefined,
|
main_domain: undefined,
|
||||||
|
domains: undefined, // Array
|
||||||
|
domains_details: {},
|
||||||
users: undefined, // basic user data: Object {username: {data}}
|
users: undefined, // basic user data: Object {username: {data}}
|
||||||
users_details: {}, // precise user data: Object {username: {data}}
|
users_details: {}, // precise user data: Object {username: {data}}
|
||||||
groups: undefined,
|
groups: undefined,
|
||||||
|
@ -19,6 +34,22 @@ export default {
|
||||||
state.domains = domains
|
state.domains = domains
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'SET_DOMAINS_DETAILS' (state, [name, details]) {
|
||||||
|
Vue.set(state.domains_details, name, details)
|
||||||
|
},
|
||||||
|
|
||||||
|
'UPDATE_DOMAINS_DETAILS' (state, payload) {
|
||||||
|
// FIXME use a common function to execute the same code ?
|
||||||
|
this.commit('SET_DOMAINS_DETAILS', payload)
|
||||||
|
},
|
||||||
|
|
||||||
|
'DEL_DOMAINS_DETAILS' (state, [name]) {
|
||||||
|
Vue.delete(state.domains_details, name)
|
||||||
|
if (state.domains) {
|
||||||
|
Vue.delete(state.domains, name)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
'ADD_DOMAINS' (state, [{ domain }]) {
|
'ADD_DOMAINS' (state, [{ domain }]) {
|
||||||
state.domains.push(domain)
|
state.domains.push(domain)
|
||||||
},
|
},
|
||||||
|
@ -48,12 +79,11 @@ export default {
|
||||||
Vue.set(state.users_details, username, userData)
|
Vue.set(state.users_details, username, userData)
|
||||||
if (!state.users) return
|
if (!state.users) return
|
||||||
const user = state.users[username]
|
const user = state.users[username]
|
||||||
for (const key of ['firstname', 'lastname', 'mail']) {
|
for (const key of ['fullname', 'mail']) {
|
||||||
if (user[key] !== userData[key]) {
|
if (user[key] !== userData[key]) {
|
||||||
Vue.set(user, key, userData[key])
|
Vue.set(user, key, userData[key])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Vue.set(user, 'fullname', `${userData.firstname} ${userData.lastname}`)
|
|
||||||
},
|
},
|
||||||
|
|
||||||
'UPDATE_USERS_DETAILS' (state, payload) {
|
'UPDATE_USERS_DETAILS' (state, payload) {
|
||||||
|
@ -174,6 +204,44 @@ export default {
|
||||||
|
|
||||||
domains: state => state.domains,
|
domains: state => state.domains,
|
||||||
|
|
||||||
|
orderedDomains: state => {
|
||||||
|
if (!state.domains) return
|
||||||
|
|
||||||
|
const splittedDomains = Object.fromEntries(state.domains.map(domain => {
|
||||||
|
// Keep the main part of the domain and the extension together
|
||||||
|
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
|
||||||
|
domain = domain.split('.')
|
||||||
|
domain.push(domain.pop() + domain.pop())
|
||||||
|
return [domain, domain.reverse()]
|
||||||
|
}))
|
||||||
|
|
||||||
|
return state.domains.sort((a, b) => splittedDomains[a] > splittedDomains[b])
|
||||||
|
},
|
||||||
|
|
||||||
|
domainsTree: (state, getters) => {
|
||||||
|
// This getter will not return any reactive data, make sure to assign its output
|
||||||
|
// to a component's `data`.
|
||||||
|
// FIXME manage to store the result in the store to allow reactive data (trigger an
|
||||||
|
// action when state.domain change)
|
||||||
|
const domains = getters.orderedDomains
|
||||||
|
if (!domains) return
|
||||||
|
const dataset = domains.map(name => ({
|
||||||
|
// data to build a hierarchy
|
||||||
|
name,
|
||||||
|
parent: getParentDomain(name, domains),
|
||||||
|
// utility data that will be used by `RecursiveListGroup` component
|
||||||
|
to: { name: 'domain-info', params: { name } },
|
||||||
|
opened: true
|
||||||
|
}))
|
||||||
|
return stratify(dataset)
|
||||||
|
},
|
||||||
|
|
||||||
|
domain: state => name => state.domains_details[name],
|
||||||
|
|
||||||
|
highestDomainParentName: (state, getters) => name => {
|
||||||
|
return getParentDomain(name, getters.orderedDomains, true)
|
||||||
|
},
|
||||||
|
|
||||||
mainDomain: state => state.main_domain,
|
mainDomain: state => state.main_domain,
|
||||||
|
|
||||||
domainsAsChoices: state => {
|
domainsAsChoices: state => {
|
||||||
|
|
|
@ -142,8 +142,8 @@ export default {
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
||||||
'LOGIN' ({ dispatch }, password) {
|
'LOGIN' ({ dispatch }, credentials) {
|
||||||
return api.post('login', { credentials: password }, null, { websocket: false }).then(() => {
|
return api.post('login', { credentials }, null, { websocket: false }).then(() => {
|
||||||
dispatch('CONNECT')
|
dispatch('CONNECT')
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
|
|
|
@ -13,6 +13,7 @@ export default {
|
||||||
fallbackLocale: localStorage.getItem('fallbackLocale'),
|
fallbackLocale: localStorage.getItem('fallbackLocale'),
|
||||||
cache: localStorage.getItem('cache') !== 'false',
|
cache: localStorage.getItem('cache') !== 'false',
|
||||||
transitions: localStorage.getItem('transitions') !== 'false',
|
transitions: localStorage.getItem('transitions') !== 'false',
|
||||||
|
theme: localStorage.getItem('theme') !== 'false',
|
||||||
experimental: localStorage.getItem('experimental') === 'true',
|
experimental: localStorage.getItem('experimental') === 'true',
|
||||||
spinner: 'pacman',
|
spinner: 'pacman',
|
||||||
supportedLocales: supportedLocales
|
supportedLocales: supportedLocales
|
||||||
|
@ -46,6 +47,12 @@ export default {
|
||||||
|
|
||||||
'SET_SPINNER' (state, spinner) {
|
'SET_SPINNER' (state, spinner) {
|
||||||
state.spinner = spinner
|
state.spinner = spinner
|
||||||
|
},
|
||||||
|
|
||||||
|
'SET_THEME' (state, boolean) {
|
||||||
|
localStorage.setItem('theme', boolean)
|
||||||
|
state.theme = boolean
|
||||||
|
document.documentElement.setAttribute('dark-theme', boolean)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -65,6 +72,10 @@ export default {
|
||||||
commit('SET_FALLBACKLOCALE', locale)
|
commit('SET_FALLBACKLOCALE', locale)
|
||||||
i18n.fallbackLocale = [locale, 'en']
|
i18n.fallbackLocale = [locale, 'en']
|
||||||
})
|
})
|
||||||
|
},
|
||||||
|
|
||||||
|
'UPDATE_THEME' ({ commit }, theme) {
|
||||||
|
commit('SET_THEME', theme)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -73,6 +84,7 @@ export default {
|
||||||
fallbackLocale: state => (state.fallbackLocale),
|
fallbackLocale: state => (state.fallbackLocale),
|
||||||
cache: state => (state.cache),
|
cache: state => (state.cache),
|
||||||
transitions: state => (state.transitions),
|
transitions: state => (state.transitions),
|
||||||
|
theme: state => (state.theme),
|
||||||
experimental: state => state.experimental,
|
experimental: state => state.experimental,
|
||||||
spinner: state => state.spinner,
|
spinner: state => state.spinner,
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,6 @@ export default {
|
||||||
{ routeName: 'domain-list', icon: 'globe', translation: 'domains' },
|
{ routeName: 'domain-list', icon: 'globe', translation: 'domains' },
|
||||||
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' },
|
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' },
|
||||||
{ routeName: 'update', icon: 'refresh', translation: 'system_update' },
|
{ routeName: 'update', icon: 'refresh', translation: 'system_update' },
|
||||||
{ routeName: 'service-list', icon: 'cog', translation: 'services' },
|
|
||||||
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
|
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
|
||||||
{ routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' },
|
{ routeName: 'diagnosis', icon: 'stethoscope', translation: 'diagnosis' },
|
||||||
{ routeName: 'backup', icon: 'archive', translation: 'backup' }
|
{ routeName: 'backup', icon: 'archive', translation: 'backup' }
|
||||||
|
|
|
@ -1,37 +1,35 @@
|
||||||
<template>
|
<template>
|
||||||
<b-form @submit.prevent="login">
|
<card-form
|
||||||
<b-input-group>
|
:title="$t('login')" icon="lock"
|
||||||
<template v-slot:prepend>
|
:validation="$v" :server-error="serverError"
|
||||||
<b-input-group-text>
|
@submit.prevent="login"
|
||||||
<label class="sr-only" for="input-password">{{ $t('password') }}</label>
|
>
|
||||||
<icon iname="lock" class="sm" />
|
<!-- ADMIN USERNAME -->
|
||||||
</b-input-group-text>
|
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
|
||||||
</template>
|
|
||||||
|
|
||||||
<b-form-input
|
<!-- ADMIN PASSWORD -->
|
||||||
id="input-password"
|
<form-field v-bind="fields.password" v-model="form.password" :validation="$v.form.password" />
|
||||||
required type="password"
|
|
||||||
v-model="password"
|
|
||||||
:placeholder="$t('administration_password')" :state="isValid"
|
|
||||||
/>
|
|
||||||
|
|
||||||
<template v-slot:append>
|
<template #buttons>
|
||||||
<b-button type="submit" variant="success" :disabled="disabled">
|
<b-button
|
||||||
{{ $t('login') }}
|
type="submit" variant="success"
|
||||||
</b-button>
|
:disabled="disabled" form="ynh-form"
|
||||||
</template>
|
>
|
||||||
</b-input-group>
|
{{ $t('login') }}
|
||||||
|
</b-button>
|
||||||
<b-form-invalid-feedback :state="isValid">
|
</template>
|
||||||
{{ $t('wrong_password') }}
|
</card-form>
|
||||||
</b-form-invalid-feedback>
|
|
||||||
</b-form>
|
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { validationMixin } from 'vuelidate'
|
||||||
|
import { alphalownum_, required, minLength } from '@/helpers/validators'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'Login',
|
name: 'Login',
|
||||||
|
|
||||||
|
mixins: [validationMixin],
|
||||||
|
|
||||||
props: {
|
props: {
|
||||||
skipInstallCheck: { type: Boolean, default: false },
|
skipInstallCheck: { type: Boolean, default: false },
|
||||||
forceReload: { type: Boolean, default: false }
|
forceReload: { type: Boolean, default: false }
|
||||||
|
@ -40,15 +38,42 @@ export default {
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
disabled: !this.skipInstallCheck,
|
disabled: !this.skipInstallCheck,
|
||||||
password: '',
|
serverError: '',
|
||||||
isValid: null,
|
form: {
|
||||||
apiError: undefined
|
username: '',
|
||||||
|
password: ''
|
||||||
|
},
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: this.$i18n.t('user_username'),
|
||||||
|
props: {
|
||||||
|
id: 'username'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: this.$i18n.t('password'),
|
||||||
|
props: {
|
||||||
|
id: 'password',
|
||||||
|
type: 'password'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
validations () {
|
||||||
|
return {
|
||||||
|
form: {
|
||||||
|
username: { required, alphalownum_ },
|
||||||
|
password: { required, passwordLenght: minLength(8) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
login () {
|
login () {
|
||||||
this.$store.dispatch('LOGIN', this.password).then(() => {
|
const credentials = [this.form.username, this.form.password].join(':')
|
||||||
|
this.$store.dispatch('LOGIN', credentials).then(() => {
|
||||||
if (this.forceReload) {
|
if (this.forceReload) {
|
||||||
window.location.href = '/yunohost/admin/'
|
window.location.href = '/yunohost/admin/'
|
||||||
} else {
|
} else {
|
||||||
|
@ -56,7 +81,7 @@ export default {
|
||||||
}
|
}
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
if (err.name !== 'APIUnauthorizedError') throw err
|
if (err.name !== 'APIUnauthorizedError') throw err
|
||||||
this.isValid = false
|
this.serverError = this.$i18n.t('wrong_password_or_username')
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
|
@ -12,7 +12,7 @@
|
||||||
<span v-html="$t('postinstall_intro_3')" />
|
<span v-html="$t('postinstall_intro_3')" />
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<b-button size="lg" variant="primary" @click="goToStep('domain')">
|
<b-button size="lg" variant="success" @click="goToStep('domain')">
|
||||||
{{ $t('begin') }}
|
{{ $t('begin') }}
|
||||||
</b-button>
|
</b-button>
|
||||||
</template>
|
</template>
|
||||||
|
@ -33,16 +33,23 @@
|
||||||
</b-button>
|
</b-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- PASSWORD SETUP STEP -->
|
<!-- FIRST USER SETUP STEP -->
|
||||||
<template v-else-if="step === 'password'">
|
<template v-else-if="step === 'user'">
|
||||||
<password-form
|
<card-form
|
||||||
:title="$t('postinstall_set_password')" :submit-text="$t('next')" :server-error="serverError"
|
:title="$t('postinstall.user.title')" icon="user-plus"
|
||||||
@submit="setPassword"
|
:validation="$v" :server-error="serverError"
|
||||||
|
:submit-text="$t('next')" @submit.prevent="setUser"
|
||||||
>
|
>
|
||||||
<template #disclaimer>
|
<read-only-alert-item
|
||||||
<p class="alert alert-warning" v-t="'postinstall_password'" />
|
:label="$t('postinstall.user.first_user_help')"
|
||||||
</template>
|
type="info"
|
||||||
</password-form>
|
/>
|
||||||
|
|
||||||
|
<form-field
|
||||||
|
v-for="(field, name) in fields" :key="name"
|
||||||
|
v-bind="field" v-model="user[name]" :validation="$v.user[name]"
|
||||||
|
/>
|
||||||
|
</card-form>
|
||||||
|
|
||||||
<b-button variant="primary" @click="goToStep('domain')" class="mt-3">
|
<b-button variant="primary" @click="goToStep('domain')" class="mt-3">
|
||||||
<icon iname="chevron-left" /> {{ $t('previous') }}
|
<icon iname="chevron-left" /> {{ $t('previous') }}
|
||||||
|
@ -74,25 +81,58 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { validationMixin } from 'vuelidate'
|
||||||
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { DomainForm, PasswordForm } from '@/views/_partials'
|
import { DomainForm } from '@/views/_partials'
|
||||||
import Login from '@/views/Login'
|
import Login from '@/views/Login'
|
||||||
|
import { alphalownum_, required, minLength, name, sameAs } from '@/helpers/validators'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'PostInstall',
|
name: 'PostInstall',
|
||||||
|
|
||||||
|
mixins: [validationMixin],
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
DomainForm,
|
DomainForm,
|
||||||
PasswordForm,
|
|
||||||
Login
|
Login
|
||||||
},
|
},
|
||||||
|
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
step: 'start',
|
step: 'start',
|
||||||
|
serverError: '',
|
||||||
domain: undefined,
|
domain: undefined,
|
||||||
password: undefined,
|
user: {
|
||||||
serverError: ''
|
username: '',
|
||||||
|
fullname: '',
|
||||||
|
password: '',
|
||||||
|
confirmation: ''
|
||||||
|
},
|
||||||
|
|
||||||
|
fields: {
|
||||||
|
username: {
|
||||||
|
label: this.$i18n.t('user_username'),
|
||||||
|
props: { id: 'username', placeholder: this.$i18n.t('placeholder.username') }
|
||||||
|
},
|
||||||
|
|
||||||
|
fullname: {
|
||||||
|
label: this.$i18n.t('user_fullname'),
|
||||||
|
props: { id: 'fullname', placeholder: this.$i18n.t('placeholder.fullname') }
|
||||||
|
},
|
||||||
|
|
||||||
|
password: {
|
||||||
|
label: this.$i18n.t('password'),
|
||||||
|
description: this.$i18n.t('good_practices_about_admin_password'),
|
||||||
|
descriptionVariant: 'warning',
|
||||||
|
props: { id: 'password', placeholder: '••••••••', type: 'password' }
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmation: {
|
||||||
|
label: this.$i18n.t('password_confirmation'),
|
||||||
|
props: { id: 'confirmation', placeholder: '••••••••', type: 'password' }
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -104,11 +144,10 @@ export default {
|
||||||
|
|
||||||
setDomain ({ domain }) {
|
setDomain ({ domain }) {
|
||||||
this.domain = domain
|
this.domain = domain
|
||||||
this.goToStep('password')
|
this.goToStep('user')
|
||||||
},
|
},
|
||||||
|
|
||||||
async setPassword ({ password }) {
|
async setUser () {
|
||||||
this.password = password
|
|
||||||
const confirmed = await this.$askConfirmation(
|
const confirmed = await this.$askConfirmation(
|
||||||
this.$i18n.t('confirm_postinstall', { domain: this.domain })
|
this.$i18n.t('confirm_postinstall', { domain: this.domain })
|
||||||
)
|
)
|
||||||
|
@ -117,22 +156,29 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
performPostInstall (force = false) {
|
performPostInstall (force = false) {
|
||||||
|
const data = {
|
||||||
|
domain: this.domain,
|
||||||
|
username: this.user.username,
|
||||||
|
fullname: this.user.fullname,
|
||||||
|
password: this.user.password
|
||||||
|
}
|
||||||
// FIXME does the api will throw an error for bad passwords ?
|
// FIXME does the api will throw an error for bad passwords ?
|
||||||
api.post(
|
api.post(
|
||||||
'postinstall' + (force ? '?force_diskspace' : ''),
|
'postinstall' + (force ? '?force_diskspace' : ''),
|
||||||
{ domain: this.domain, password: this.password },
|
data,
|
||||||
{ key: 'postinstall' }
|
{ key: 'postinstall' }
|
||||||
).then(() => {
|
).then(() => {
|
||||||
// Display success message and allow the user to login
|
// Display success message and allow the user to login
|
||||||
this.goToStep('login')
|
this.goToStep('login')
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
|
const hasWordsInError = (words) => words.some((word) => (err.key || err.message).includes(word))
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
if (err.name !== 'APIBadRequestError') throw err
|
||||||
if (err.key === 'postinstall_low_rootfsspace') {
|
if (err.key === 'postinstall_low_rootfsspace') {
|
||||||
this.step = 'rootfsspace-error'
|
this.step = 'rootfsspace-error'
|
||||||
} else if (err.key.includes('password')) {
|
} else if (hasWordsInError(['domain', 'dyndns'])) {
|
||||||
this.step = 'password'
|
|
||||||
} else if (['domain', 'dyndns'].some(word => err.key.includes(word))) {
|
|
||||||
this.step = 'domain'
|
this.step = 'domain'
|
||||||
|
} else if (hasWordsInError(['password', 'user'])) {
|
||||||
|
this.step = 'user'
|
||||||
} else {
|
} else {
|
||||||
throw err
|
throw err
|
||||||
}
|
}
|
||||||
|
@ -141,6 +187,17 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
validations () {
|
||||||
|
return {
|
||||||
|
user: {
|
||||||
|
username: { required, alphalownum_ },
|
||||||
|
fullname: { required, name },
|
||||||
|
password: { required, passwordLenght: minLength(8) },
|
||||||
|
confirmation: { required, passwordMatch: sameAs('password') }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
created () {
|
created () {
|
||||||
this.$store.dispatch('CHECK_INSTALL').then(installed => {
|
this.$store.dispatch('CHECK_INSTALL').then(installed => {
|
||||||
if (installed) {
|
if (installed) {
|
||||||
|
|
|
@ -126,12 +126,10 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
onSubmit () {
|
async onSubmit () {
|
||||||
const domainType = this.selected
|
const domainType = this.selected
|
||||||
this.$emit('submit', {
|
const domain = await formatFormDataValue(this.form[domainType])
|
||||||
domain: formatFormDataValue(this.form[domainType]),
|
this.$emit('submit', { domain, domainType })
|
||||||
domainType
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -207,6 +207,7 @@ export default {
|
||||||
border-bottom-left-radius: 0;
|
border-bottom-left-radius: 0;
|
||||||
font-size: $font-size-sm;
|
font-size: $font-size-sm;
|
||||||
|
|
||||||
|
|
||||||
& > header {
|
& > header {
|
||||||
cursor: ns-resize;
|
cursor: ns-resize;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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 ViewLockOverlay } from './ViewLockOverlay'
|
||||||
|
|
||||||
export { default as DomainForm } from './DomainForm'
|
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">
|
<b-card-title class="d-flex mb-2">
|
||||||
{{ app.manifest.name }}
|
{{ app.manifest.name }}
|
||||||
|
|
||||||
<small v-if="app.state !== 'working'" class="d-flex align-items-center ml-2">
|
<small v-if="app.state !== 'working' || app.high_quality" class="d-flex align-items-center ml-2">
|
||||||
<b-badge
|
<b-badge
|
||||||
v-if="app.state !== 'highquality'"
|
v-if="app.state !== 'working'"
|
||||||
:variant="(app.color === 'danger' && app.state === 'lowquality') ? 'warning' : app.color"
|
:variant="app.color"
|
||||||
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
|
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
|
||||||
>
|
>
|
||||||
|
<!-- app.state can be 'lowquality' or 'inprogress' -->
|
||||||
{{ $t('app_state_' + app.state) }}
|
{{ $t('app_state_' + app.state) }}
|
||||||
</b-badge>
|
</b-badge>
|
||||||
|
|
||||||
<icon
|
<icon
|
||||||
v-else iname="star" class="star"
|
v-if="app.high_quality" iname="star" class="star"
|
||||||
v-b-popover.hover.bottom="$t(`app_state_${app.state}_explanation`)"
|
v-b-popover.hover.bottom="$t(`app_state_highquality_explanation`)"
|
||||||
/>
|
/>
|
||||||
</small>
|
</small>
|
||||||
</b-card-title>
|
</b-card-title>
|
||||||
|
|
||||||
<b-card-text>{{ app.manifest.description }}</b-card-text>
|
<b-card-text>{{ app.manifest.description }}</b-card-text>
|
||||||
|
|
||||||
<b-card-text v-if="app.maintained === 'orphaned'" class="align-self-end mt-auto">
|
<b-card-text v-if="!app.maintained" class="align-self-end mt-auto">
|
||||||
<span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')">
|
<span class="alert-warning p-1" v-b-popover.hover.top="$t('orphaned_details')">
|
||||||
<icon iname="warning" /> {{ $t(app.maintained) }}
|
<icon iname="warning" /> {{ $t('orphaned') }}
|
||||||
</span>
|
</span>
|
||||||
</b-card-text>
|
</b-card-text>
|
||||||
</b-card-body>
|
</b-card-body>
|
||||||
|
@ -182,9 +183,9 @@ export default {
|
||||||
|
|
||||||
// Filtering options
|
// Filtering options
|
||||||
qualityOptions: [
|
qualityOptions: [
|
||||||
{ value: 'isHighQuality', text: this.$i18n.t('only_highquality_apps') },
|
{ value: 'high_quality', text: this.$i18n.t('only_highquality_apps') },
|
||||||
{ value: 'isDecentQuality', text: this.$i18n.t('only_decent_quality_apps') },
|
{ value: 'decent_quality', text: this.$i18n.t('only_decent_quality_apps') },
|
||||||
{ value: 'isWorking', text: this.$i18n.t('only_working_apps') },
|
{ value: 'working', text: this.$i18n.t('only_working_apps') },
|
||||||
{ value: 'all', text: this.$i18n.t('all_apps') }
|
{ value: 'all', text: this.$i18n.t('all_apps') }
|
||||||
],
|
],
|
||||||
categories: [
|
categories: [
|
||||||
|
@ -197,7 +198,7 @@ export default {
|
||||||
search: '',
|
search: '',
|
||||||
category: null,
|
category: null,
|
||||||
subtag: 'all',
|
subtag: 'all',
|
||||||
quality: 'isDecentQuality',
|
quality: 'decent_quality',
|
||||||
|
|
||||||
// Custom install form
|
// Custom install form
|
||||||
customInstall: {
|
customInstall: {
|
||||||
|
@ -264,51 +265,31 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
formatQuality (app) {
|
|
||||||
const filters = {
|
|
||||||
isHighQuality: false,
|
|
||||||
isDecentQuality: false,
|
|
||||||
isWorking: false,
|
|
||||||
state: 'inprogress'
|
|
||||||
}
|
|
||||||
if (app.state === 'inprogress') return filters
|
|
||||||
if (app.state === 'working' && app.level > 0) {
|
|
||||||
filters.state = 'working'
|
|
||||||
filters.isWorking = true
|
|
||||||
}
|
|
||||||
if (app.level <= 4 || app.level === '?') {
|
|
||||||
filters.state = 'lowquality'
|
|
||||||
return filters
|
|
||||||
} else {
|
|
||||||
filters.isDecentQuality = true
|
|
||||||
}
|
|
||||||
if (app.level >= 8) {
|
|
||||||
filters.state = 'highquality'
|
|
||||||
filters.isHighQuality = true
|
|
||||||
}
|
|
||||||
return filters
|
|
||||||
},
|
|
||||||
|
|
||||||
formatColor (app) {
|
|
||||||
if (app.isDecentQuality || app.isHighQuality) return 'success'
|
|
||||||
if (app.isWorking) return 'warning'
|
|
||||||
return 'danger'
|
|
||||||
},
|
|
||||||
|
|
||||||
onQueriesResponse (data) {
|
onQueriesResponse (data) {
|
||||||
// APPS
|
|
||||||
const apps = []
|
const apps = []
|
||||||
for (const key in data.apps) {
|
for (const key in data.apps) {
|
||||||
const app = data.apps[key]
|
const app = data.apps[key]
|
||||||
if (app.state === 'notworking') continue
|
app.isInstallable = !app.installed || app.manifest.integration.multi_instance
|
||||||
|
app.working = app.state === 'working'
|
||||||
Object.assign(app, this.formatQuality(app))
|
app.decent_quality = app.working && app.level > 4
|
||||||
app.isInstallable = !app.installed || app.manifest.multi_instance
|
app.high_quality = app.working && app.level >= 8
|
||||||
if (app.maintained !== 'request_adoption') {
|
app.color = 'danger'
|
||||||
app.maintained = app.maintained ? 'maintained' : 'orphaned'
|
if (app.working && app.level <= 0) {
|
||||||
|
app.state = 'broken'
|
||||||
|
app.color = 'danger'
|
||||||
|
} else if (app.working && app.level <= 4) {
|
||||||
|
app.state = 'lowquality'
|
||||||
|
app.color = 'warning'
|
||||||
|
} else if (app.working) {
|
||||||
|
app.color = 'success'
|
||||||
}
|
}
|
||||||
app.color = this.formatColor(app)
|
app.searchValues = [
|
||||||
app.searchValues = [app.id, app.state, app.manifest.name.toLowerCase(), app.manifest.description.toLowerCase()].join(' ')
|
app.id,
|
||||||
|
app.state,
|
||||||
|
app.manifest.name,
|
||||||
|
app.manifest.description,
|
||||||
|
app.potential_alternative_to.join(' ')
|
||||||
|
].join(' ').toLowerCase()
|
||||||
apps.push(app)
|
apps.push(app)
|
||||||
}
|
}
|
||||||
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
|
this.apps = apps.sort((a, b) => a.id > b.id ? 1 : -1)
|
||||||
|
@ -328,10 +309,8 @@ export default {
|
||||||
|
|
||||||
// INSTALL APP
|
// INSTALL APP
|
||||||
async onInstallClick (app) {
|
async onInstallClick (app) {
|
||||||
if (!app.isDecentQuality) {
|
if (!app.decent_quality) {
|
||||||
// Ask for confirmation
|
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + app.state))
|
||||||
const state = app.color === 'danger' ? 'inprogress' : app.state
|
|
||||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_install_app_' + state))
|
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
}
|
}
|
||||||
this.$router.push({ name: 'app-install', params: { id: app.id } })
|
this.$router.push({ name: 'app-install', params: { id: app.id } })
|
||||||
|
|
|
@ -3,7 +3,10 @@
|
||||||
:queries="queries" @queries-response="onQueriesResponse"
|
:queries="queries" @queries-response="onQueriesResponse"
|
||||||
ref="view" skeleton="card-form-skeleton"
|
ref="view" skeleton="card-form-skeleton"
|
||||||
>
|
>
|
||||||
<config-panels v-if="config.panels" v-bind="config" @submit="applyConfig" />
|
<config-panels
|
||||||
|
v-if="config.panels" v-bind="config"
|
||||||
|
@submit="onConfigSubmit"
|
||||||
|
/>
|
||||||
|
|
||||||
<b-alert v-else-if="config.panels === null" variant="warning">
|
<b-alert v-else-if="config.panels === null" variant="warning">
|
||||||
<icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }}
|
<icon iname="exclamation-triangle" /> {{ $t('app_config_panel_no_panel') }}
|
||||||
|
@ -34,7 +37,7 @@ export default {
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
queries: [
|
queries: [
|
||||||
['GET', `apps/${this.id}/config-panel?full`]
|
['GET', `apps/${this.id}/config?full`]
|
||||||
],
|
],
|
||||||
config: {}
|
config: {}
|
||||||
}
|
}
|
||||||
|
@ -49,23 +52,22 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
async applyConfig (id_) {
|
async onConfigSubmit ({ id, form, action, name }) {
|
||||||
const formatedData = await formatFormData(
|
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||||
this.config.forms[id_],
|
|
||||||
{ removeEmpty: false, removeNull: true, multipart: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
api.put(
|
api.put(
|
||||||
`apps/${this.id}/config`,
|
action
|
||||||
{ key: id_, args: objectToParams(formatedData) },
|
? `apps/${this.id}/actions/${action}`
|
||||||
{ key: 'apps.update_config', name: this.id }
|
: `apps/${this.id}/config/${id}`,
|
||||||
).then(response => {
|
{ args: objectToParams(args) },
|
||||||
|
{ key: `apps.${action ? 'action' : 'update'}_config`, id, name: this.id }
|
||||||
|
).then(() => {
|
||||||
this.$refs.view.fetchQueries({ triggerLoading: true })
|
this.$refs.view.fetchQueries({ triggerLoading: true })
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
if (err.name !== 'APIBadRequestError') throw err
|
||||||
const panel = this.config.panels.find(({ id }) => id_ === id)
|
const panel = this.config.panels.find(panel => panel.id === id)
|
||||||
if (err.data.name) {
|
if (err.data.name) {
|
||||||
this.config.errors[id_][err.data.name].message = err.message
|
this.config.errors[id][err.data.name].message = err.message
|
||||||
} else this.$set(panel, 'serverError', err.message)
|
} else this.$set(panel, 'serverError', err.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
@ -2,33 +2,22 @@
|
||||||
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
|
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
|
||||||
<!-- BASIC INFOS -->
|
<!-- BASIC INFOS -->
|
||||||
<card v-if="infos" :title="infos.label" icon="cube">
|
<card v-if="infos" :title="infos.label" icon="cube">
|
||||||
<b-row
|
<description-row
|
||||||
v-for="(value, prop) in infos" :key="prop"
|
v-for="(value, key) in infos" :key="key"
|
||||||
no-gutters class="row-line"
|
:term="$t(key)"
|
||||||
>
|
>
|
||||||
<b-col cols="auto" md="3">
|
<a v-if="key === 'url'" :href="value" target="_blank">{{ value }}</a>
|
||||||
<strong>{{ $t(prop) }}</strong>
|
<template v-else>{{ value }}</template>
|
||||||
</b-col>
|
</description-row>
|
||||||
<b-col>
|
<description-row :term="$t('app_info_access_desc')">
|
||||||
<a v-if="prop === 'url'" :href="value" target="_blank">{{ value }}</a>
|
{{ allowedGroups.length > 0 ? allowedGroups.join(', ') + '.' : $t('nobody') }}
|
||||||
<span v-else>{{ value }}</span>
|
<b-button
|
||||||
</b-col>
|
size="sm" :to="{ name: 'group-list'}" variant="info"
|
||||||
</b-row>
|
class="ml-2"
|
||||||
<b-row no-gutters class="row-line">
|
>
|
||||||
<b-col cols="auto" md="3">
|
<icon iname="key-modern" /> {{ $t('groups_and_permissions_manage') }}
|
||||||
<strong>{{ $t('app_info_access_desc') }}</strong>
|
</b-button>
|
||||||
<span class="sep" />
|
</description-row>
|
||||||
</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>
|
|
||||||
</card>
|
</card>
|
||||||
|
|
||||||
<!-- OPERATIONS -->
|
<!-- OPERATIONS -->
|
||||||
|
@ -145,19 +134,6 @@
|
||||||
</b-form-group>
|
</b-form-group>
|
||||||
</card>
|
</card>
|
||||||
|
|
||||||
<!-- EXPERIMENTAL (displayed if experimental feature has been enabled in web-admin options)-->
|
|
||||||
<card v-if="experimental" :title="$t('experimental')" icon="flask">
|
|
||||||
<!-- APP ACTIONS -->
|
|
||||||
<b-form-group
|
|
||||||
:label="$t('app_actions_label')" label-for="actions"
|
|
||||||
label-cols-md="4" label-class="font-weight-bold"
|
|
||||||
>
|
|
||||||
<b-button id="actions" variant="warning" :to="{ name: 'app-actions', params: { id } }">
|
|
||||||
<icon iname="flask" /> {{ $t('app_actions') }}
|
|
||||||
</b-button>
|
|
||||||
</b-form-group>
|
|
||||||
</card>
|
|
||||||
|
|
||||||
<template #skeleton>
|
<template #skeleton>
|
||||||
<card-info-skeleton :item-count="8" />
|
<card-info-skeleton :item-count="8" />
|
||||||
<card-form-skeleton />
|
<card-form-skeleton />
|
||||||
|
@ -195,7 +171,7 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['domains', 'experimental']),
|
...mapGetters(['domains']),
|
||||||
|
|
||||||
allowedGroups () {
|
allowedGroups () {
|
||||||
if (!this.app) return
|
if (!this.app) return
|
||||||
|
@ -243,7 +219,7 @@ export default {
|
||||||
label: mainPermission.label,
|
label: mainPermission.label,
|
||||||
description: app.description,
|
description: app.description,
|
||||||
version: app.version,
|
version: app.version,
|
||||||
multi_instance: this.$i18n.t(app.manifest.multi_instance ? 'yes' : 'no'),
|
multi_instance: this.$i18n.t(app.manifest.integration.multi_instance ? 'yes' : 'no'),
|
||||||
install_time: readableDate(app.settings.install_time, true, true)
|
install_time: readableDate(app.settings.install_time, true, true)
|
||||||
}
|
}
|
||||||
if (app.settings.domain && app.settings.path) {
|
if (app.settings.domain && app.settings.path) {
|
||||||
|
|
|
@ -3,18 +3,10 @@
|
||||||
<template v-if="infos">
|
<template v-if="infos">
|
||||||
<!-- BASIC INFOS -->
|
<!-- BASIC INFOS -->
|
||||||
<card :title="name" icon="download">
|
<card :title="name" icon="download">
|
||||||
<b-row
|
<description-row
|
||||||
v-for="(info, key) in infos" :key="key"
|
v-for="(info, key) in infos" :key="key"
|
||||||
no-gutters class="row-line"
|
:term="$t(key)" :details="info"
|
||||||
>
|
/>
|
||||||
<b-col cols="5" md="3" xl="3">
|
|
||||||
<strong>{{ $t(key) }}</strong>
|
|
||||||
<span class="sep" />
|
|
||||||
</b-col>
|
|
||||||
<b-col>
|
|
||||||
<span>{{ info }}</span>
|
|
||||||
</b-col>
|
|
||||||
</b-row>
|
|
||||||
</card>
|
</card>
|
||||||
|
|
||||||
<!-- INSTALL FORM -->
|
<!-- INSTALL FORM -->
|
||||||
|
@ -24,10 +16,9 @@
|
||||||
@submit.prevent="performInstall"
|
@submit.prevent="performInstall"
|
||||||
>
|
>
|
||||||
<template v-for="(field, fname) in fields">
|
<template v-for="(field, fname) in fields">
|
||||||
<form-field
|
<component
|
||||||
v-if="isVisible(field.visible, field)"
|
v-if="field.visible" :is="field.is" v-bind="field.props"
|
||||||
:key="fname" label-cols="0"
|
v-model="form[fname]" :validation="$v.form[fname]" :key="fname"
|
||||||
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
|
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
</card-form>
|
</card-form>
|
||||||
|
@ -47,10 +38,13 @@
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { validationMixin } from 'vuelidate'
|
import { validationMixin } from 'vuelidate'
|
||||||
import evaluate from 'simple-evaluate'
|
|
||||||
|
|
||||||
import api, { objectToParams } from '@/api'
|
import api, { objectToParams } from '@/api'
|
||||||
import { formatYunoHostArguments, formatI18nField, formatFormData, pFileReader } from '@/helpers/yunohostArguments'
|
import {
|
||||||
|
formatYunoHostArguments,
|
||||||
|
formatI18nField,
|
||||||
|
formatFormData
|
||||||
|
} from '@/helpers/yunohostArguments'
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'AppInstall',
|
name: 'AppInstall',
|
||||||
|
@ -68,7 +62,6 @@ export default {
|
||||||
],
|
],
|
||||||
name: undefined,
|
name: undefined,
|
||||||
infos: undefined,
|
infos: undefined,
|
||||||
formDisclaimer: null,
|
|
||||||
form: undefined,
|
form: undefined,
|
||||||
fields: undefined,
|
fields: undefined,
|
||||||
validations: null,
|
validations: null,
|
||||||
|
@ -85,17 +78,28 @@ export default {
|
||||||
onQueriesResponse (manifest) {
|
onQueriesResponse (manifest) {
|
||||||
this.name = manifest.name
|
this.name = manifest.name
|
||||||
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
|
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
|
||||||
|
manifest.license = manifest.upstream.license
|
||||||
if (manifest.license === undefined || manifest.license === 'free') {
|
if (manifest.license === undefined || manifest.license === 'free') {
|
||||||
infosKeys.splice(2, 1)
|
infosKeys.splice(2, 1)
|
||||||
}
|
}
|
||||||
manifest.description = formatI18nField(manifest.description)
|
manifest.description = formatI18nField(manifest.description)
|
||||||
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no')
|
manifest.multi_instance = this.$i18n.t(manifest.integration.multi_instance ? 'yes' : 'no')
|
||||||
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
|
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
|
||||||
|
|
||||||
const { form, fields, validations, errors } = formatYunoHostArguments(
|
// FIXME yunohost should add the label field by default
|
||||||
manifest.arguments.install,
|
manifest.install.unshift({
|
||||||
manifest.name
|
ask: this.$t('label_for_manifestname', { name: manifest.name }),
|
||||||
)
|
default: manifest.name,
|
||||||
|
name: 'label',
|
||||||
|
help: this.$t('label_for_manifestname_help')
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
form,
|
||||||
|
fields,
|
||||||
|
validations,
|
||||||
|
errors
|
||||||
|
} = formatYunoHostArguments(manifest.install)
|
||||||
|
|
||||||
this.fields = fields
|
this.fields = fields
|
||||||
this.form = form
|
this.form = form
|
||||||
|
@ -103,41 +107,6 @@ export default {
|
||||||
this.errors = errors
|
this.errors = errors
|
||||||
},
|
},
|
||||||
|
|
||||||
isVisible (expression, field) {
|
|
||||||
if (!expression || !field) return true
|
|
||||||
const context = {}
|
|
||||||
|
|
||||||
const promises = []
|
|
||||||
for (const shortname in this.form) {
|
|
||||||
if (this.form[shortname] instanceof File) {
|
|
||||||
if (expression.includes(shortname)) {
|
|
||||||
promises.push(pFileReader(this.form[shortname], context, shortname, false))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
context[shortname] = this.form[shortname]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Allow to use match(var,regexp) function
|
|
||||||
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
|
|
||||||
let i = 0
|
|
||||||
Promise.all(promises).then(() => {
|
|
||||||
for (const matched of expression.matchAll(matchRe)) {
|
|
||||||
i++
|
|
||||||
const varName = matched[1] + '__re' + i.toString()
|
|
||||||
context[varName] = new RegExp(matched[2], 'm').test(context[matched[1]])
|
|
||||||
expression = expression.replace(matched[0], varName)
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
field.isVisible = evaluate(context, expression)
|
|
||||||
} catch (error) {
|
|
||||||
field.isVisible = false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// This value should be updated magically when vuejs will detect isVisible changed
|
|
||||||
return field.isVisible
|
|
||||||
},
|
|
||||||
|
|
||||||
async performInstall () {
|
async performInstall () {
|
||||||
if ('path' in this.form && this.form.path === '/') {
|
if ('path' in this.form && this.form.path === '/') {
|
||||||
const confirmed = await this.$askConfirmation(
|
const confirmed = await this.$askConfirmation(
|
||||||
|
@ -148,7 +117,7 @@ export default {
|
||||||
|
|
||||||
const { data: args, label } = await formatFormData(
|
const { data: args, label } = await formatFormData(
|
||||||
this.form,
|
this.form,
|
||||||
{ extract: ['label'], removeEmpty: false, removeNull: true, multipart: false }
|
{ extract: ['label'], removeEmpty: false, removeNull: true }
|
||||||
)
|
)
|
||||||
const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }
|
const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }
|
||||||
|
|
||||||
|
|
|
@ -68,25 +68,8 @@ export default {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const multiInstances = {}
|
this.apps = apps.map(({ id, name, description, manifest }) => {
|
||||||
this.apps = apps.map(({ id, name, description, permissions, manifest }) => {
|
return { id, name: manifest.name, label: name, description }
|
||||||
// FIXME seems like some apps may no have a label (replace with id)
|
|
||||||
const label = permissions[id + '.main'].label
|
|
||||||
// Display the `id` of the instead of its `name` if multiple apps share the same name
|
|
||||||
if (manifest.multi_instance) {
|
|
||||||
if (!(name in multiInstances)) {
|
|
||||||
multiInstances[name] = []
|
|
||||||
}
|
|
||||||
const labels = multiInstances[name]
|
|
||||||
if (labels.includes(label)) {
|
|
||||||
name = id
|
|
||||||
}
|
|
||||||
labels.push(label)
|
|
||||||
}
|
|
||||||
if (label === name) {
|
|
||||||
name = null
|
|
||||||
}
|
|
||||||
return { id, name, description, label }
|
|
||||||
}).sort((prev, app) => {
|
}).sort((prev, app) => {
|
||||||
return prev.label > app.label ? 1 : -1
|
return prev.label > app.label ? 1 : -1
|
||||||
})
|
})
|
||||||
|
|
|
@ -74,7 +74,7 @@
|
||||||
</b-button>
|
</b-button>
|
||||||
<b-button
|
<b-button
|
||||||
v-if="item.details"
|
v-if="item.details"
|
||||||
size="sm" variant="outline-dark" class="ml-lg-2 mt-2 mt-lg-0"
|
size="sm" :variant="'outline-' + (theme ? 'light' : 'dark')" class="ml-lg-2 mt-2 mt-lg-0"
|
||||||
v-b-toggle="`collapse-${report.id}-item-${i}`"
|
v-b-toggle="`collapse-${report.id}-item-${i}`"
|
||||||
>
|
>
|
||||||
<icon iname="level-down" /> {{ $t('details') }}
|
<icon iname="level-down" /> {{ $t('details') }}
|
||||||
|
@ -104,6 +104,8 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
import api from '@/api'
|
import api from '@/api'
|
||||||
import { distanceToNow } from '@/helpers/filters/date'
|
import { distanceToNow } from '@/helpers/filters/date'
|
||||||
|
|
||||||
|
@ -120,6 +122,10 @@ export default {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
computed: {
|
||||||
|
...mapGetters(['theme'])
|
||||||
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
formatReportItem (report, item) {
|
formatReportItem (report, item) {
|
||||||
let issue = false
|
let issue = false
|
||||||
|
|
|
@ -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"
|
:queries="queries" @queries-response="onQueriesResponse" :loading="loading"
|
||||||
skeleton="card-info-skeleton"
|
skeleton="card-info-skeleton"
|
||||||
>
|
>
|
||||||
<card v-if="showAutoConfigCard" :title="$t('domain.dns.auto_config')" icon="wrench">
|
<section v-if="showAutoConfigCard" class="panel-section">
|
||||||
<b-alert variant="warning">
|
<b-card-title title-tag="h3">
|
||||||
<icon iname="flask" /> <icon iname="warning" /> <span v-html="$t('domain.dns.info')" />
|
{{ $t('domain.dns.auto_config') }}
|
||||||
</b-alert>
|
</b-card-title>
|
||||||
|
|
||||||
|
<read-only-alert-item
|
||||||
|
:label="$t('domain.dns.info')"
|
||||||
|
type="warning"
|
||||||
|
icon="flask"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- AUTO CONFIG CHANGES -->
|
<!-- AUTO CONFIG CHANGES -->
|
||||||
<template v-if="dnsChanges">
|
<template v-if="dnsChanges">
|
||||||
|
@ -32,27 +38,32 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- CONFIG OK ALERT -->
|
<!-- CONFIG OK ALERT -->
|
||||||
<b-alert v-else-if="dnsChanges === null" variant="success" class="m-0">
|
<read-only-alert-item
|
||||||
<icon iname="thumbs-up" /> {{ $t('domain.dns.auto_config_ok') }}
|
v-else-if="dnsChanges === null"
|
||||||
</b-alert>
|
:label="$t('domain.dns.auto_config_ok')"
|
||||||
|
type="success"
|
||||||
|
icon="thumbs-up"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- CONFIG ERROR ALERT -->
|
<!-- CONFIG ERROR ALERT -->
|
||||||
<template v-if="dnsErrors && dnsErrors.length">
|
<template v-if="dnsErrors && dnsErrors.length">
|
||||||
<b-alert
|
<read-only-alert-item
|
||||||
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i"
|
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i"
|
||||||
:variant="variant" :class="dnsErrors.length === 1 ? 'm-0' : ''"
|
:label="message"
|
||||||
>
|
:type="variant"
|
||||||
<icon :iname="icon" /> <span v-html="message" />
|
:icon="icon"
|
||||||
</b-alert>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- CONFIG OVERWRITE DISCLAIMER -->
|
<!-- CONFIG OVERWRITE DISCLAIMER -->
|
||||||
<b-alert v-if="force !== null" variant="warning">
|
<read-only-alert-item
|
||||||
<icon iname="warning" /> <span v-html="$t('domain.dns.push_force_warning')" />
|
v-if="force !== null"
|
||||||
</b-alert>
|
:label="$t('domain.dns.push_force_warning')"
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
|
||||||
<!-- CONFIG PUSH SUBMIT -->
|
<!-- CONFIG PUSH SUBMIT -->
|
||||||
<template v-if="dnsChanges" #buttons>
|
<template v-if="dnsChanges">
|
||||||
<b-form-checkbox v-if="force !== null" v-model="force">
|
<b-form-checkbox v-if="force !== null" v-model="force">
|
||||||
{{ $t('domain.dns.push_force') }}
|
{{ $t('domain.dns.push_force') }}
|
||||||
</b-form-checkbox>
|
</b-form-checkbox>
|
||||||
|
@ -61,13 +72,14 @@
|
||||||
{{ $t('domain.dns.push') }}
|
{{ $t('domain.dns.push') }}
|
||||||
</b-button>
|
</b-button>
|
||||||
</template>
|
</template>
|
||||||
</card>
|
</section>
|
||||||
|
|
||||||
<!-- CURRENT DNS ZONE -->
|
<!-- CURRENT DNS ZONE -->
|
||||||
<card
|
<section v-if="showAutoConfigCard && dnsZone && dnsZone.length" class="panel-section">
|
||||||
v-if="showAutoConfigCard && dnsZone && dnsZone.length"
|
<b-card-title title-tag="h3">
|
||||||
:title="$t('domain.dns.auto_config_zone')" icon="globe" no-body
|
{{ $t('domain.dns.auto_config_zone') }}
|
||||||
>
|
</b-card-title>
|
||||||
|
|
||||||
<div class="log">
|
<div class="log">
|
||||||
<div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records">
|
<div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records">
|
||||||
{{ record }}
|
{{ record }}
|
||||||
|
@ -75,19 +87,21 @@
|
||||||
<span>{{ content }}</span>
|
<span>{{ content }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</card>
|
</section>
|
||||||
|
|
||||||
<!-- MANUAL CONFIG CARD -->
|
<!-- MANUAL CONFIG CARD -->
|
||||||
<card
|
<section v-if="showManualConfigCard" class="panel-section">
|
||||||
v-if="showManualConfigCard"
|
<b-card-title title-tag="h3">
|
||||||
:title="$t('domain.dns.manual_config')" icon="globe" no-body
|
{{ $t('domain.dns.manual_config') }}
|
||||||
>
|
</b-card-title>
|
||||||
<b-alert variant="warning" class="m-0">
|
|
||||||
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }}
|
<read-only-alert-item
|
||||||
</b-alert>
|
:label="$t('domain_dns_conf_is_just_a_recommendation')"
|
||||||
|
type="warning"
|
||||||
|
/>
|
||||||
|
|
||||||
<pre class="log">{{ dnsConfig }}</pre>
|
<pre class="log">{{ dnsConfig }}</pre>
|
||||||
</card>
|
</section>
|
||||||
</view-base>
|
</view-base>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
@ -1,83 +1,160 @@
|
||||||
<template>
|
<template>
|
||||||
<view-base :queries="queries" skeleton="card-list-skeleton">
|
<view-base
|
||||||
<card :title="name" icon="globe">
|
:queries="queries" @queries-response="onQueriesResponse"
|
||||||
<!-- VISIT -->
|
ref="view" skeleton="card-list-skeleton"
|
||||||
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p>
|
>
|
||||||
<b-button variant="success" :href="'https://' + name" target="_blank">
|
<!-- INFO CARD -->
|
||||||
<icon iname="external-link" /> {{ $t('domain_visit') }}
|
<card v-if="domain" :title="name" icon="globe">
|
||||||
</b-button>
|
<template v-if="isMainDomain" #header-next>
|
||||||
<hr>
|
<b-badge variant="info" class="main-domain-badge">
|
||||||
|
<explain-what
|
||||||
|
id="explain-main-domain"
|
||||||
|
:title="$t('domain.types.main_domain')"
|
||||||
|
:content="$t('domain.explain.main_domain', { domain: name })"
|
||||||
|
>
|
||||||
|
<icon iname="star" /> {{ $t('domain.types.main_domain') }}
|
||||||
|
</explain-what>
|
||||||
|
</b-badge>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- DEFAULT DOMAIN -->
|
<template #header-buttons>
|
||||||
<p>{{ $t('domain_default_desc') }}</p>
|
<!-- DEFAULT DOMAIN -->
|
||||||
<p v-if="isMainDomain" class="alert alert-info">
|
<b-button v-if="!isMainDomain" @click="setAsDefaultDomain" variant="info">
|
||||||
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
|
<icon iname="star" /> {{ $t('set_default') }}
|
||||||
</p>
|
</b-button>
|
||||||
<b-button v-else variant="info" @click="setAsDefaultDomain">
|
|
||||||
<icon iname="star" /> {{ $t('set_default') }}
|
|
||||||
</b-button>
|
|
||||||
<hr>
|
|
||||||
|
|
||||||
<!-- DOMAIN CONFIG -->
|
<!-- DELETE DOMAIN -->
|
||||||
<p>{{ $t('domain.config.edit') }}</p>
|
<b-button @click="deleteDomain" :disabled="isMainDomain" variant="danger">
|
||||||
<b-button variant="warning" :to="{ name: 'domain-config', param: { name } }">
|
<icon iname="trash-o" /> {{ $t('delete') }}
|
||||||
<icon iname="cog" /> {{ $t('domain.config.title') }}
|
</b-button>
|
||||||
</b-button>
|
</template>
|
||||||
<hr>
|
|
||||||
|
|
||||||
<!-- DNS CONFIG -->
|
<!-- DOMAIN LINK -->
|
||||||
<p>{{ $t('domain.dns.edit') }}</p>
|
<description-row :term="$t('words.link')">
|
||||||
<b-button variant="warning" :to="{ name: 'domain-dns', param: { name } }">
|
<b-link :href="'https://' + name" target="_blank">
|
||||||
<icon iname="globe" /> {{ $t('domain_dns_config') }}
|
https://{{ name }}
|
||||||
</b-button>
|
</b-link>
|
||||||
<hr>
|
</description-row>
|
||||||
|
|
||||||
<!-- SSL CERTIFICATE -->
|
<!-- DOMAIN CERT AUTHORITY -->
|
||||||
<p>{{ $t('certificate_manage') }}</p>
|
<description-row :term="$t('domain.info.certificate_authority')">
|
||||||
<b-button variant="outline-dark" :to="{ name: 'domain-cert', param: { name } }">
|
<icon :iname="cert.icon" :variant="cert.variant" class="mr-1" />
|
||||||
<icon iname="lock" /> {{ $t('ssl_certificate') }}
|
{{ $t('domain.cert.types.' + cert.authority) }}
|
||||||
</b-button>
|
<span class="text-secondary px-2">({{ $t('domain.cert.valid_for', { days: $tc('day_validity', cert.validity) }) }})</span>
|
||||||
<hr>
|
</description-row>
|
||||||
|
|
||||||
<!-- DELETE -->
|
<!-- DOMAIN REGISTRAR -->
|
||||||
<p>{{ $t('domain_delete_longdesc') }}</p>
|
<description-row v-if="domain.registrar" :term="$t('domain.info.registrar')">
|
||||||
<p
|
<template v-if="domain.registrar === 'parent_domain'">
|
||||||
v-if="isMainDomain" class="alert alert-info"
|
{{ $t('domain.see_parent_domain') }} <b-link :href="`#/domains/${domain.topest_parent}/dns`">
|
||||||
v-html="$t('domain_delete_forbidden_desc', { domain: name })"
|
{{ domain.topest_parent }}
|
||||||
/>
|
</b-link>
|
||||||
<b-button v-else variant="danger" @click="deleteDomain">
|
</template>
|
||||||
<icon iname="trash-o" /> {{ $t('delete') }}
|
<template v-else>
|
||||||
</b-button>
|
{{ domain.registrar }}
|
||||||
|
</template>
|
||||||
|
</description-row>
|
||||||
|
|
||||||
|
<!-- DOMAIN APPS -->
|
||||||
|
<description-row :term="$t('domain.info.apps_on_domain')">
|
||||||
|
<b-button-group
|
||||||
|
v-for="app in domain.apps" :key="app.id"
|
||||||
|
size="sm" class="mr-2"
|
||||||
|
>
|
||||||
|
<b-button class="py-0 font-weight-bold" variant="outline-dark" :to="{ name: 'app-info', params: { id: app.id }}">
|
||||||
|
{{ app.name }}
|
||||||
|
</b-button>
|
||||||
|
<b-button
|
||||||
|
variant="outline-dark" class="py-0 px-1"
|
||||||
|
:href="'https://' + name + app.path" target="_blank"
|
||||||
|
>
|
||||||
|
<span class="sr-only">{{ $t('app.visit_app') }}</span>
|
||||||
|
<icon iname="external-link" />
|
||||||
|
</b-button>
|
||||||
|
</b-button-group>
|
||||||
|
|
||||||
|
{{ domain.apps.length ? '' : $t('words.none') }}
|
||||||
|
</description-row>
|
||||||
</card>
|
</card>
|
||||||
|
|
||||||
|
<config-panels v-if="config.panels" v-bind="config" @submit="onConfigSubmit">
|
||||||
|
<template v-if="currentTab === 'dns'" #tab-after>
|
||||||
|
<domain-dns :name="name" />
|
||||||
|
</template>
|
||||||
|
</config-panels>
|
||||||
</view-base>
|
</view-base>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
import api from '@/api'
|
import api, { objectToParams } from '@/api'
|
||||||
|
import {
|
||||||
|
formatFormData,
|
||||||
|
formatYunoHostConfigPanels
|
||||||
|
} from '@/helpers/yunohostArguments'
|
||||||
|
import ConfigPanels from '@/components/ConfigPanels'
|
||||||
|
import DomainDns from './DomainDns.vue'
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DomainInfo',
|
name: 'DomainInfo',
|
||||||
|
|
||||||
props: {
|
components: {
|
||||||
name: {
|
ConfigPanels,
|
||||||
type: String,
|
DomainDns
|
||||||
required: true
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
|
||||||
data: () => {
|
props: {
|
||||||
|
name: { type: String, required: true }
|
||||||
|
},
|
||||||
|
|
||||||
|
data () {
|
||||||
return {
|
return {
|
||||||
queries: [
|
queries: [
|
||||||
['GET', { uri: 'domains/main', storeKey: 'main_domain' }]
|
['GET', { uri: 'domains', storeKey: 'domains' }],
|
||||||
]
|
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
|
||||||
|
['GET', { uri: 'domains', storeKey: 'domains_details', param: this.name }],
|
||||||
|
['GET', `domains/${this.name}/config?full`]
|
||||||
|
],
|
||||||
|
config: {}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['mainDomain']),
|
...mapGetters(['mainDomain']),
|
||||||
|
|
||||||
|
currentTab () {
|
||||||
|
return this.$route.params.tabId
|
||||||
|
},
|
||||||
|
|
||||||
|
domain () {
|
||||||
|
return this.$store.getters.domain(this.name)
|
||||||
|
},
|
||||||
|
|
||||||
|
parentName () {
|
||||||
|
return this.$store.getters.highestDomainParentName(this.name)
|
||||||
|
},
|
||||||
|
|
||||||
|
cert () {
|
||||||
|
const { CA_type: authority, validity } = this.domain.certificate
|
||||||
|
const baseInfos = { authority, validity }
|
||||||
|
if (validity <= 0) {
|
||||||
|
return { icon: 'times', variant: 'danger', ...baseInfos }
|
||||||
|
} else if (authority === 'other') {
|
||||||
|
return validity < 15
|
||||||
|
? { icon: 'exclamation', variant: 'danger', ...baseInfos }
|
||||||
|
: { icon: 'check', variant: 'success', ...baseInfos }
|
||||||
|
} else if (authority === 'letsencrypt') {
|
||||||
|
return { icon: 'thumbs-up', variant: 'success', ...baseInfos }
|
||||||
|
}
|
||||||
|
return { icon: 'exclamation', variant: 'warning', ...baseInfos }
|
||||||
|
},
|
||||||
|
|
||||||
|
dns () {
|
||||||
|
return this.domain.dns
|
||||||
|
},
|
||||||
|
|
||||||
isMainDomain () {
|
isMainDomain () {
|
||||||
if (!this.mainDomain) return
|
if (!this.mainDomain) return
|
||||||
return this.name === this.mainDomain
|
return this.name === this.mainDomain
|
||||||
|
@ -85,6 +162,30 @@ export default {
|
||||||
},
|
},
|
||||||
|
|
||||||
methods: {
|
methods: {
|
||||||
|
onQueriesResponse (domains, mainDomain, domain, config) {
|
||||||
|
this.config = formatYunoHostConfigPanels(config)
|
||||||
|
},
|
||||||
|
|
||||||
|
async onConfigSubmit ({ id, form, action, name }) {
|
||||||
|
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||||
|
|
||||||
|
api.put(
|
||||||
|
action
|
||||||
|
? `domain/${this.name}/actions/${action}`
|
||||||
|
: `domains/${this.name}/config/${id}`,
|
||||||
|
{ args: objectToParams(args) },
|
||||||
|
{ key: `domains.${action ? 'action' : 'update'}_config`, id, name: this.name }
|
||||||
|
).then(() => {
|
||||||
|
this.$refs.view.fetchQueries({ triggerLoading: true })
|
||||||
|
}).catch(err => {
|
||||||
|
if (err.name !== 'APIBadRequestError') throw err
|
||||||
|
const panel = this.config.panels.find(panel => panel.id === id)
|
||||||
|
if (err.data.name) {
|
||||||
|
this.config.errors[id][err.data.name].message = err.message
|
||||||
|
} else this.$set(panel, 'serverError', err.message)
|
||||||
|
})
|
||||||
|
},
|
||||||
|
|
||||||
async deleteDomain () {
|
async deleteDomain () {
|
||||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
|
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
|
||||||
if (!confirmed) return
|
if (!confirmed) return
|
||||||
|
@ -112,3 +213,10 @@ export default {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.main-domain-badge {
|
||||||
|
font-size: .75rem;
|
||||||
|
padding-right: .2em;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
|
@ -3,9 +3,10 @@
|
||||||
id="domain-list"
|
id="domain-list"
|
||||||
:search.sync="search"
|
:search.sync="search"
|
||||||
:items="domains"
|
:items="domains"
|
||||||
:filtered-items="filteredDomains"
|
|
||||||
items-name="domains"
|
items-name="domains"
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
|
:filtered-items="hasFilteredItems"
|
||||||
|
@queries-response="onQueriesResponse"
|
||||||
>
|
>
|
||||||
<template #top-bar-buttons>
|
<template #top-bar-buttons>
|
||||||
<b-button variant="success" :to="{ name: 'domain-add' }">
|
<b-button variant="success" :to="{ name: 'domain-add' }">
|
||||||
|
@ -14,57 +15,75 @@
|
||||||
</b-button>
|
</b-button>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<b-list-group>
|
<recursive-list-group :tree="tree" :toggle-text="$t('domain.toggle_subdomains')" class="mb-5">
|
||||||
<b-list-group-item
|
<template #default="{ data, parent }">
|
||||||
v-for="domain in filteredDomains" :key="domain"
|
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||||
:to="{ name: 'domain-info', params: { name: domain }}"
|
<h5 class="mr-3">
|
||||||
class="d-flex justify-content-between align-items-center pr-0"
|
<b-link :to="data.to" class="text-body text-decoration-none">
|
||||||
>
|
<span class="font-weight-bold">{{ data.name.replace(parent ? parent.data.name : null, '') }}</span>
|
||||||
<div>
|
<span v-if="parent" class="text-secondary">{{ parent.data.name }}</span>
|
||||||
<h5 class="font-weight-bold">
|
</b-link>
|
||||||
{{ domain }}
|
|
||||||
<small v-if="domain === mainDomain">
|
<small
|
||||||
<span class="sr-only">{{ $t('words.default') }}</span>
|
v-if="data.name === mainDomain"
|
||||||
<icon iname="star" :title="$t('words.default')" />
|
:title="$t('domain.types.main_domain')" class="ml-1"
|
||||||
|
v-b-tooltip.hover
|
||||||
|
>
|
||||||
|
<icon iname="star" />
|
||||||
</small>
|
</small>
|
||||||
</h5>
|
</h5>
|
||||||
<p class="font-italic m-0">
|
|
||||||
https://{{ domain }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
|
</template>
|
||||||
</b-list-group-item>
|
</recursive-list-group>
|
||||||
</b-list-group>
|
|
||||||
</view-search>
|
</view-search>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
import { mapGetters } from 'vuex'
|
import { mapGetters } from 'vuex'
|
||||||
|
|
||||||
|
import RecursiveListGroup from '@/components/RecursiveListGroup'
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DomainList',
|
name: 'DomainList',
|
||||||
|
|
||||||
|
components: {
|
||||||
|
RecursiveListGroup
|
||||||
|
},
|
||||||
|
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
queries: [
|
queries: [
|
||||||
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
|
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
|
||||||
['GET', { uri: 'domains' }]
|
['GET', { uri: 'domains', storeKey: 'domains' }]
|
||||||
],
|
],
|
||||||
search: ''
|
search: '',
|
||||||
|
domainsTree: undefined
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
...mapGetters(['domains', 'mainDomain']),
|
...mapGetters(['domains', 'mainDomain']),
|
||||||
|
|
||||||
filteredDomains () {
|
tree () {
|
||||||
if (!this.domains || !this.mainDomain) return
|
if (!this.domainsTree) return
|
||||||
const search = this.search.toLowerCase()
|
if (this.search) {
|
||||||
const mainDomain = this.mainDomain
|
const search = this.search.toLowerCase()
|
||||||
const domains = this.domains
|
return this.domainsTree.filter(node => node.data.name.includes(search))
|
||||||
.filter(name => name.toLowerCase().includes(search))
|
}
|
||||||
.sort(prevDomain => prevDomain === mainDomain ? -1 : 1)
|
return this.domainsTree
|
||||||
return domains.length ? domains : null
|
},
|
||||||
|
|
||||||
|
hasFilteredItems () {
|
||||||
|
if (!this.tree) return
|
||||||
|
return this.tree.children || null
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
methods: {
|
||||||
|
onQueriesResponse () {
|
||||||
|
// Add the tree to `data` to make it reactive
|
||||||
|
this.domainsTree = this.$store.getters.domainsTree
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -43,7 +43,7 @@
|
||||||
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em>
|
<em>{{ $t('group_explain_visitors_needed_for_external_client') }}</em>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-if="groupName == 'admins' || !group.isSpecial">
|
||||||
<tags-selectize-item
|
<tags-selectize-item
|
||||||
v-model="group.members" :options="usersOptions"
|
v-model="group.members" :options="usersOptions"
|
||||||
:id="groupName + '-users'" :label="$t('group_add_member')"
|
:id="groupName + '-users'" :label="$t('group_add_member')"
|
||||||
|
@ -173,7 +173,7 @@ export default {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
group.isSpecial = ['visitors', 'all_users'].includes(groupName)
|
group.isSpecial = ['visitors', 'all_users', 'admins'].includes(groupName)
|
||||||
|
|
||||||
if (groupName === 'visitors') {
|
if (groupName === 'visitors') {
|
||||||
// Forbid to add or remove a protected permission on group `visitors`
|
// Forbid to add or remove a protected permission on group `visitors`
|
||||||
|
@ -189,6 +189,13 @@ export default {
|
||||||
}).map(({ id }) => permsDict[id].label)
|
}).map(({ id }) => permsDict[id].label)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (groupName === 'admins') {
|
||||||
|
// Forbid to add ssh and sftp permission on group `admins`
|
||||||
|
group.disabledItems = permissions.filter(({ id }) => {
|
||||||
|
return ['ssh.main', 'sftp.main'].includes(id)
|
||||||
|
}).map(({ id }) => permsDict[id].label)
|
||||||
|
}
|
||||||
|
|
||||||
primaryGroups[groupName] = group
|
primaryGroups[groupName] = group
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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: [
|
menu: [
|
||||||
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
|
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
|
||||||
{ routeName: 'tool-migrations', icon: 'share', translation: 'migrations' },
|
{ routeName: 'tool-migrations', icon: 'share', translation: 'migrations' },
|
||||||
|
{ routeName: 'service-list', icon: 'gears', translation: 'services' },
|
||||||
{ routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
|
{ routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
|
||||||
{ routeName: 'tool-adminpw', icon: 'key-modern', translation: 'tools_adminpw' },
|
{ routeName: 'tool-settings', icon: 'sliders', translation: 'tools_yunohost_settings' },
|
||||||
{ routeName: 'tool-webadmin', icon: 'cog', translation: 'tools_webadmin_settings' },
|
{ routeName: 'tool-webadmin', icon: 'sliders', translation: 'tools_webadmin_settings' },
|
||||||
{ routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' }
|
{ routeName: 'tool-power', icon: 'power-off', translation: 'tools_shutdown_reboot' }
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
:queries="queries" @queries-response="onQueriesResponse"
|
:queries="queries" @queries-response="onQueriesResponse"
|
||||||
ref="view" skeleton="card-form-skeleton"
|
ref="view" skeleton="card-form-skeleton"
|
||||||
>
|
>
|
||||||
<config-panels v-if="config.panels" v-bind="config" @submit="applyConfig" />
|
<config-panels v-if="config.panels" v-bind="config" @submit="onConfigSubmit" />
|
||||||
</view-base>
|
</view-base>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -17,20 +17,18 @@ import ConfigPanels from '@/components/ConfigPanels'
|
||||||
|
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
name: 'DomainConfig',
|
name: 'ToolSettingsConfig',
|
||||||
|
|
||||||
components: {
|
components: {
|
||||||
ConfigPanels
|
ConfigPanels
|
||||||
},
|
},
|
||||||
|
|
||||||
props: {
|
props: {},
|
||||||
name: { type: String, required: true }
|
|
||||||
},
|
|
||||||
|
|
||||||
data () {
|
data () {
|
||||||
return {
|
return {
|
||||||
queries: [
|
queries: [
|
||||||
['GET', `domains/${this.name}/config?full`]
|
['GET', 'settings?full']
|
||||||
],
|
],
|
||||||
config: {}
|
config: {}
|
||||||
}
|
}
|
||||||
|
@ -41,23 +39,21 @@ export default {
|
||||||
this.config = formatYunoHostConfigPanels(config)
|
this.config = formatYunoHostConfigPanels(config)
|
||||||
},
|
},
|
||||||
|
|
||||||
async applyConfig (id_) {
|
async onConfigSubmit ({ id, form }) {
|
||||||
const formatedData = await formatFormData(
|
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||||
this.config.forms[id_],
|
|
||||||
{ removeEmpty: false, removeNull: true, multipart: false }
|
|
||||||
)
|
|
||||||
|
|
||||||
|
// FIXME no route for potential action
|
||||||
api.put(
|
api.put(
|
||||||
`domains/${this.name}/config`,
|
`settings/${id}`,
|
||||||
{ key: id_, args: objectToParams(formatedData) },
|
{ args: objectToParams(args) },
|
||||||
{ key: 'domains.update_config', name: this.name }
|
{ key: 'settings.update', panel: id }
|
||||||
).then(() => {
|
).then(() => {
|
||||||
this.$refs.view.fetchQueries({ triggerLoading: true })
|
this.$refs.view.fetchQueries({ triggerLoading: true })
|
||||||
}).catch(err => {
|
}).catch(err => {
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
if (err.name !== 'APIBadRequestError') throw err
|
||||||
const panel = this.config.panels.find(({ id }) => id_ === id)
|
const panel = this.config.panels.find(panel => panel.id === id)
|
||||||
if (err.data.name) {
|
if (err.data.name) {
|
||||||
this.config.errors[id_][err.data.name].message = err.message
|
this.config.errors[id][err.data.name].message = err.message
|
||||||
} else this.$set(panel, 'serverError', err.message)
|
} else this.$set(panel, 'serverError', err.message)
|
||||||
})
|
})
|
||||||
}
|
}
|
|
@ -60,6 +60,13 @@ export default {
|
||||||
label: this.$i18n.t('tools_webadmin.transitions'),
|
label: this.$i18n.t('tools_webadmin.transitions'),
|
||||||
component: 'CheckboxItem',
|
component: 'CheckboxItem',
|
||||||
props: { labels: { true: 'enabled', false: 'disabled' } }
|
props: { labels: { true: 'enabled', false: 'disabled' } }
|
||||||
|
},
|
||||||
|
|
||||||
|
theme: {
|
||||||
|
id: 'theme',
|
||||||
|
label: this.$i18n.t('tools_webadmin.theme'),
|
||||||
|
component: 'CheckboxItem',
|
||||||
|
props: { labels: { true: '🌙', false: '☀️' } }
|
||||||
}
|
}
|
||||||
|
|
||||||
// experimental: added in `created()`
|
// experimental: added in `created()`
|
||||||
|
@ -69,7 +76,7 @@ export default {
|
||||||
|
|
||||||
computed: {
|
computed: {
|
||||||
// Those are set/get computed properties
|
// Those are set/get computed properties
|
||||||
...mapStoreGetSet(['locale', 'fallbackLocale'], 'dispatch'),
|
...mapStoreGetSet(['locale', 'fallbackLocale', 'theme'], 'dispatch'),
|
||||||
...mapStoreGetSet(['cache', 'transitions', 'experimental'])
|
...mapStoreGetSet(['cache', 'transitions', 'experimental'])
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
|
@ -9,26 +9,7 @@
|
||||||
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
|
<form-field v-bind="fields.username" v-model="form.username" :validation="$v.form.username" />
|
||||||
|
|
||||||
<!-- USER FULLNAME -->
|
<!-- USER FULLNAME -->
|
||||||
<form-field
|
<form-field v-bind="fields.fullname" :validation="$v.form.fullname" v-model="form.fullname" />
|
||||||
v-bind="fields.fullname" :validation="$v.form.fullname"
|
|
||||||
>
|
|
||||||
<template #default="{ self }">
|
|
||||||
<b-input-group>
|
|
||||||
<template v-for="fname in ['firstname', 'lastname']">
|
|
||||||
<b-input-group-prepend :key="fname + 'prepend'">
|
|
||||||
<b-input-group-text :id="fname + '-label'" tag="label">
|
|
||||||
{{ self[fname].label }}
|
|
||||||
</b-input-group-text>
|
|
||||||
</b-input-group-prepend>
|
|
||||||
|
|
||||||
<input-item
|
|
||||||
v-bind="self[fname]" v-model="form.fullname[fname]" :key="fname + 'input'"
|
|
||||||
:name="self[fname].id" :aria-labelledby="fname + '-label'"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</b-input-group>
|
|
||||||
</template>
|
|
||||||
</form-field>
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!-- USER MAIL DOMAIN -->
|
<!-- USER MAIL DOMAIN -->
|
||||||
|
@ -82,10 +63,7 @@ export default {
|
||||||
|
|
||||||
form: {
|
form: {
|
||||||
username: '',
|
username: '',
|
||||||
fullname: {
|
fullname: '',
|
||||||
firstname: '',
|
|
||||||
lastname: ''
|
|
||||||
},
|
|
||||||
domain: '',
|
domain: '',
|
||||||
password: '',
|
password: '',
|
||||||
confirmation: ''
|
confirmation: ''
|
||||||
|
@ -104,18 +82,9 @@ export default {
|
||||||
|
|
||||||
fullname: {
|
fullname: {
|
||||||
label: this.$i18n.t('user_fullname'),
|
label: this.$i18n.t('user_fullname'),
|
||||||
id: 'fullname',
|
|
||||||
props: {
|
props: {
|
||||||
firstname: {
|
id: 'fullname',
|
||||||
id: 'firstname',
|
placeholder: this.$i18n.t('placeholder.fullname')
|
||||||
label: this.$i18n.t('common.firstname'),
|
|
||||||
placeholder: this.$i18n.t('placeholder.firstname')
|
|
||||||
},
|
|
||||||
lastname: {
|
|
||||||
id: 'lastname',
|
|
||||||
label: this.$i18n.t('common.lastname'),
|
|
||||||
placeholder: this.$i18n.t('placeholder.lastname')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -156,10 +125,7 @@ export default {
|
||||||
return {
|
return {
|
||||||
form: {
|
form: {
|
||||||
username: { required, alphalownum_, notInUsers: unique(this.userNames) },
|
username: { required, alphalownum_, notInUsers: unique(this.userNames) },
|
||||||
fullname: {
|
fullname: { required, name },
|
||||||
firstname: { required, name },
|
|
||||||
lastname: { required, name }
|
|
||||||
},
|
|
||||||
domain: { required },
|
domain: { required },
|
||||||
password: { required, passwordLenght: minLength(8) },
|
password: { required, passwordLenght: minLength(8) },
|
||||||
confirmation: { required, passwordMatch: sameAs('password') }
|
confirmation: { required, passwordMatch: sameAs('password') }
|
||||||
|
@ -189,10 +155,6 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
#lastname-label {
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.custom-select {
|
.custom-select {
|
||||||
flex-basis: 40%;
|
flex-basis: 40%;
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,26 +8,9 @@
|
||||||
<!-- USERNAME (disabled) -->
|
<!-- USERNAME (disabled) -->
|
||||||
<form-field v-bind="fields.username" />
|
<form-field v-bind="fields.username" />
|
||||||
|
|
||||||
<!-- USER FULLNAME (FIXME quite a mess, but will be removed)-->
|
<!-- USER FULLNAME -->
|
||||||
<form-field v-bind="fields.fullname" :validation="$v.form.fullname">
|
<form-field v-bind="fields.fullname" v-model="form.fullname" :validation="$v.form.fullname" />
|
||||||
<template #default="{ self }">
|
|
||||||
<b-input-group>
|
|
||||||
<template v-for="name_ in ['firstname', 'lastname']">
|
|
||||||
<b-input-group-prepend :key="name_ + 'prepend'">
|
|
||||||
<b-input-group-text :id="name_ + '-label'" tag="label">
|
|
||||||
{{ self[name_].label }}
|
|
||||||
</b-input-group-text>
|
|
||||||
</b-input-group-prepend>
|
|
||||||
|
|
||||||
<input-item
|
|
||||||
v-bind="self[name_]" v-model.trim="form.fullname[name_]" :key="name_ + 'input'"
|
|
||||||
:name="self[name_].id" :aria-labelledby="name_ + '-label'"
|
|
||||||
:state="$v.form.fullname[name_].$invalid && $v.form.fullname.$anyDirty ? false : null"
|
|
||||||
/>
|
|
||||||
</template>
|
|
||||||
</b-input-group>
|
|
||||||
</template>
|
|
||||||
</form-field>
|
|
||||||
<hr>
|
<hr>
|
||||||
|
|
||||||
<!-- USER EMAIL -->
|
<!-- USER EMAIL -->
|
||||||
|
@ -137,7 +120,7 @@ export default {
|
||||||
],
|
],
|
||||||
|
|
||||||
form: {
|
form: {
|
||||||
fullname: { firstname: '', lastname: '' },
|
fullname: '',
|
||||||
mail: { localPart: '', separator: '@', domain: '' },
|
mail: { localPart: '', separator: '@', domain: '' },
|
||||||
mailbox_quota: '',
|
mailbox_quota: '',
|
||||||
mail_aliases: [],
|
mail_aliases: [],
|
||||||
|
@ -157,18 +140,9 @@ export default {
|
||||||
|
|
||||||
fullname: {
|
fullname: {
|
||||||
label: this.$i18n.t('user_fullname'),
|
label: this.$i18n.t('user_fullname'),
|
||||||
id: 'fullname',
|
|
||||||
props: {
|
props: {
|
||||||
firstname: {
|
id: 'fullname',
|
||||||
id: 'firstname',
|
placeholder: this.$i18n.t('placeholder.fullname')
|
||||||
label: this.$i18n.t('common.firstname'),
|
|
||||||
placeholder: this.$i18n.t('placeholder.firstname')
|
|
||||||
},
|
|
||||||
lastname: {
|
|
||||||
id: 'lastname',
|
|
||||||
label: this.$i18n.t('common.lastname'),
|
|
||||||
placeholder: this.$i18n.t('placeholder.lastname')
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -220,10 +194,7 @@ export default {
|
||||||
|
|
||||||
validations: {
|
validations: {
|
||||||
form: {
|
form: {
|
||||||
fullname: {
|
fullname: { required, name },
|
||||||
firstname: { required, name },
|
|
||||||
lastname: { required, name }
|
|
||||||
},
|
|
||||||
mail: {
|
mail: {
|
||||||
localPart: { required, email: emailLocalPart }
|
localPart: { required, email: emailLocalPart }
|
||||||
},
|
},
|
||||||
|
@ -246,11 +217,7 @@ export default {
|
||||||
this.fields.mail.props.choices = this.domainsAsChoices
|
this.fields.mail.props.choices = this.domainsAsChoices
|
||||||
this.fields.mail_aliases.props.choices = this.domainsAsChoices
|
this.fields.mail_aliases.props.choices = this.domainsAsChoices
|
||||||
|
|
||||||
this.form.fullname = {
|
this.form.fullname = user.fullname
|
||||||
// Copy value to avoid refering to the stored user data
|
|
||||||
firstname: user.firstname.valueOf(),
|
|
||||||
lastname: user.lastname.valueOf()
|
|
||||||
}
|
|
||||||
this.form.mail = adressToFormValue(user.mail)
|
this.form.mail = adressToFormValue(user.mail)
|
||||||
if (user['mail-aliases']) {
|
if (user['mail-aliases']) {
|
||||||
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
|
this.form.mail_aliases = user['mail-aliases'].map(mail => adressToFormValue(mail))
|
||||||
|
@ -334,10 +301,6 @@ export default {
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
#lastname-label {
|
|
||||||
border-left: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mail-list {
|
.mail-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-items: stretch;
|
justify-items: stretch;
|
||||||
|
|
Loading…
Add table
Reference in a new issue