mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
Merge pull request #436 from YunoHost/enh-domains
Rework domain list and domain info view (using tree-structure and new config panel for domain)
This commit is contained in:
commit
c1395df89d
19 changed files with 871 additions and 216 deletions
|
@ -2,6 +2,7 @@
|
||||||
<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="onApply"
|
@submit.prevent.stop="onApply"
|
||||||
|
:no-footer="!panel.hasApplyButton"
|
||||||
>
|
>
|
||||||
<slot name="tab-top" />
|
<slot name="tab-top" />
|
||||||
|
|
||||||
|
@ -13,8 +14,10 @@
|
||||||
|
|
||||||
<template v-for="section in panel.sections">
|
<template v-for="section in panel.sections">
|
||||||
<component
|
<component
|
||||||
v-if="section.visible" :is="section.name ? 'section' : 'div'"
|
v-if="section.visible"
|
||||||
:key="section.id" class="mb-5"
|
: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>
|
||||||
|
@ -86,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>
|
||||||
|
|
|
@ -3,7 +3,11 @@
|
||||||
:routes="routes_"
|
:routes="routes_"
|
||||||
v-bind="{ panels, forms, v: $v, ...$attrs }"
|
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>
|
||||||
|
|
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 }">
|
||||||
|
|
59
app/src/components/globals/DescriptionRow.vue
Normal file
59
app/src/components/globals/DescriptionRow.vue
Normal file
|
@ -0,0 +1,59 @@
|
||||||
|
<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: .5rem 0;
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(0, 0, 0, 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</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>
|
|
@ -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,25 @@ 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: $white;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.warning {
|
||||||
|
color: $black;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
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)
|
||||||
|
}
|
|
@ -10,6 +10,14 @@ import {
|
||||||
} from '@/helpers/commons'
|
} 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.
|
||||||
|
@ -359,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] = {}
|
||||||
|
@ -391,6 +399,10 @@ export function formatYunoHostConfigPanels (data) {
|
||||||
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)
|
||||||
|
|
|
@ -134,6 +134,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_experimental_disclaimer": "Be aware that the diagnosis feature is still experimental and being polished, and it may not be fully reliable.",
|
"diagnosis_experimental_disclaimer": "Be aware that the diagnosis feature is still experimental and being polished, and it may not be fully reliable.",
|
||||||
|
@ -144,6 +145,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"
|
||||||
|
@ -153,6 +162,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",
|
||||||
|
@ -160,7 +176,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>.",
|
||||||
|
@ -178,8 +207,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",
|
||||||
|
@ -550,8 +577,10 @@
|
||||||
"browse": "Browse",
|
"browse": "Browse",
|
||||||
"collapse": "Collapse",
|
"collapse": "Collapse",
|
||||||
"default": "Default",
|
"default": "Default",
|
||||||
|
"link": "Link",
|
||||||
"none": "None",
|
"none": "None",
|
||||||
"separator": ", "
|
"separator": ", ",
|
||||||
|
"valid": "Valid"
|
||||||
},
|
},
|
||||||
"wrong_password_or_username": "Wrong password or username",
|
"wrong_password_or_username": "Wrong password or username",
|
||||||
"yes": "Yes",
|
"yes": "Yes",
|
||||||
|
|
|
@ -141,44 +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']
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
/* ───────╮
|
/* ───────╮
|
||||||
│ APPS │
|
│ APPS │
|
||||||
|
|
|
@ -97,3 +97,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;
|
||||||
|
|
|
@ -58,12 +58,20 @@ 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 {
|
||||||
|
|
|
@ -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)
|
||||||
},
|
},
|
||||||
|
@ -174,6 +205,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 => {
|
||||||
|
|
|
@ -1,73 +0,0 @@
|
||||||
<template>
|
|
||||||
<view-base
|
|
||||||
:queries="queries" @queries-response="onQueriesResponse"
|
|
||||||
ref="view" skeleton="card-form-skeleton"
|
|
||||||
>
|
|
||||||
<config-panels
|
|
||||||
v-if="config.panels" v-bind="config"
|
|
||||||
@submit="onConfigSubmit"
|
|
||||||
/>
|
|
||||||
</view-base>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import api, { objectToParams } from '@/api'
|
|
||||||
import {
|
|
||||||
formatFormData,
|
|
||||||
formatYunoHostConfigPanels
|
|
||||||
} from '@/helpers/yunohostArguments'
|
|
||||||
import ConfigPanels from '@/components/ConfigPanels'
|
|
||||||
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DomainConfig',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
ConfigPanels
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
name: { type: String, required: true }
|
|
||||||
},
|
|
||||||
|
|
||||||
data () {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', `domains/${this.name}/config?full`]
|
|
||||||
],
|
|
||||||
config: {}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse (config) {
|
|
||||||
this.config = formatYunoHostConfigPanels(config)
|
|
||||||
},
|
|
||||||
|
|
||||||
async onConfigSubmit ({ id, form, action, name }) {
|
|
||||||
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
|
||||||
const call = action
|
|
||||||
? api.put(
|
|
||||||
`domain/${this.name}/actions/${action}`,
|
|
||||||
{ args: objectToParams(args) },
|
|
||||||
{ key: 'domains.' + name, name: this.name }
|
|
||||||
)
|
|
||||||
: api.put(
|
|
||||||
`domains/${this.name}/config/${id}`,
|
|
||||||
{ args: objectToParams(args) },
|
|
||||||
{ key: 'domains.update_config', id, name: this.name }
|
|
||||||
)
|
|
||||||
|
|
||||||
call.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)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</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,76 +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>
|
||||||
|
|
||||||
<!-- DELETE -->
|
<!-- DOMAIN CERT AUTHORITY -->
|
||||||
<p>{{ $t('domain_delete_longdesc') }}</p>
|
<description-row :term="$t('domain.info.certificate_authority')">
|
||||||
<p
|
<icon :iname="cert.icon" :variant="cert.variant" class="mr-1" />
|
||||||
v-if="isMainDomain" class="alert alert-info"
|
{{ $t('domain.cert.types.' + cert.authority) }}
|
||||||
v-html="$t('domain_delete_forbidden_desc', { domain: name })"
|
<span class="text-secondary px-2">({{ $t('domain.cert.valid_for', { days: $tc('day_validity', cert.validity) }) }})</span>
|
||||||
/>
|
</description-row>
|
||||||
<b-button v-else variant="danger" @click="deleteDomain">
|
|
||||||
<icon iname="trash-o" /> {{ $t('delete') }}
|
<!-- DOMAIN REGISTRAR -->
|
||||||
</b-button>
|
<description-row v-if="domain.registrar" :term="$t('domain.info.registrar')">
|
||||||
|
<template v-if="domain.registrar === 'parent_domain'">
|
||||||
|
{{ $t('domain.see_parent_domain') }} <b-link :href="`#/domains/${domain.topest_parent}/dns`">
|
||||||
|
{{ domain.topest_parent }}
|
||||||
|
</b-link>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ domain.registrar }}
|
||||||
|
</template>
|
||||||
|
</description-row>
|
||||||
|
|
||||||
|
<!-- DOMAIN APPS -->
|
||||||
|
<description-row :term="$t('domain.info.apps_on_domain')">
|
||||||
|
<b-button-group
|
||||||
|
v-for="app in domain.apps" :key="app.id"
|
||||||
|
size="sm" class="mr-2"
|
||||||
|
>
|
||||||
|
<b-button class="py-0 font-weight-bold" variant="outline-dark" :to="{ name: 'app-info', params: { id: app.id }}">
|
||||||
|
{{ app.name }}
|
||||||
|
</b-button>
|
||||||
|
<b-button
|
||||||
|
variant="outline-dark" class="py-0 px-1"
|
||||||
|
:href="'https://' + name + app.path" target="_blank"
|
||||||
|
>
|
||||||
|
<span class="sr-only">{{ $t('app.visit_app') }}</span>
|
||||||
|
<icon iname="external-link" />
|
||||||
|
</b-button>
|
||||||
|
</b-button-group>
|
||||||
|
|
||||||
|
{{ domain.apps.length ? '' : $t('words.none') }}
|
||||||
|
</description-row>
|
||||||
</card>
|
</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
|
||||||
|
@ -78,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
|
||||||
|
@ -105,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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Reference in a new issue