Merge pull request #468 from YunoHost/11.1

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

View file

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

View file

Before

Width:  |  Height:  |  Size: 8.3 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

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

View file

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

View file

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

View file

@ -13,7 +13,11 @@
</b-card-header>
<!-- 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>

View file

@ -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 }">

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,65 @@
<template>
<b-row no-gutters class="description-row">
<b-col v-bind="cols_" class="font-weight-bold">
{{ label }}
</b-col>
<!-- FIXME not sure about rendering html -->
<b-col v-html="text" />
</b-row>
</template>
<script>
export default {
name: 'ReadOnlyField',
inheritAttrs: false,
props: {
label: { type: String, required: true },
component: { type: String, default: 'InputItem' },
value: { type: null, default: null },
cols: { type: Object, default: () => ({ md: 4, lg: 3 }) }
},
computed: {
cols_ () {
return Object.assign({ md: 4, lg: 3 }, this.cols)
},
text () {
return this.parseValue(this.value)
}
},
methods: {
parseValue (value) {
const item = this.component
if (item === 'FileItem') value = value.file ? value.file.name : null
if (item === 'CheckboxItem') value = this.$i18n.t(value ? 'yes' : 'no')
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
if (Array.isArray(value)) {
value = value.length ? value.join(this.$i18n.t('words.separator')) : null
}
if ([null, undefined, ''].includes(this.value)) value = this.$i18n.t('words.none')
return value
}
}
}
</script>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {
margin: 1rem 0;
}
@include media-breakpoint-down(sm) {
flex-direction: column;
&:not(:last-of-type) {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: $border-width solid $card-border-color;
}
}
}
</style>

View file

@ -0,0 +1,40 @@
<template>
<b-button
:id="id"
:variant="type"
@click="$emit('action', $event)"
:disabled="!enabled"
class="d-block mb-3"
>
<icon :iname="icon_" class="mr-2" />
<span v-html="label" />
</b-button>
</template>
<script>
export default {
name: 'ButtonItem',
props: {
label: { type: String, default: null },
id: { type: String, default: null },
type: { type: String, default: 'success' },
icon: { type: String, default: null },
enabled: { type: [Boolean, String], default: true }
},
computed: {
icon_ () {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times'
}
return this.icon || icons[this.type]
}
}
}
</script>

View file

@ -0,0 +1,16 @@
<template>
<div>
<p v-text="label" />
</div>
</template>
<script>
export default {
name: 'DisplayTextItem',
props: {
id: { type: String, default: null },
label: { type: String, default: null }
}
}
</script>

View file

@ -1,19 +1,24 @@
<template>
<b-button-group class="w-100">
<b-button @click="clearFiles" variant="danger" v-if="!this.required && this.value !== null && !this.value._removed">
<b-button
v-if="!this.required && this.value.file !== null"
@click="clearFiles" variant="danger"
>
<span class="sr-only">{{ $t('delete') }}</span>
<icon iname="trash" />
</b-button>
<b-form-file
v-model="file"
:value="value.file"
ref="input-file"
:id="id"
v-on="$listeners"
:required="required"
:placeholder="_placeholder"
:accept="accept"
:drop-placeholder="dropPlaceholder"
:state="state"
:browse-text="$t('words.browse')"
@input="onInput"
@blur="$parent.$emit('touch', name)"
@focusout.native="$parent.$emit('touch', name)"
/>
@ -21,18 +26,14 @@
</template>
<script>
import { getFileContent } from '@/helpers/commons'
export default {
name: 'FileItem',
data () {
return {
file: this.value
}
},
props: {
id: { type: String, default: null },
value: { type: [File, null], default: null },
value: { type: Object, default: () => ({ file: null }) },
placeholder: { type: String, default: 'Choose a file or drop it here...' },
dropPlaceholder: { type: String, default: null },
accept: { type: String, default: null },
@ -43,22 +44,35 @@ export default {
computed: {
_placeholder: function () {
return (this.value === null) ? this.placeholder : this.value.name
return this.value.file === null ? this.placeholder : this.value.file.name
}
},
methods: {
clearFiles () {
const f = new File([''], this.placeholder)
f._removed = true
if (this.value && this.value.currentfile) {
this.$refs['input-file'].reset()
this.$emit('input', f)
} else {
this.$refs['input-file'].setFiles([f])
this.file = f
this.$emit('input', f)
onInput (file) {
const value = {
file,
content: '',
current: false,
removed: false
}
// Update the value with the new File and an empty content for now
this.$emit('input', value)
// Asynchronously load the File content and update the value again
getFileContent(file).then(content => {
this.$emit('input', { ...value, content })
})
},
clearFiles () {
this.$refs['input-file'].reset()
this.$emit('input', {
file: null,
content: '',
current: false,
removed: true
})
}
}
}

View file

@ -2,7 +2,6 @@
<b-input
:value="value"
:id="id"
v-on="$listeners"
:placeholder="placeholder"
:type="type"
:state="state"
@ -12,6 +11,7 @@
:step="step"
:trim="trim"
:autocomplete="autocomplete_"
v-on="$listeners"
@blur="$parent.$emit('touch', name)"
/>
</template>
@ -21,11 +21,6 @@
export default {
name: 'InputItem',
data () {
return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
}
},
props: {
value: { type: [String, Number], default: null },
id: { type: String, default: null },
@ -40,6 +35,12 @@ export default {
autocomplete: { type: String, default: null },
pattern: { type: Object, default: null },
name: { type: String, default: null }
},
data () {
return {
autocomplete_: (this.autocomplete) ? this.autocomplete : (this.type === 'password') ? 'new-password' : null
}
}
}
</script>

View file

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

View file

@ -1,8 +1,10 @@
<template>
<b-alert class="d-flex" :variant="type" show>
<icon :iname="icon_" class="mr-1 mt-1" />
<vue-showdown :markdown="label" flavor="github"
tag="span" class="markdown"
<b-alert class="d-flex flex-column flex-md-row align-items-center" :variant="type" show>
<icon :iname="icon_" class="mr-md-3 mb-md-0 mb-2" :variant="type" />
<vue-showdown
:markdown="label" flavor="github"
tag="span" class="markdown"
/>
</b-alert>
</template>
@ -11,29 +13,23 @@
export default {
name: 'ReadOnlyAlertItem',
data () {
const icons = {
success: 'thumbs-up',
info: 'info-circle',
warning: 'warning',
danger: 'times'
}
return {
icon_: (this.icon) ? this.icon : icons[this.type]
}
},
props: {
id: { type: String, default: null },
label: { type: String, default: null },
type: { type: String, default: null },
icon: { type: String, default: null }
},
computed: {
icon_ () {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times'
}
return this.icon || icons[this.type]
}
}
}
</script>
<style lang="scss">
.alert p:last-child {
margin-bottom: 0;
}
</style>

View file

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

View file

@ -1,6 +1,6 @@
<template>
<b-form-textarea
v-model="value"
:value="value"
:id="id"
:placeholder="placeholder"
:required="required"

View file

@ -63,6 +63,19 @@ export function flattenObjectLiteral (obj, flattened = {}) {
}
/**
* Returns an new Object filtered with passed filter function.
* Each entry `[key, value]` will be forwarded to the `filter` function.
*
* @param {Object} obj - object to filter.
* @param {Function} filter - the filter function to call for each entry.
* @return {Object}
*/
export function filterObject (obj, filter) {
return Object.fromEntries(Object.entries(obj).filter((...args) => filter(...args)))
}
/**
* Returns an new array containing items that are in first array but not in the other.
*
@ -100,3 +113,26 @@ export function escapeHtml (unsafe) {
export function randint (min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min
}
/**
* Returns a File content.
*
* @param {File} file
* @param {Object} [extraParams] - Optionnal params
* @param {Boolean} [extraParams.base64] - returns a base64 representation of the file.
* @return {Promise<String>}
*/
export function getFileContent (file, { base64 = false } = {}) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.onerror = reject
reader.onload = () => resolve(reader.result)
if (base64) {
reader.readAsDataURL(file)
} else {
reader.readAsText(file)
}
})
}

View file

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

View file

@ -2,9 +2,22 @@ import i18n from '@/i18n'
import store from '@/store'
import evaluate from 'simple-evaluate'
import * as validators from '@/helpers/validators'
import { isObjectLiteral, isEmptyValue, flattenObjectLiteral } from '@/helpers/commons'
import {
isObjectLiteral,
isEmptyValue,
flattenObjectLiteral,
getFileContent
} from '@/helpers/commons'
const NO_VALUE_FIELDS = [
'ReadOnlyField',
'ReadOnlyAlertItem',
'MarkdownItem',
'DisplayTextItem',
'ButtonItem'
]
/**
* Tries to find a translation corresponding to the user's locale/fallback locale in a
* Yunohost argument or simply return the string if it's not an object literal.
@ -49,6 +62,49 @@ export function adressToFormValue (address) {
}
/**
* Evaluate config panel string expression that can contain regular expressions.
* Expression are evaluated with the config panel form as context.
*
* @param {String} expression - A String to evaluate.
* @param {Object} forms - A nested form used in config panels.
* @return {Boolean} - expression evaluation result.
*/
export function evaluateExpression (expression, forms) {
if (!expression) return true
if (expression === '"false"') return false
const context = Object.values(forms).reduce((ctx, args) => {
Object.entries(args).forEach(([name, value]) => {
ctx[name] = isObjectLiteral(value) && 'file' in value ? value.content : value
})
return ctx
}, {})
// Allow to use match(var,regexp) function
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
for (const matched of expression.matchAll(matchRe)) {
const [fullMatch, varMatch, regExpMatch] = matched
const varName = varMatch + '__re' + matched.index
context[varName] = new RegExp(regExpMatch, 'm').test(context[varMatch])
expression = expression.replace(fullMatch, varName)
}
try {
return !!evaluate(context, expression)
} catch {
return false
}
}
// Adds a property to an Object that will dynamically returns a expression evaluation result.
function addEvaluationGetter (prop, obj, expr, ctx) {
Object.defineProperty(obj, prop, {
get: () => evaluateExpression(expr, ctx)
})
}
/**
* Format app install, actions and config panel argument into a data structure that
* will be automaticly transformed into a component on screen.
@ -62,22 +118,21 @@ export function formatYunoHostArgument (arg) {
const error = { message: null }
arg.ask = formatI18nField(arg.ask)
const field = {
component: undefined,
label: arg.ask,
props: {}
is: arg.readonly ? 'ReadOnlyField' : 'FormField',
visible: [undefined, true, '"true"'].includes(arg.visible),
props: {
label: arg.ask,
component: undefined,
props: {}
}
}
const defaultProps = ['id:name', 'placeholder:example']
const components = [
{
types: [undefined, 'string', 'path'],
types: ['string', 'path'],
name: 'InputItem',
props: defaultProps.concat(['autocomplete', 'trim', 'choices']),
callback: function () {
if (arg.choices && Object.keys(arg.choices).length) {
arg.type = 'select'
this.name = 'SelectItem'
}
}
props: defaultProps.concat(['autocomplete', 'trim', 'choices'])
},
{
types: ['email', 'url', 'date', 'time', 'color'],
@ -90,7 +145,7 @@ export function formatYunoHostArgument (arg) {
props: defaultProps.concat(['type', 'autocomplete', 'trim']),
callback: function () {
if (!arg.help) {
arg.help = 'good_practices_about_admin_password'
arg.help = i18n.t('good_practices_about_admin_password')
}
arg.example = '••••••••••••'
validation.passwordLenght = validators.minLength(8)
@ -111,13 +166,13 @@ export function formatYunoHostArgument (arg) {
}
},
{
types: ['select', 'user', 'domain', 'app'],
types: ['select', 'user', 'domain', 'app', 'group'],
name: 'SelectItem',
props: ['id:name', 'choices'],
callback: function () {
if ((arg.type !== 'select')) {
field.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
}
if (arg.type !== 'select') {
field.props.link = { name: arg.type + '-list', text: i18n.t(`manage_${arg.type}s`) }
}
}
},
{
@ -125,9 +180,12 @@ export function formatYunoHostArgument (arg) {
name: 'FileItem',
props: defaultProps.concat(['accept']),
callback: function () {
if (value) {
value = new File([''], value)
value.currentfile = true
value = {
// in case of already defined file, we receive only the file path (not the actual file)
file: value ? new File([''], value) : null,
content: '',
current: !!value,
removed: false
}
}
},
@ -141,11 +199,13 @@ export function formatYunoHostArgument (arg) {
name: 'TagsItem',
props: defaultProps.concat(['limit', 'placeholder', 'options:choices', 'tagIcon:icon']),
callback: function () {
if (arg.choices) {
this.name = 'TagsSelectizeItem'
field.props.auto = true
field.props.itemsName = ''
field.props.label = arg.placeholder
if (arg.choices && arg.choices.length) {
this.name = 'TagsSelectizeItem'
Object.assign(field.props.props, {
auto: true,
itemsName: '',
label: arg.placeholder
})
}
if (typeof value === 'string') {
value = value.split(',')
@ -170,53 +230,67 @@ export function formatYunoHostArgument (arg) {
types: ['alert'],
name: 'ReadOnlyAlertItem',
props: ['type:style', 'label:ask', 'icon'],
readonly: true
renderSelf: true
},
{
types: ['markdown', 'display_text'],
types: ['markdown'],
name: 'MarkdownItem',
props: ['label:ask'],
readonly: true
renderSelf: true
},
{
types: ['display_text'],
name: 'DisplayTextItem',
props: ['label:ask'],
renderSelf: true
},
{
types: ['button'],
name: 'ButtonItem',
props: ['type:style', 'label:ask', 'icon', 'enabled'],
renderSelf: true
}
]
// Default type management if no one is filled
if (arg.type === undefined) {
arg.type = (arg.choices === undefined) ? 'string' : 'select'
arg.type = arg.choices && arg.choices.length ? 'select' : 'string'
}
// Search the component bind to the type
const component = components.find(element => element.types.includes(arg.type))
if (component === undefined) throw new TypeError('Unknown type: ' + arg.type)
// Callback use for specific behaviour
if (component.callback) component.callback()
field.component = component.name
field.props.component = component.name
// Affect properties to the field Item
for (let prop of component.props) {
prop = prop.split(':')
const propName = prop[0]
const argName = prop.slice(-1)[0]
if (argName in arg) {
field.props[propName] = arg[argName]
field.props.props[propName] = arg[argName]
}
}
// We don't want to display a label html item as this kind or field contains
// already the text to display
if (component.readonly) delete field.label
// Required (no need for checkbox its value can't be null)
else if (field.component !== 'CheckboxItem' && arg.optional !== true) {
if (!component.renderSelf && arg.type !== 'boolean' && arg.optional !== true) {
validation.required = validators.required
}
if (arg.pattern && arg.type !== 'tags') {
validation.pattern = validators.helpers.regex(formatI18nField(arg.pattern.error), new RegExp(arg.pattern.regexp))
}
validation.remote = validators.helpers.withParams(error, (v) => {
const result = !error.message
error.message = null
return result
})
if (!component.renderSelf && !arg.readonly) {
// Bind a validation with what the server may respond
validation.remote = validators.helpers.withParams(error, (v) => {
const result = !error.message
error.message = null
return result
})
}
// field.props['title'] = field.pattern.error
// Default value if still `null`
if (value === null && arg.current_value) {
value = arg.current_value
@ -227,18 +301,17 @@ export function formatYunoHostArgument (arg) {
// Help message
if (arg.help) {
field.description = formatI18nField(arg.help)
field.props.description = formatI18nField(arg.help)
}
// Help message
if (arg.helpLink) {
field.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
field.props.link = { href: arg.helpLink.href, text: i18n.t(arg.helpLink.text) }
}
if (arg.visible) {
field.visible = arg.visible
// Temporary value to wait visible expression to be evaluated
field.isVisible = true
if (component.renderSelf) {
field.is = field.props.component
field.props = field.props.props
}
return {
@ -256,30 +329,29 @@ export function formatYunoHostArgument (arg) {
* as v-model values, fields that can be passed to a FormField component and validations.
*
* @param {Array} args - a yunohost arg array written by a packager.
* @param {String} name - (temp) an app name to build a label field in case of manifest install args
* @param {Object|null} forms - nested form used as the expression evualuations context.
* @return {Object} an object containing all parsed values to be used in vue views.
*/
export function formatYunoHostArguments (args, name = null) {
export function formatYunoHostArguments (args, forms) {
const form = {}
const fields = {}
const validations = {}
const errors = {}
// FIXME yunohost should add the label field by default
if (name) {
args.unshift({
ask: i18n.t('label_for_manifestname', { name }),
default: name,
name: 'label'
})
}
for (const arg of args) {
const { value, field, validation, error } = formatYunoHostArgument(arg)
fields[arg.name] = field
form[arg.name] = value
if (validation) validations[arg.name] = validation
errors[arg.name] = error
if ('visible' in arg && ![false, '"false"'].includes(arg.visible)) {
addEvaluationGetter('visible', field, arg.visible, forms)
}
if ('enabled' in arg) {
addEvaluationGetter('enabled', field.props, arg.enabled, forms)
}
}
return { form, fields, validations, errors }
@ -295,7 +367,7 @@ export function formatYunoHostConfigPanels (data) {
}
for (const { id: panelId, name, help, sections } of data.panels) {
const panel = { id: panelId, sections: [], serverError: '' }
const panel = { id: panelId, sections: [], serverError: '', hasApplyButton: false }
result.forms[panelId] = {}
result.validations[panelId] = {}
result.errors[panelId] = {}
@ -303,17 +375,34 @@ export function formatYunoHostConfigPanels (data) {
if (name) panel.name = formatI18nField(name)
if (help) panel.help = formatI18nField(help)
for (const { id: sectionId, name, help, visible, options } of sections) {
const section = { id: sectionId, visible, isVisible: false }
if (help) section.help = formatI18nField(help)
if (name) section.name = formatI18nField(name)
const { form, fields, validations, errors } = formatYunoHostArguments(options)
for (const _section of sections) {
const section = {
id: _section.id,
isActionSection: _section.is_action_section,
visible: [undefined, true, '"true"'].includes(_section.visible)
}
if (_section.help) section.help = formatI18nField(_section.help)
if (_section.name) section.name = formatI18nField(_section.name)
if (_section.visible && ![false, '"false"'].includes(_section.visible)) {
addEvaluationGetter('visible', section, _section.visible, result.forms)
}
const {
form,
fields,
validations,
errors
} = formatYunoHostArguments(_section.options, result.forms)
// Merge all sections forms to the panel to get a unique form
Object.assign(result.forms[panelId], form)
Object.assign(result.validations[panelId], validations)
Object.assign(result.errors[panelId], errors)
section.fields = fields
panel.sections.push(section)
if (!section.isActionSection && Object.values(fields).some((field) => !NO_VALUE_FIELDS.includes(field.is))) {
panel.hasApplyButton = true
}
}
result.panels.push(panel)
@ -323,80 +412,65 @@ export function formatYunoHostConfigPanels (data) {
}
export function configPanelsFieldIsVisible (expression, field, forms) {
if (!expression || !field) return true
const context = {}
const promises = []
for (const args of Object.values(forms)) {
for (const shortname in args) {
if (args[shortname] instanceof File) {
if (expression.includes(shortname)) {
promises.push(pFileReader(args[shortname], context, shortname, false))
}
} else {
context[shortname] = args[shortname]
}
}
}
// Allow to use match(var,regexp) function
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
let i = 0
Promise.all(promises).then(() => {
for (const matched of expression.matchAll(matchRe)) {
i++
const varName = matched[1] + '__re' + i.toString()
context[varName] = new RegExp(matched[2], 'm').test(context[matched[1]])
expression = expression.replace(matched[0], varName)
}
try {
field.isVisible = evaluate(context, expression)
} catch {
field.isVisible = false
}
})
return field.isVisible
}
export function pFileReader (file, output, key, base64 = true) {
return new Promise((resolve, reject) => {
const fr = new FileReader()
fr.onerror = reject
fr.onload = () => {
output[key] = fr.result
if (base64) {
output[key] = fr.result.replace(/data:[^;]*;base64,/, '')
}
output[key + '[name]'] = file.name
resolve()
}
if (base64) {
fr.readAsDataURL(file)
} else {
fr.readAsText(file)
}
})
}
/**
* Format helper for a form value.
* Convert Boolean to (1|0) and concatenate adresses.
* Parse a front-end value to its API equivalent. This function returns a Promise or an
* Object `{ key: Promise }` if `key` is supplied. When parsing a form, all those
* objects must be merged to define the final sent form.
*
* Convert Boolean to '1' (true) or '0' (false),
* Concatenate two parts adresses (subdomain or email for example) into a single string,
* Convert File to its Base64 representation or set its value to '' to ask for a removal.
*
* @param {*} value
* @return {*}
*/
export function formatFormDataValue (value) {
if (typeof value === 'boolean') {
return value ? 1 : 0
} else if (isObjectLiteral(value) && 'separator' in value) {
return Object.values(value).join('')
export function formatFormDataValue (value, key = null) {
if (Array.isArray(value)) {
return Promise.all(
value.map(value_ => formatFormDataValue(value_))
).then(resolvedValues => ({ [key]: resolvedValues }))
}
return value
let result = value
if (typeof value === 'boolean') result = value ? 1 : 0
if (isObjectLiteral(value) && 'file' in value) {
// File has to be deleted
if (value.removed) result = ''
// File has not changed (will not be sent)
else if (value.current || value.file === null) result = null
else {
return getFileContent(value.file, { base64: true }).then(content => {
return {
[key]: content.replace(/data:[^;]*;base64,/, ''),
[key + '[name]']: value.file.name
}
})
}
} else if (isObjectLiteral(value) && 'separator' in value) {
result = Object.values(value).join('')
}
// Returns a resolved Promise for non async values
return Promise.resolve(key ? { [key]: result } : result)
}
/**
* Convinient helper to properly parse a front-end form to its API equivalent.
* This parse each values asynchronously, allow to inject keys into the final form and
* make sure every async values resolves before resolving itself.
*
* @param {Object} formData
* @return {Object}
*/
function formatFormDataValues (formData) {
const promisedValues = Object.entries(formData).map(([key, value]) => {
return formatFormDataValue(value, key)
})
return Promise.all(promisedValues).then(resolvedValues => {
return resolvedValues.reduce((form, obj) => ({ ...form, ...obj }), {})
})
}
@ -412,38 +486,28 @@ export function formatFormDataValue (value) {
*/
export async function formatFormData (
formData,
{ extract = null, flatten = false, removeEmpty = true, removeNull = false, multipart = true } = {}
{ extract = null, flatten = false, removeEmpty = true, removeNull = false } = {}
) {
const output = {
data: {},
extracted: {}
}
const promises = []
for (const key in formData) {
const type = extract && extract.includes(key) ? 'extracted' : 'data'
const value = Array.isArray(formData[key])
? formData[key].map(item => formatFormDataValue(item))
: formatFormDataValue(formData[key])
const values = await formatFormDataValues(formData)
for (const key in values) {
const type = extract && extract.includes(key) ? 'extracted' : 'data'
const value = values[key]
if (removeEmpty && isEmptyValue(value)) {
continue
} else if (removeNull && (value === null || value === undefined)) {
} else if (removeNull && [null, undefined].includes(value)) {
continue
} else if (value instanceof File && !multipart) {
if (value.currentfile) {
continue
} else if (value._removed) {
output[type][key] = ''
continue
}
promises.push(pFileReader(value, output[type], key))
} else if (flatten && isObjectLiteral(value)) {
flattenObjectLiteral(value, output[type])
} else {
output[type][key] = value
}
}
if (promises.length) await Promise.all(promises)
const { data, extracted } = output
return extract ? { data, ...extracted } : data
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,18 +3,10 @@
<template v-if="infos">
<!-- BASIC INFOS -->
<card :title="name" icon="download">
<b-row
<description-row
v-for="(info, key) in infos" :key="key"
no-gutters class="row-line"
>
<b-col cols="5" md="3" xl="3">
<strong>{{ $t(key) }}</strong>
<span class="sep" />
</b-col>
<b-col>
<span>{{ info }}</span>
</b-col>
</b-row>
:term="$t(key)" :details="info"
/>
</card>
<!-- INSTALL FORM -->
@ -24,10 +16,9 @@
@submit.prevent="performInstall"
>
<template v-for="(field, fname) in fields">
<form-field
v-if="isVisible(field.visible, field)"
:key="fname" label-cols="0"
v-bind="field" v-model="form[fname]" :validation="$v.form[fname]"
<component
v-if="field.visible" :is="field.is" v-bind="field.props"
v-model="form[fname]" :validation="$v.form[fname]" :key="fname"
/>
</template>
</card-form>
@ -47,10 +38,13 @@
<script>
import { validationMixin } from 'vuelidate'
import evaluate from 'simple-evaluate'
import api, { objectToParams } from '@/api'
import { formatYunoHostArguments, formatI18nField, formatFormData, pFileReader } from '@/helpers/yunohostArguments'
import {
formatYunoHostArguments,
formatI18nField,
formatFormData
} from '@/helpers/yunohostArguments'
export default {
name: 'AppInstall',
@ -68,7 +62,6 @@ export default {
],
name: undefined,
infos: undefined,
formDisclaimer: null,
form: undefined,
fields: undefined,
validations: null,
@ -85,17 +78,28 @@ export default {
onQueriesResponse (manifest) {
this.name = manifest.name
const infosKeys = ['id', 'description', 'license', 'version', 'multi_instance']
manifest.license = manifest.upstream.license
if (manifest.license === undefined || manifest.license === 'free') {
infosKeys.splice(2, 1)
}
manifest.description = formatI18nField(manifest.description)
manifest.multi_instance = this.$i18n.t(manifest.multi_instance ? 'yes' : 'no')
manifest.multi_instance = this.$i18n.t(manifest.integration.multi_instance ? 'yes' : 'no')
this.infos = Object.fromEntries(infosKeys.map(key => [key, manifest[key]]))
const { form, fields, validations, errors } = formatYunoHostArguments(
manifest.arguments.install,
manifest.name
)
// FIXME yunohost should add the label field by default
manifest.install.unshift({
ask: this.$t('label_for_manifestname', { name: manifest.name }),
default: manifest.name,
name: 'label',
help: this.$t('label_for_manifestname_help')
})
const {
form,
fields,
validations,
errors
} = formatYunoHostArguments(manifest.install)
this.fields = fields
this.form = form
@ -103,41 +107,6 @@ export default {
this.errors = errors
},
isVisible (expression, field) {
if (!expression || !field) return true
const context = {}
const promises = []
for (const shortname in this.form) {
if (this.form[shortname] instanceof File) {
if (expression.includes(shortname)) {
promises.push(pFileReader(this.form[shortname], context, shortname, false))
}
} else {
context[shortname] = this.form[shortname]
}
}
// Allow to use match(var,regexp) function
const matchRe = new RegExp('match\\(\\s*(\\w+)\\s*,\\s*"([^"]+)"\\s*\\)', 'g')
let i = 0
Promise.all(promises).then(() => {
for (const matched of expression.matchAll(matchRe)) {
i++
const varName = matched[1] + '__re' + i.toString()
context[varName] = new RegExp(matched[2], 'm').test(context[matched[1]])
expression = expression.replace(matched[0], varName)
}
try {
field.isVisible = evaluate(context, expression)
} catch (error) {
field.isVisible = false
}
})
// This value should be updated magically when vuejs will detect isVisible changed
return field.isVisible
},
async performInstall () {
if ('path' in this.form && this.form.path === '/') {
const confirmed = await this.$askConfirmation(
@ -148,7 +117,7 @@ export default {
const { data: args, label } = await formatFormData(
this.form,
{ extract: ['label'], removeEmpty: false, removeNull: true, multipart: false }
{ extract: ['label'], removeEmpty: false, removeNull: true }
)
const data = { app: this.id, label, args: Object.entries(args).length ? objectToParams(args) : undefined }

View file

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

View file

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

View file

@ -1,160 +0,0 @@
<template>
<view-base :queries="queries" @queries-response="onQueriesResponse" ref="view">
<card v-if="cert" :title="$t('certificate_status')" icon="lock">
<p :class="'alert alert-' + cert.alert.type">
<icon :iname="cert.alert.icon" /> {{ $t('certificate_alert_' + cert.alert.trad) }}
</p>
<b-row no-gutters class="row-line">
<b-col md="4" xl="2">
<strong v-t="'certificate_authority'" />
</b-col>
<b-col>{{ cert.type }} ({{ name }})</b-col>
</b-row>
<b-row no-gutters class="row-line">
<b-col md="4" xl="2">
<strong v-t="'validity'" />
</b-col>
<b-col>{{ $tc('day_validity', cert.validity) }}</b-col>
</b-row>
</card>
<card v-if="cert" :title="$t('operations')" icon="wrench">
<!-- CERT INSTALL LETSENCRYPT -->
<template v-if="actionsEnabled.installLetsencrypt">
<p>
<icon :iname="cert.acmeEligible ? 'check' : 'meh-o'" /> <span v-html="$t(`domain_${cert.acmeEligible ? 'is' : 'not'}_eligible_for_ACME`)" />
</p>
<b-button @click="callAction('install_LE')" variant="success" :disabled="!cert.acmeEligible">
<icon iname="star" /> {{ $t('install_letsencrypt_cert') }}
</b-button>
<hr>
</template>
<!-- CERT RENEW LETS-ENCRYPT -->
<template v-if="actionsEnabled.manualRenewLetsencrypt">
<p v-t="'manually_renew_letsencrypt_message'" />
<b-button @click="callAction('manual_renew_LE')" variant="warning">
<icon iname="refresh" /> {{ $t('manually_renew_letsencrypt') }}
</b-button>
<hr>
</template>
<!-- CERT REGEN SELF-SIGNED -->
<template v-if="actionsEnabled.regenSelfsigned">
<p v-t="'regenerate_selfsigned_cert_message'" />
<b-button @click="callAction('regen_selfsigned')" variant="warning">
<icon iname="refresh" /> {{ $t('regenerate_selfsigned_cert') }}
</b-button>
<hr>
</template>
<!-- CERT REPLACE WITH SELF-SIGNED -->
<template v-if="actionsEnabled.replaceWithSelfsigned">
<p v-t="'revert_to_selfsigned_cert_message'" />
<b-button @click="callAction('revert_to_selfsigned')" variant="danger">
<icon iname="exclamation-triangle" /> {{ $t('revert_to_selfsigned_cert') }}
</b-button>
<hr>
</template>
</card>
<template #skeleton>
<card-info-skeleton :item-count="2" />
<card-buttons-skeleton :item-count="2" />
</template>
</view-base>
</template>
<script>
import api from '@/api'
export default {
name: 'DomainCert',
props: {
name: { type: String, required: true }
},
data () {
return {
queries: [
['GET', `domains/${this.name}/cert?full`]
],
cert: undefined,
actionsEnabled: undefined
}
},
methods: {
formatCertAlert (code, type) {
switch (code) {
case 'critical': return { type: 'danger', trad: 'not_valid', icon: 'exclamation-circle' }
case 'warning': return { type: 'warning', trad: 'selfsigned', icon: 'exclamation-triangle' }
case 'attention':
if (type === 'lets-encrypt') {
return { type: 'warning', trad: 'letsencrypt_about_to_expire', icon: 'clock-o' }
} else {
return { type: 'danger', trad: 'about_to_expire', icon: 'clock-o' }
}
case 'good': return { type: 'success', trad: 'good', icon: 'check-circle' }
case 'great': return { type: 'success', trad: 'great', icon: 'thumbs-up' }
default: return { type: 'warning', trad: 'unknown', icon: 'question' }
}
},
onQueriesResponse (data) {
const certData = data.certificates[this.name]
const cert = {
type: certData.CA_type.verbose,
name: certData.CA_name,
validity: certData.validity,
acmeEligible: certData.ACME_eligible,
alert: this.formatCertAlert(certData.summary.code, certData.CA_type.verbose)
}
const actionsEnabled = {
installLetsencrypt: false,
manualRenewLetsencrypt: false,
regenSelfsigned: false,
replaceWithSelfsigned: false
}
switch (certData.CA_type.code) {
case 'self-signed':
actionsEnabled.installLetsencrypt = true
actionsEnabled.regenSelfsigned = true
break
case 'lets-encrypt':
actionsEnabled.manualRenewLetsencrypt = true
actionsEnabled.replaceWithSelfsigned = true
break
default:
actionsEnabled.replaceWithSelfsigned = true
}
this.cert = cert
this.actionsEnabled = actionsEnabled
},
async callAction (action) {
const confirmed = await this.$askConfirmation(this.$i18n.t(`confirm_cert_${action}`))
if (!confirmed) return
let uri = `domains/${this.name}/cert`
if (action === 'regen_selfsigned') uri += '?self_signed'
else if (action === 'manual_renew_LE') uri += '?force'
else if (action === 'revert_to_selfsigned') uri += '?self_signed&force'
api.put(
uri, {}, { key: 'domains.' + action, name: this.name }
).then(this.$refs.view.fetchQueries)
}
}
}
</script>

View file

@ -3,10 +3,16 @@
:queries="queries" @queries-response="onQueriesResponse" :loading="loading"
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>

View file

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

View file

@ -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
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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