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
|
||||
v-bind="{ id: panel.id + '-form', validation, serverError: panel.serverError }"
|
||||
@submit.prevent.stop="onApply"
|
||||
:no-footer="!panel.hasApplyButton"
|
||||
>
|
||||
<slot name="tab-top" />
|
||||
|
||||
|
@ -13,8 +14,10 @@
|
|||
|
||||
<template v-for="section in panel.sections">
|
||||
<component
|
||||
v-if="section.visible" :is="section.name ? 'section' : 'div'"
|
||||
:key="section.id" class="mb-5"
|
||||
v-if="section.visible"
|
||||
:is="section.name ? 'section' : 'div'"
|
||||
:key="section.id"
|
||||
class="panel-section"
|
||||
>
|
||||
<b-card-title v-if="section.name" title-tag="h3">
|
||||
{{ section.name }} <small v-if="section.help">{{ section.help }}</small>
|
||||
|
@ -86,6 +89,9 @@ export default {
|
|||
<style lang="scss" scoped>
|
||||
.card-title {
|
||||
margin-bottom: 1em;
|
||||
border-bottom: solid 1px #aaa;
|
||||
border-bottom: solid $border-width $gray-500;
|
||||
}
|
||||
::v-deep .panel-section:not(:last-child) {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,7 +3,11 @@
|
|||
:routes="routes_"
|
||||
v-bind="{ panels, forms, v: $v, ...$attrs }"
|
||||
v-on="$listeners"
|
||||
/>
|
||||
>
|
||||
<slot name="tab-top" slot="tab-top"></slot>
|
||||
<slot name="tab-before" slot="tab-before"></slot>
|
||||
<slot name="tab-after" slot="tab-after"></slot>
|
||||
</routable-tabs>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
|
|
112
app/src/components/RecursiveListGroup.vue
Normal file
112
app/src/components/RecursiveListGroup.vue
Normal file
|
@ -0,0 +1,112 @@
|
|||
<template>
|
||||
<b-list-group :flush="flush" :style="{ '--depth': tree.depth }">
|
||||
<template v-for="(node, i) in tree.children">
|
||||
<b-list-group-item
|
||||
:key="node.id"
|
||||
class="list-group-item-action" :class="getClasses(node, i)"
|
||||
@click="$router.push(node.data.to)"
|
||||
>
|
||||
<slot name="default" v-bind="node" />
|
||||
|
||||
<b-button
|
||||
v-if="node.children"
|
||||
size="xs" variant="outline-secondary"
|
||||
:aria-expanded="node.data.opened ? 'true' : 'false'" :aria-controls="'collapse-' + node.id"
|
||||
:class="node.data.opened ? 'not-collapsed' : 'collapsed'" class="ml-2"
|
||||
@click.stop="node.data.opened = !node.data.opened"
|
||||
>
|
||||
<span class="sr-only">{{ toggleText }}</span>
|
||||
<icon iname="chevron-right" />
|
||||
</b-button>
|
||||
</b-list-group-item>
|
||||
|
||||
<b-collapse
|
||||
v-if="node.children" :key="'collapse-' + node.id"
|
||||
v-model="node.data.opened" :id="'collapse-' + node.id"
|
||||
>
|
||||
<recursive-list-group
|
||||
:tree="node"
|
||||
:last="last !== undefined ? last : i === tree.children.length - 1" flush
|
||||
>
|
||||
<!-- PASS THE DEFAULT SLOT WITH SCOPE TO NEXT NESTED COMPONENT -->
|
||||
<template slot="default" slot-scope="scope">
|
||||
<slot name="default" v-bind="scope" />
|
||||
</template>
|
||||
</recursive-list-group>
|
||||
</b-collapse>
|
||||
</template>
|
||||
</b-list-group>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'RecursiveListGroup',
|
||||
|
||||
props: {
|
||||
tree: { type: Object, required: true },
|
||||
flush: { type: Boolean, default: false },
|
||||
last: { type: Boolean, default: undefined },
|
||||
toggleText: { type: String, default: null }
|
||||
},
|
||||
|
||||
methods: {
|
||||
getClasses (node, i) {
|
||||
const children = node.height > 0
|
||||
const opened = children && node.data.opened
|
||||
const last = this.last !== false && (!children || !opened) && i === this.tree.children.length - 1
|
||||
return { collapsible: children, uncollapsible: !children, opened, last }
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.list-group {
|
||||
.collapse {
|
||||
&:not(.show) + .list-group-item {
|
||||
border-end-start-radius: $border-radius;
|
||||
}
|
||||
&.show + .list-group-item {
|
||||
border-start-start-radius: $border-radius;
|
||||
}
|
||||
|
||||
+ .list-group-item {
|
||||
border-block-start-width: 1px !important;
|
||||
}
|
||||
}
|
||||
|
||||
&-item {
|
||||
&-action {
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
width: unset;
|
||||
}
|
||||
|
||||
&.collapsible.opened {
|
||||
border-end-start-radius: $border-radius;
|
||||
}
|
||||
&.collapsible:not(.opened, .last) {
|
||||
border-block-end-width: 0;
|
||||
}
|
||||
|
||||
&.last {
|
||||
border-block-end-width: $list-group-border-width;
|
||||
border-end-start-radius: $border-radius;
|
||||
}
|
||||
}
|
||||
|
||||
&-flush .list-group-item {
|
||||
margin-inline-start: calc(1rem * var(--depth));
|
||||
border-inline-end: $list-group-border-width solid $list-group-border-color;
|
||||
border-inline-start: $list-group-border-width solid $list-group-border-color;
|
||||
text-decoration: none;
|
||||
background-color: $list-group-hover-bg;
|
||||
|
||||
@include hover-focus() {
|
||||
background-color: darken($list-group-hover-bg, 3%);
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
|
@ -13,7 +13,11 @@
|
|||
</b-card-header>
|
||||
|
||||
<!-- Bind extra props to the child view and forward child events to parent -->
|
||||
<router-view v-bind="$attrs" v-on="$listeners" />
|
||||
<router-view v-bind="$attrs" v-on="$listeners">
|
||||
<slot name="tab-top" slot="tab-top"></slot>
|
||||
<slot name="tab-before" slot="tab-before"></slot>
|
||||
<slot name="tab-after" slot="tab-after"></slot>
|
||||
</router-view>
|
||||
</b-card>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -6,6 +6,7 @@
|
|||
<component :is="titleTag" class="custom-header-title">
|
||||
<icon v-if="icon" :iname="icon" class="mr-2" />{{ title }}
|
||||
</component>
|
||||
<slot name="header-next" />
|
||||
</slot>
|
||||
|
||||
<div v-if="hasButtons" class="mt-2 w-100 custom-header-buttons" :class="{ [`ml-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]: buttonUnbreak }">
|
||||
|
|
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>
|
||||
<span :class="'icon fa fa-' + iname" aria-hidden="true" />
|
||||
<span :class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']" aria-hidden="true" />
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
name: 'Icon',
|
||||
props: {
|
||||
iname: { type: String, required: true }
|
||||
iname: { type: String, required: true },
|
||||
variant: { type: String, default: null }
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
@ -26,5 +27,25 @@ export default {
|
|||
&.fs-sm {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&.variant {
|
||||
font-size: .8rem;
|
||||
width: 1.35rem;
|
||||
min-width: 1.35rem;
|
||||
height: 1.35rem;
|
||||
line-height: 165%;
|
||||
border-radius: 50rem;
|
||||
|
||||
@each $color, $value in $theme-colors {
|
||||
&.#{$color} {
|
||||
background-color: $value;
|
||||
color: $white;
|
||||
}
|
||||
}
|
||||
|
||||
&.warning {
|
||||
color: $black;
|
||||
}
|
||||
}
|
||||
}
|
||||
</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'
|
||||
|
||||
|
||||
const NO_VALUE_FIELDS = [
|
||||
'ReadOnlyField',
|
||||
'ReadOnlyAlertItem',
|
||||
'MarkdownItem',
|
||||
'DisplayTextItem',
|
||||
'ButtonItem'
|
||||
]
|
||||
|
||||
/**
|
||||
* Tries to find a translation corresponding to the user's locale/fallback locale in a
|
||||
* Yunohost argument or simply return the string if it's not an object literal.
|
||||
|
@ -359,7 +367,7 @@ export function formatYunoHostConfigPanels (data) {
|
|||
}
|
||||
|
||||
for (const { id: panelId, name, help, sections } of data.panels) {
|
||||
const panel = { id: panelId, sections: [], serverError: '' }
|
||||
const panel = { id: panelId, sections: [], serverError: '', hasApplyButton: false }
|
||||
result.forms[panelId] = {}
|
||||
result.validations[panelId] = {}
|
||||
result.errors[panelId] = {}
|
||||
|
@ -391,6 +399,10 @@ export function formatYunoHostConfigPanels (data) {
|
|||
Object.assign(result.errors[panelId], errors)
|
||||
section.fields = fields
|
||||
panel.sections.push(section)
|
||||
|
||||
if (!section.isActionSection && Object.values(fields).some((field) => !NO_VALUE_FIELDS.includes(field.is))) {
|
||||
panel.hasApplyButton = true
|
||||
}
|
||||
}
|
||||
|
||||
result.panels.push(panel)
|
||||
|
|
|
@ -134,6 +134,7 @@
|
|||
"delete": "Delete",
|
||||
"description": "Description",
|
||||
"details": "Details",
|
||||
"details_about": "Show more details about {subject}",
|
||||
"domain_dns_conf_is_just_a_recommendation": "This page shows you the *recommended* configuration. It does *not* configure the DNS for you. It is your responsibility to configure your DNS zone in your DNS registrar according to this recommendation.",
|
||||
"diagnosis": "Diagnosis",
|
||||
"diagnosis_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",
|
||||
"dns": "DNS",
|
||||
"domain": {
|
||||
"cert": {
|
||||
"types": {
|
||||
"selfsigned": "Self-signed",
|
||||
"letsencrypt": "Let's Encrypt",
|
||||
"other": "Other/Unknown"
|
||||
},
|
||||
"valid_for": "valid for {days}"
|
||||
},
|
||||
"config": {
|
||||
"edit": "Edit domain configuration",
|
||||
"title": "Domain configuration"
|
||||
|
@ -153,6 +162,13 @@
|
|||
"auto_config_ignored": "ignored, won't be changed by YunoHost unless you check the overwrite option",
|
||||
"auto_config_ok": "Automatic configuration seems to be OK!",
|
||||
"auto_config_zone": "Current DNS zone",
|
||||
"methods": {
|
||||
"auto": "Automatic",
|
||||
"handled_in_parent": "Handled in parent domain",
|
||||
"manual": "Manual",
|
||||
"none": "None",
|
||||
"semi_auto": "Semi-automatic"
|
||||
},
|
||||
"edit": "Edit DNS configuration",
|
||||
"info": "The automatic DNS records configuration is an experimental feature. <br>Consider saving your current DNS zone from your DNS registrar's interface before pushing records from here.",
|
||||
"manual_config": "Suggested DNS records for manual configuration",
|
||||
|
@ -160,7 +176,20 @@
|
|||
"push_force": "Overwrite existing records",
|
||||
"push_force_confirm": "Are you sure you want to force push all suggested dns records? Be aware that it may overwrite manually or important default records set by you or your registrar.",
|
||||
"push_force_warning": "It looks like some DNS records that YunoHost would have set are already in the registrar configuration. You can use the overwrite option if you know what you are doing."
|
||||
}
|
||||
},
|
||||
"explain": {
|
||||
"main_domain": "The main domain is the domain from which users can connect to the portal (via \"{domain}/yunohost/sso\").<br>Therefore, it is not possible to delete it.<br>If you want to delete \"{domain}\", you will first have to choose or add another domain and set it as the main domain."
|
||||
},
|
||||
"info": {
|
||||
"apps_on_domain": "Apps installed on domain",
|
||||
"certificate_authority": "SSL Certification authority",
|
||||
"registrar": "Registrar"
|
||||
},
|
||||
"see_parent_domain": "See parent domain",
|
||||
"types": {
|
||||
"main_domain": "Main domain"
|
||||
},
|
||||
"toggle_subdomains": "Toggle subdomains"
|
||||
},
|
||||
"domain_add": "Add domain",
|
||||
"domain_add_dns_doc": "… and I have <a href='//yunohost.org/dns_config' target='_blank'>set my DNS correctly</a>.",
|
||||
|
@ -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_not_applicable": "The automatic DNS records feature is not applicable to domain {domain},<br> You should manually configure your DNS records following the <a href='https://yunohost.org/dns'>documentation</a> and the suggested configuration below.",
|
||||
"domain_name": "Domain name",
|
||||
"domain_visit": "Visit",
|
||||
"domain_visit_url": "Visit {url}",
|
||||
"domains": "Domains",
|
||||
"download": "Download",
|
||||
"enable": "Enable",
|
||||
|
@ -550,8 +577,10 @@
|
|||
"browse": "Browse",
|
||||
"collapse": "Collapse",
|
||||
"default": "Default",
|
||||
"link": "Link",
|
||||
"none": "None",
|
||||
"separator": ", "
|
||||
"separator": ", ",
|
||||
"valid": "Valid"
|
||||
},
|
||||
"wrong_password_or_username": "Wrong password or username",
|
||||
"yes": "Yes",
|
||||
|
|
|
@ -141,44 +141,23 @@ const routes = [
|
|||
}
|
||||
},
|
||||
{
|
||||
name: 'domain-info',
|
||||
path: '/domains/:name',
|
||||
component: () => import(/* webpackChunkName: "views/domain/info" */ '@/views/domain/DomainInfo'),
|
||||
props: true,
|
||||
meta: {
|
||||
args: { param: 'name' },
|
||||
breadcrumb: ['domain-list', 'domain-info']
|
||||
}
|
||||
},
|
||||
{
|
||||
// no need for name here, only children are visited
|
||||
path: '/domains/:name/config',
|
||||
component: () => import(/* webpackChunkName: "views/domain/config" */ '@/views/domain/DomainConfig'),
|
||||
props: true,
|
||||
children: [
|
||||
{
|
||||
name: 'domain-config',
|
||||
name: 'domain-info',
|
||||
path: ':tabId?',
|
||||
component: () => import(/* webpackChunkName: "components/configPanel" */ '@/components/ConfigPanel'),
|
||||
props: true,
|
||||
meta: {
|
||||
routerParams: ['name'], // Override router key params to avoid view recreation at tab change.
|
||||
args: { trad: 'config' },
|
||||
breadcrumb: ['domain-list', 'domain-info', 'domain-config']
|
||||
args: { param: 'name' },
|
||||
breadcrumb: ['domain-list', 'domain-info']
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
name: 'domain-dns',
|
||||
path: '/domains/:name/dns',
|
||||
component: () => import(/* webpackChunkName: "views/domain/dns" */ '@/views/domain/DomainDns'),
|
||||
props: true,
|
||||
meta: {
|
||||
args: { trad: 'dns' },
|
||||
breadcrumb: ['domain-list', 'domain-info', 'domain-dns']
|
||||
}
|
||||
},
|
||||
|
||||
/* ───────╮
|
||||
│ APPS │
|
||||
|
|
|
@ -97,3 +97,7 @@ $fa-font-size-base: $font-size-base;
|
|||
// ╰────────────────────╯
|
||||
|
||||
$thin-border: $hr-border-width solid $hr-border-color;
|
||||
|
||||
$btn-padding-y-xs: .25rem;
|
||||
$btn-padding-x-xs: .35rem;
|
||||
$btn-line-height-xs: 1.5;
|
||||
|
|
|
@ -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
|
||||
.input-group .is-invalid ~ .invalid-feedback {
|
||||
display: block;
|
||||
}
|
||||
|
||||
|
||||
.tooltip { top: 0; }
|
||||
// Descriptive list (<b-row /> elems with <b-col> inside)
|
||||
// FIXME REMOVE when every infos switch to `DescriptionRow`
|
||||
.row-line {
|
||||
@include media-breakpoint-up(md) {
|
||||
&:hover {
|
||||
|
|
|
@ -2,12 +2,27 @@ import Vue from 'vue'
|
|||
|
||||
import api from '@/api'
|
||||
import { isEmptyValue } from '@/helpers/commons'
|
||||
import { stratify } from '@/helpers/data/tree'
|
||||
|
||||
|
||||
export function getParentDomain (domain, domains, highest = false) {
|
||||
const method = highest ? 'lastIndexOf' : 'indexOf'
|
||||
let i = domain[method]('.')
|
||||
while (i !== -1) {
|
||||
const dn = domain.slice(i + 1)
|
||||
if (domains.includes(dn)) return dn
|
||||
i = domain[method]('.', i + (highest ? -1 : 1))
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
|
||||
export default {
|
||||
state: () => ({
|
||||
domains: undefined, // Array
|
||||
main_domain: undefined,
|
||||
domains: undefined, // Array
|
||||
domains_details: {},
|
||||
users: undefined, // basic user data: Object {username: {data}}
|
||||
users_details: {}, // precise user data: Object {username: {data}}
|
||||
groups: undefined,
|
||||
|
@ -19,6 +34,22 @@ export default {
|
|||
state.domains = domains
|
||||
},
|
||||
|
||||
'SET_DOMAINS_DETAILS' (state, [name, details]) {
|
||||
Vue.set(state.domains_details, name, details)
|
||||
},
|
||||
|
||||
'UPDATE_DOMAINS_DETAILS' (state, payload) {
|
||||
// FIXME use a common function to execute the same code ?
|
||||
this.commit('SET_DOMAINS_DETAILS', payload)
|
||||
},
|
||||
|
||||
'DEL_DOMAINS_DETAILS' (state, [name]) {
|
||||
Vue.delete(state.domains_details, name)
|
||||
if (state.domains) {
|
||||
Vue.delete(state.domains, name)
|
||||
}
|
||||
},
|
||||
|
||||
'ADD_DOMAINS' (state, [{ domain }]) {
|
||||
state.domains.push(domain)
|
||||
},
|
||||
|
@ -174,6 +205,44 @@ export default {
|
|||
|
||||
domains: state => state.domains,
|
||||
|
||||
orderedDomains: state => {
|
||||
if (!state.domains) return
|
||||
|
||||
const splittedDomains = Object.fromEntries(state.domains.map(domain => {
|
||||
// Keep the main part of the domain and the extension together
|
||||
// eg: this.is.an.example.com -> ['example.com', 'an', 'is', 'this']
|
||||
domain = domain.split('.')
|
||||
domain.push(domain.pop() + domain.pop())
|
||||
return [domain, domain.reverse()]
|
||||
}))
|
||||
|
||||
return state.domains.sort((a, b) => splittedDomains[a] > splittedDomains[b])
|
||||
},
|
||||
|
||||
domainsTree: (state, getters) => {
|
||||
// This getter will not return any reactive data, make sure to assign its output
|
||||
// to a component's `data`.
|
||||
// FIXME manage to store the result in the store to allow reactive data (trigger an
|
||||
// action when state.domain change)
|
||||
const domains = getters.orderedDomains
|
||||
if (!domains) return
|
||||
const dataset = domains.map(name => ({
|
||||
// data to build a hierarchy
|
||||
name,
|
||||
parent: getParentDomain(name, domains),
|
||||
// utility data that will be used by `RecursiveListGroup` component
|
||||
to: { name: 'domain-info', params: { name } },
|
||||
opened: true
|
||||
}))
|
||||
return stratify(dataset)
|
||||
},
|
||||
|
||||
domain: state => name => state.domains_details[name],
|
||||
|
||||
highestDomainParentName: (state, getters) => name => {
|
||||
return getParentDomain(name, getters.orderedDomains, true)
|
||||
},
|
||||
|
||||
mainDomain: state => state.main_domain,
|
||||
|
||||
domainsAsChoices: state => {
|
||||
|
|
|
@ -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"
|
||||
skeleton="card-info-skeleton"
|
||||
>
|
||||
<card v-if="showAutoConfigCard" :title="$t('domain.dns.auto_config')" icon="wrench">
|
||||
<b-alert variant="warning">
|
||||
<icon iname="flask" /> <icon iname="warning" /> <span v-html="$t('domain.dns.info')" />
|
||||
</b-alert>
|
||||
<section v-if="showAutoConfigCard" class="panel-section">
|
||||
<b-card-title title-tag="h3">
|
||||
{{ $t('domain.dns.auto_config') }}
|
||||
</b-card-title>
|
||||
|
||||
<read-only-alert-item
|
||||
:label="$t('domain.dns.info')"
|
||||
type="warning"
|
||||
icon="flask"
|
||||
/>
|
||||
|
||||
<!-- AUTO CONFIG CHANGES -->
|
||||
<template v-if="dnsChanges">
|
||||
|
@ -32,27 +38,32 @@
|
|||
</template>
|
||||
|
||||
<!-- CONFIG OK ALERT -->
|
||||
<b-alert v-else-if="dnsChanges === null" variant="success" class="m-0">
|
||||
<icon iname="thumbs-up" /> {{ $t('domain.dns.auto_config_ok') }}
|
||||
</b-alert>
|
||||
<read-only-alert-item
|
||||
v-else-if="dnsChanges === null"
|
||||
:label="$t('domain.dns.auto_config_ok')"
|
||||
type="success"
|
||||
icon="thumbs-up"
|
||||
/>
|
||||
|
||||
<!-- CONFIG ERROR ALERT -->
|
||||
<template v-if="dnsErrors && dnsErrors.length">
|
||||
<b-alert
|
||||
<read-only-alert-item
|
||||
v-for="({ variant, icon, message }, i) in dnsErrors" :key="i"
|
||||
:variant="variant" :class="dnsErrors.length === 1 ? 'm-0' : ''"
|
||||
>
|
||||
<icon :iname="icon" /> <span v-html="message" />
|
||||
</b-alert>
|
||||
:label="message"
|
||||
:type="variant"
|
||||
:icon="icon"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- CONFIG OVERWRITE DISCLAIMER -->
|
||||
<b-alert v-if="force !== null" variant="warning">
|
||||
<icon iname="warning" /> <span v-html="$t('domain.dns.push_force_warning')" />
|
||||
</b-alert>
|
||||
<read-only-alert-item
|
||||
v-if="force !== null"
|
||||
:label="$t('domain.dns.push_force_warning')"
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
<!-- CONFIG PUSH SUBMIT -->
|
||||
<template v-if="dnsChanges" #buttons>
|
||||
<template v-if="dnsChanges">
|
||||
<b-form-checkbox v-if="force !== null" v-model="force">
|
||||
{{ $t('domain.dns.push_force') }}
|
||||
</b-form-checkbox>
|
||||
|
@ -61,13 +72,14 @@
|
|||
{{ $t('domain.dns.push') }}
|
||||
</b-button>
|
||||
</template>
|
||||
</card>
|
||||
</section>
|
||||
|
||||
<!-- CURRENT DNS ZONE -->
|
||||
<card
|
||||
v-if="showAutoConfigCard && dnsZone && dnsZone.length"
|
||||
:title="$t('domain.dns.auto_config_zone')" icon="globe" no-body
|
||||
>
|
||||
<section v-if="showAutoConfigCard && dnsZone && dnsZone.length" class="panel-section">
|
||||
<b-card-title title-tag="h3">
|
||||
{{ $t('domain.dns.auto_config_zone') }}
|
||||
</b-card-title>
|
||||
|
||||
<div class="log">
|
||||
<div v-for="({ name: record, spaces, content, type }, i) in dnsZone" :key="'zone-' + i" class="records">
|
||||
{{ record }}
|
||||
|
@ -75,19 +87,21 @@
|
|||
<span>{{ content }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</card>
|
||||
</section>
|
||||
|
||||
<!-- MANUAL CONFIG CARD -->
|
||||
<card
|
||||
v-if="showManualConfigCard"
|
||||
:title="$t('domain.dns.manual_config')" icon="globe" no-body
|
||||
>
|
||||
<b-alert variant="warning" class="m-0">
|
||||
<icon iname="warning" /> {{ $t('domain_dns_conf_is_just_a_recommendation') }}
|
||||
</b-alert>
|
||||
<section v-if="showManualConfigCard" class="panel-section">
|
||||
<b-card-title title-tag="h3">
|
||||
{{ $t('domain.dns.manual_config') }}
|
||||
</b-card-title>
|
||||
|
||||
<read-only-alert-item
|
||||
:label="$t('domain_dns_conf_is_just_a_recommendation')"
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
<pre class="log">{{ dnsConfig }}</pre>
|
||||
</card>
|
||||
</section>
|
||||
</view-base>
|
||||
</template>
|
||||
|
||||
|
|
|
@ -1,76 +1,160 @@
|
|||
<template>
|
||||
<view-base :queries="queries" skeleton="card-list-skeleton">
|
||||
<card :title="name" icon="globe">
|
||||
<!-- VISIT -->
|
||||
<p>{{ $t('domain_visit_url', { url: 'https://' + name }) }}</p>
|
||||
<b-button variant="success" :href="'https://' + name" target="_blank">
|
||||
<icon iname="external-link" /> {{ $t('domain_visit') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
<view-base
|
||||
:queries="queries" @queries-response="onQueriesResponse"
|
||||
ref="view" skeleton="card-list-skeleton"
|
||||
>
|
||||
<!-- INFO CARD -->
|
||||
<card v-if="domain" :title="name" icon="globe">
|
||||
<template v-if="isMainDomain" #header-next>
|
||||
<b-badge variant="info" class="main-domain-badge">
|
||||
<explain-what
|
||||
id="explain-main-domain"
|
||||
:title="$t('domain.types.main_domain')"
|
||||
:content="$t('domain.explain.main_domain', { domain: name })"
|
||||
>
|
||||
<icon iname="star" /> {{ $t('domain.types.main_domain') }}
|
||||
</explain-what>
|
||||
</b-badge>
|
||||
</template>
|
||||
|
||||
<!-- DEFAULT DOMAIN -->
|
||||
<p>{{ $t('domain_default_desc') }}</p>
|
||||
<p v-if="isMainDomain" class="alert alert-info">
|
||||
<icon iname="star" /> {{ $t('domain_default_longdesc') }}
|
||||
</p>
|
||||
<b-button v-else variant="info" @click="setAsDefaultDomain">
|
||||
<icon iname="star" /> {{ $t('set_default') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
<template #header-buttons>
|
||||
<!-- DEFAULT DOMAIN -->
|
||||
<b-button v-if="!isMainDomain" @click="setAsDefaultDomain" variant="info">
|
||||
<icon iname="star" /> {{ $t('set_default') }}
|
||||
</b-button>
|
||||
|
||||
<!-- DOMAIN CONFIG -->
|
||||
<p>{{ $t('domain.config.edit') }}</p>
|
||||
<b-button variant="warning" :to="{ name: 'domain-config', param: { name } }">
|
||||
<icon iname="cog" /> {{ $t('domain.config.title') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
<!-- DELETE DOMAIN -->
|
||||
<b-button @click="deleteDomain" :disabled="isMainDomain" variant="danger">
|
||||
<icon iname="trash-o" /> {{ $t('delete') }}
|
||||
</b-button>
|
||||
</template>
|
||||
|
||||
<!-- DNS CONFIG -->
|
||||
<p>{{ $t('domain.dns.edit') }}</p>
|
||||
<b-button variant="warning" :to="{ name: 'domain-dns', param: { name } }">
|
||||
<icon iname="globe" /> {{ $t('domain_dns_config') }}
|
||||
</b-button>
|
||||
<hr>
|
||||
<!-- DOMAIN LINK -->
|
||||
<description-row :term="$t('words.link')">
|
||||
<b-link :href="'https://' + name" target="_blank">
|
||||
https://{{ name }}
|
||||
</b-link>
|
||||
</description-row>
|
||||
|
||||
<!-- DELETE -->
|
||||
<p>{{ $t('domain_delete_longdesc') }}</p>
|
||||
<p
|
||||
v-if="isMainDomain" class="alert alert-info"
|
||||
v-html="$t('domain_delete_forbidden_desc', { domain: name })"
|
||||
/>
|
||||
<b-button v-else variant="danger" @click="deleteDomain">
|
||||
<icon iname="trash-o" /> {{ $t('delete') }}
|
||||
</b-button>
|
||||
<!-- DOMAIN CERT AUTHORITY -->
|
||||
<description-row :term="$t('domain.info.certificate_authority')">
|
||||
<icon :iname="cert.icon" :variant="cert.variant" class="mr-1" />
|
||||
{{ $t('domain.cert.types.' + cert.authority) }}
|
||||
<span class="text-secondary px-2">({{ $t('domain.cert.valid_for', { days: $tc('day_validity', cert.validity) }) }})</span>
|
||||
</description-row>
|
||||
|
||||
<!-- DOMAIN REGISTRAR -->
|
||||
<description-row v-if="domain.registrar" :term="$t('domain.info.registrar')">
|
||||
<template v-if="domain.registrar === 'parent_domain'">
|
||||
{{ $t('domain.see_parent_domain') }} <b-link :href="`#/domains/${domain.topest_parent}/dns`">
|
||||
{{ domain.topest_parent }}
|
||||
</b-link>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ domain.registrar }}
|
||||
</template>
|
||||
</description-row>
|
||||
|
||||
<!-- DOMAIN APPS -->
|
||||
<description-row :term="$t('domain.info.apps_on_domain')">
|
||||
<b-button-group
|
||||
v-for="app in domain.apps" :key="app.id"
|
||||
size="sm" class="mr-2"
|
||||
>
|
||||
<b-button class="py-0 font-weight-bold" variant="outline-dark" :to="{ name: 'app-info', params: { id: app.id }}">
|
||||
{{ app.name }}
|
||||
</b-button>
|
||||
<b-button
|
||||
variant="outline-dark" class="py-0 px-1"
|
||||
:href="'https://' + name + app.path" target="_blank"
|
||||
>
|
||||
<span class="sr-only">{{ $t('app.visit_app') }}</span>
|
||||
<icon iname="external-link" />
|
||||
</b-button>
|
||||
</b-button-group>
|
||||
|
||||
{{ domain.apps.length ? '' : $t('words.none') }}
|
||||
</description-row>
|
||||
</card>
|
||||
|
||||
<config-panels v-if="config.panels" v-bind="config" @submit="onConfigSubmit">
|
||||
<template v-if="currentTab === 'dns'" #tab-after>
|
||||
<domain-dns :name="name" />
|
||||
</template>
|
||||
</config-panels>
|
||||
</view-base>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import api from '@/api'
|
||||
import api, { objectToParams } from '@/api'
|
||||
import {
|
||||
formatFormData,
|
||||
formatYunoHostConfigPanels
|
||||
} from '@/helpers/yunohostArguments'
|
||||
import ConfigPanels from '@/components/ConfigPanels'
|
||||
import DomainDns from './DomainDns.vue'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'DomainInfo',
|
||||
|
||||
props: {
|
||||
name: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
components: {
|
||||
ConfigPanels,
|
||||
DomainDns
|
||||
},
|
||||
|
||||
data: () => {
|
||||
props: {
|
||||
name: { type: String, required: true }
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
queries: [
|
||||
['GET', { uri: 'domains/main', storeKey: 'main_domain' }]
|
||||
]
|
||||
['GET', { uri: 'domains', storeKey: 'domains' }],
|
||||
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
|
||||
['GET', { uri: 'domains', storeKey: 'domains_details', param: this.name }],
|
||||
['GET', `domains/${this.name}/config?full`]
|
||||
],
|
||||
config: {}
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['mainDomain']),
|
||||
|
||||
currentTab () {
|
||||
return this.$route.params.tabId
|
||||
},
|
||||
|
||||
domain () {
|
||||
return this.$store.getters.domain(this.name)
|
||||
},
|
||||
|
||||
parentName () {
|
||||
return this.$store.getters.highestDomainParentName(this.name)
|
||||
},
|
||||
|
||||
cert () {
|
||||
const { CA_type: authority, validity } = this.domain.certificate
|
||||
const baseInfos = { authority, validity }
|
||||
if (validity <= 0) {
|
||||
return { icon: 'times', variant: 'danger', ...baseInfos }
|
||||
} else if (authority === 'other') {
|
||||
return validity < 15
|
||||
? { icon: 'exclamation', variant: 'danger', ...baseInfos }
|
||||
: { icon: 'check', variant: 'success', ...baseInfos }
|
||||
} else if (authority === 'letsencrypt') {
|
||||
return { icon: 'thumbs-up', variant: 'success', ...baseInfos }
|
||||
}
|
||||
return { icon: 'exclamation', variant: 'warning', ...baseInfos }
|
||||
},
|
||||
|
||||
dns () {
|
||||
return this.domain.dns
|
||||
},
|
||||
|
||||
isMainDomain () {
|
||||
if (!this.mainDomain) return
|
||||
return this.name === this.mainDomain
|
||||
|
@ -78,6 +162,30 @@ export default {
|
|||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse (domains, mainDomain, domain, config) {
|
||||
this.config = formatYunoHostConfigPanels(config)
|
||||
},
|
||||
|
||||
async onConfigSubmit ({ id, form, action, name }) {
|
||||
const args = await formatFormData(form, { removeEmpty: false, removeNull: true })
|
||||
|
||||
api.put(
|
||||
action
|
||||
? `domain/${this.name}/actions/${action}`
|
||||
: `domains/${this.name}/config/${id}`,
|
||||
{ args: objectToParams(args) },
|
||||
{ key: `domains.${action ? 'action' : 'update'}_config`, id, name: this.name }
|
||||
).then(() => {
|
||||
this.$refs.view.fetchQueries({ triggerLoading: true })
|
||||
}).catch(err => {
|
||||
if (err.name !== 'APIBadRequestError') throw err
|
||||
const panel = this.config.panels.find(panel => panel.id === id)
|
||||
if (err.data.name) {
|
||||
this.config.errors[id][err.data.name].message = err.message
|
||||
} else this.$set(panel, 'serverError', err.message)
|
||||
})
|
||||
},
|
||||
|
||||
async deleteDomain () {
|
||||
const confirmed = await this.$askConfirmation(this.$i18n.t('confirm_delete', { name: this.name }))
|
||||
if (!confirmed) return
|
||||
|
@ -105,3 +213,10 @@ export default {
|
|||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.main-domain-badge {
|
||||
font-size: .75rem;
|
||||
padding-right: .2em;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -3,9 +3,10 @@
|
|||
id="domain-list"
|
||||
:search.sync="search"
|
||||
:items="domains"
|
||||
:filtered-items="filteredDomains"
|
||||
items-name="domains"
|
||||
:queries="queries"
|
||||
:filtered-items="hasFilteredItems"
|
||||
@queries-response="onQueriesResponse"
|
||||
>
|
||||
<template #top-bar-buttons>
|
||||
<b-button variant="success" :to="{ name: 'domain-add' }">
|
||||
|
@ -14,57 +15,75 @@
|
|||
</b-button>
|
||||
</template>
|
||||
|
||||
<b-list-group>
|
||||
<b-list-group-item
|
||||
v-for="domain in filteredDomains" :key="domain"
|
||||
:to="{ name: 'domain-info', params: { name: domain }}"
|
||||
class="d-flex justify-content-between align-items-center pr-0"
|
||||
>
|
||||
<div>
|
||||
<h5 class="font-weight-bold">
|
||||
{{ domain }}
|
||||
<small v-if="domain === mainDomain">
|
||||
<span class="sr-only">{{ $t('words.default') }}</span>
|
||||
<icon iname="star" :title="$t('words.default')" />
|
||||
<recursive-list-group :tree="tree" :toggle-text="$t('domain.toggle_subdomains')" class="mb-5">
|
||||
<template #default="{ data, parent }">
|
||||
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||
<h5 class="mr-3">
|
||||
<b-link :to="data.to" class="text-body text-decoration-none">
|
||||
<span class="font-weight-bold">{{ data.name.replace(parent ? parent.data.name : null, '') }}</span>
|
||||
<span v-if="parent" class="text-secondary">{{ parent.data.name }}</span>
|
||||
</b-link>
|
||||
|
||||
<small
|
||||
v-if="data.name === mainDomain"
|
||||
:title="$t('domain.types.main_domain')" class="ml-1"
|
||||
v-b-tooltip.hover
|
||||
>
|
||||
<icon iname="star" />
|
||||
</small>
|
||||
</h5>
|
||||
<p class="font-italic m-0">
|
||||
https://{{ domain }}
|
||||
</p>
|
||||
</div>
|
||||
<icon iname="chevron-right" class="lg fs-sm ml-auto" />
|
||||
</b-list-group-item>
|
||||
</b-list-group>
|
||||
</template>
|
||||
</recursive-list-group>
|
||||
</view-search>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { mapGetters } from 'vuex'
|
||||
|
||||
import RecursiveListGroup from '@/components/RecursiveListGroup'
|
||||
|
||||
|
||||
export default {
|
||||
name: 'DomainList',
|
||||
|
||||
components: {
|
||||
RecursiveListGroup
|
||||
},
|
||||
|
||||
data () {
|
||||
return {
|
||||
queries: [
|
||||
['GET', { uri: 'domains/main', storeKey: 'main_domain' }],
|
||||
['GET', { uri: 'domains' }]
|
||||
['GET', { uri: 'domains', storeKey: 'domains' }]
|
||||
],
|
||||
search: ''
|
||||
search: '',
|
||||
domainsTree: undefined
|
||||
}
|
||||
},
|
||||
|
||||
computed: {
|
||||
...mapGetters(['domains', 'mainDomain']),
|
||||
|
||||
filteredDomains () {
|
||||
if (!this.domains || !this.mainDomain) return
|
||||
const search = this.search.toLowerCase()
|
||||
const mainDomain = this.mainDomain
|
||||
const domains = this.domains
|
||||
.filter(name => name.toLowerCase().includes(search))
|
||||
.sort(prevDomain => prevDomain === mainDomain ? -1 : 1)
|
||||
return domains.length ? domains : null
|
||||
tree () {
|
||||
if (!this.domainsTree) return
|
||||
if (this.search) {
|
||||
const search = this.search.toLowerCase()
|
||||
return this.domainsTree.filter(node => node.data.name.includes(search))
|
||||
}
|
||||
return this.domainsTree
|
||||
},
|
||||
|
||||
hasFilteredItems () {
|
||||
if (!this.tree) return
|
||||
return this.tree.children || null
|
||||
}
|
||||
},
|
||||
|
||||
methods: {
|
||||
onQueriesResponse () {
|
||||
// Add the tree to `data` to make it reactive
|
||||
this.domainsTree = this.$store.getters.domainsTree
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue