mirror of
https://github.com/YunoHost/yunohost-admin.git
synced 2024-09-03 20:06:15 +02:00
refactor: quickly turn components to script setup + ts
This commit is contained in:
parent
0f673709af
commit
7123ba6cdc
87 changed files with 5278 additions and 5615 deletions
182
app/src/App.vue
182
app/src/App.vue
|
@ -1,3 +1,87 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
import { HistoryConsole, ViewLockOverlay } from '@/views/_partials'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
const {
|
||||||
|
connected,
|
||||||
|
yunohost,
|
||||||
|
routerKey,
|
||||||
|
transitions,
|
||||||
|
transitionName,
|
||||||
|
waiting,
|
||||||
|
dark,
|
||||||
|
ssoLink,
|
||||||
|
} = useStoreGetters()
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
store.dispatch('LOGOUT')
|
||||||
|
}
|
||||||
|
|
||||||
|
store.dispatch('ON_APP_CREATED')
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp']
|
||||||
|
let copypastastep = 0
|
||||||
|
document.addEventListener('keydown', ({ key }) => {
|
||||||
|
if (key === copypastaCode[copypastastep++]) {
|
||||||
|
if (copypastastep === copypastaCode.length) {
|
||||||
|
document
|
||||||
|
.querySelectorAll('.unselectable')
|
||||||
|
.forEach((element) => element.classList.remove('unselectable'))
|
||||||
|
copypastastep = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
copypastastep = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Konamicode ;P
|
||||||
|
const konamiCode = [
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowUp',
|
||||||
|
'ArrowDown',
|
||||||
|
'ArrowDown',
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'ArrowLeft',
|
||||||
|
'ArrowRight',
|
||||||
|
'b',
|
||||||
|
'a',
|
||||||
|
]
|
||||||
|
let konamistep = 0
|
||||||
|
document.addEventListener('keydown', ({ key }) => {
|
||||||
|
if (key === konamiCode[konamistep++]) {
|
||||||
|
if (konamistep === konamiCode.length) {
|
||||||
|
store.commit('SET_SPINNER', 'nyancat')
|
||||||
|
konamistep = 0
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
konamistep = 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// April fools easter egg ;)
|
||||||
|
const today = new Date()
|
||||||
|
if (today.getDate() === 1 && today.getMonth() + 1 === 4) {
|
||||||
|
store.commit('SET_SPINNER', 'magikarp')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Halloween easter egg ;)
|
||||||
|
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
|
||||||
|
store.commit('SET_SPINNER', 'spookycat')
|
||||||
|
}
|
||||||
|
// updates the data-bs-theme attribute
|
||||||
|
document.documentElement.setAttribute(
|
||||||
|
'data-bs-theme',
|
||||||
|
dark.value ? 'dark' : 'light',
|
||||||
|
)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div id="app" class="container">
|
<div id="app" class="container">
|
||||||
<!-- HEADER -->
|
<!-- HEADER -->
|
||||||
|
@ -97,104 +181,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import { HistoryConsole, ViewLockOverlay } from '@/views/_partials'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'App',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
HistoryConsole,
|
|
||||||
ViewLockOverlay,
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters([
|
|
||||||
'connected',
|
|
||||||
'yunohost',
|
|
||||||
'routerKey',
|
|
||||||
'transitions',
|
|
||||||
'transitionName',
|
|
||||||
'waiting',
|
|
||||||
'dark',
|
|
||||||
'ssoLink',
|
|
||||||
]),
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async logout() {
|
|
||||||
this.$store.dispatch('LOGOUT')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
// This hook is only triggered at page first load
|
|
||||||
created() {
|
|
||||||
this.$store.dispatch('ON_APP_CREATED')
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
// Unlock copypasta on log view
|
|
||||||
const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp']
|
|
||||||
let copypastastep = 0
|
|
||||||
document.addEventListener('keydown', ({ key }) => {
|
|
||||||
if (key === copypastaCode[copypastastep++]) {
|
|
||||||
if (copypastastep === copypastaCode.length) {
|
|
||||||
document
|
|
||||||
.querySelectorAll('.unselectable')
|
|
||||||
.forEach((element) => element.classList.remove('unselectable'))
|
|
||||||
copypastastep = 0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
copypastastep = 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// Konamicode ;P
|
|
||||||
const konamiCode = [
|
|
||||||
'ArrowUp',
|
|
||||||
'ArrowUp',
|
|
||||||
'ArrowDown',
|
|
||||||
'ArrowDown',
|
|
||||||
'ArrowLeft',
|
|
||||||
'ArrowRight',
|
|
||||||
'ArrowLeft',
|
|
||||||
'ArrowRight',
|
|
||||||
'b',
|
|
||||||
'a',
|
|
||||||
]
|
|
||||||
let konamistep = 0
|
|
||||||
document.addEventListener('keydown', ({ key }) => {
|
|
||||||
if (key === konamiCode[konamistep++]) {
|
|
||||||
if (konamistep === konamiCode.length) {
|
|
||||||
this.$store.commit('SET_SPINNER', 'nyancat')
|
|
||||||
konamistep = 0
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
konamistep = 0
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
// April fools easter egg ;)
|
|
||||||
const today = new Date()
|
|
||||||
if (today.getDate() === 1 && today.getMonth() + 1 === 4) {
|
|
||||||
this.$store.commit('SET_SPINNER', 'magikarp')
|
|
||||||
}
|
|
||||||
|
|
||||||
// Halloween easter egg ;)
|
|
||||||
if (today.getDate() === 31 && today.getMonth() + 1 === 10) {
|
|
||||||
this.$store.commit('SET_SPINNER', 'spookycat')
|
|
||||||
}
|
|
||||||
// updates the data-bs-theme attribute
|
|
||||||
document.documentElement.setAttribute(
|
|
||||||
'data-bs-theme',
|
|
||||||
this.dark ? 'dark' : 'light',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
// generic style for <html>, <body> and <#app> is in `scss/main.scss`
|
// generic style for <html>, <body> and <#app> is in `scss/main.scss`
|
||||||
header {
|
header {
|
||||||
|
|
|
@ -1,3 +1,39 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
type CustomEmail = {
|
||||||
|
localPart: string | null
|
||||||
|
separator: string
|
||||||
|
domain: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: CustomEmail
|
||||||
|
choices: string[]
|
||||||
|
placeholder?: string
|
||||||
|
id?: string
|
||||||
|
state?: false | null
|
||||||
|
type?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
placeholder: undefined,
|
||||||
|
id: undefined,
|
||||||
|
state: undefined,
|
||||||
|
type: 'email',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: CustomEmail]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function onInput(key: 'localPart' | 'domain', modelValue: string | null) {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
[key]: modelValue,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BInputGroup v-bind="$attrs">
|
<BInputGroup v-bind="$attrs">
|
||||||
<InputItem
|
<InputItem
|
||||||
|
@ -30,29 +66,3 @@
|
||||||
/>
|
/>
|
||||||
</BInputGroup>
|
</BInputGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'AdressInputSelect',
|
|
||||||
|
|
||||||
inheritAttrs: false,
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: { type: Object, required: true },
|
|
||||||
choices: { type: Array, required: true },
|
|
||||||
placeholder: { type: String, default: null },
|
|
||||||
id: { type: String, default: null },
|
|
||||||
state: { type: null, default: null },
|
|
||||||
type: { type: String, default: 'email' },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onInput(key, modelValue) {
|
|
||||||
this.$emit('update:modelValue', {
|
|
||||||
...this.modelValue,
|
|
||||||
[key]: modelValue,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,5 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ColorVariant } from 'bootstrap-vue-next'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
variant?: ColorVariant
|
||||||
|
visible?: boolean
|
||||||
|
flush?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: 'light',
|
||||||
|
visible: false,
|
||||||
|
flush: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const class_ = computed(() => {
|
||||||
|
const baseClass = 'card-collapse'
|
||||||
|
return [
|
||||||
|
baseClass,
|
||||||
|
{
|
||||||
|
[`${baseClass}-flush`]: props.flush,
|
||||||
|
[`${baseClass}-${props.variant}`]: props.variant,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard v-bind="$attrs" no-body :class="_class">
|
<BCard no-body :class="class_">
|
||||||
<template #header>
|
<template #header>
|
||||||
<slot name="header">
|
<slot name="header">
|
||||||
<h2>
|
<h2>
|
||||||
|
@ -21,33 +52,6 @@
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'CardCollapse',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, required: true },
|
|
||||||
title: { type: String, required: true },
|
|
||||||
variant: { type: String, default: 'white' },
|
|
||||||
visible: { type: Boolean, default: false },
|
|
||||||
flush: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
_class() {
|
|
||||||
const baseClass = 'card-collapse'
|
|
||||||
return [
|
|
||||||
baseClass,
|
|
||||||
{
|
|
||||||
[`${baseClass}-flush`]: this.flush,
|
|
||||||
[`${baseClass}-${this.variant}`]: this.variant,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card-collapse {
|
.card-collapse {
|
||||||
.card-header {
|
.card-header {
|
||||||
|
|
|
@ -1,91 +1,97 @@
|
||||||
<script>
|
<script setup lang="ts">
|
||||||
|
import { BCardGroup } from 'bootstrap-vue-next'
|
||||||
|
import {
|
||||||
|
getCurrentInstance,
|
||||||
|
h,
|
||||||
|
nextTick,
|
||||||
|
onBeforeUnmount,
|
||||||
|
onBeforeUpdate,
|
||||||
|
onMounted,
|
||||||
|
ref,
|
||||||
|
} from 'vue'
|
||||||
|
|
||||||
// Implementation of the feed pattern
|
// Implementation of the feed pattern
|
||||||
// https://www.w3.org/WAI/ARIA/apg/patterns/feed/
|
// https://www.w3.org/WAI/ARIA/apg/patterns/feed/
|
||||||
import { h } from 'vue'
|
|
||||||
import { BCardGroup } from 'bootstrap-vue-next'
|
|
||||||
|
|
||||||
export default {
|
const props = withDefaults(defineProps<{ stacks?: number }>(), { stacks: 21 })
|
||||||
name: 'CardDeckFeed',
|
const slots = defineSlots<{
|
||||||
|
default: any
|
||||||
|
}>()
|
||||||
|
|
||||||
props: {
|
const busy = ref(false)
|
||||||
stacks: { type: Number, default: 21 },
|
const range = ref(props.stacks)
|
||||||
},
|
const childrenCount = ref(slots.default()[0].children.length)
|
||||||
|
const feedElem = ref<InstanceType<typeof BCardGroup> | null>(null)
|
||||||
|
|
||||||
data() {
|
function getTopParent(prev: HTMLElement): HTMLElement {
|
||||||
return {
|
return prev.parentElement === feedElem.value?.$el
|
||||||
busy: false,
|
|
||||||
range: this.stacks,
|
|
||||||
childrenCount: this.$slots.default()[0].children,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
getTopParent(prev) {
|
|
||||||
return prev.parentElement === this.$refs.feed.$el
|
|
||||||
? prev
|
? prev
|
||||||
: this.getTopParent(prev.parentElement)
|
: getTopParent(prev.parentElement!)
|
||||||
},
|
}
|
||||||
|
|
||||||
onScroll() {
|
const i = getCurrentInstance()
|
||||||
const elem = this.$refs.feed.$el
|
function onScroll() {
|
||||||
|
const elem = feedElem.value?.$el
|
||||||
if (
|
if (
|
||||||
window.innerHeight >
|
window.innerHeight >
|
||||||
elem.clientHeight + elem.getBoundingClientRect().top - 200
|
elem.clientHeight + elem.getBoundingClientRect().top - 200
|
||||||
) {
|
) {
|
||||||
this.busy = true
|
busy.value = true
|
||||||
this.range = Math.min(this.range + this.stacks, this.childrenCount)
|
range.value = Math.min(range.value + props.stacks, childrenCount.value)
|
||||||
this.$nextTick().then(() => {
|
nextTick().then(() => {
|
||||||
this.busy = false
|
busy.value = false
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
|
|
||||||
onKeydown(e) {
|
function onKeydown(e: KeyboardEvent) {
|
||||||
if (['PageUp', 'PageDown'].includes(e.code)) {
|
if (['PageUp', 'PageDown'].includes(e.code)) {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
const key = e.code === 'PageUp' ? 'previous' : 'next'
|
const key = e.code === 'PageUp' ? 'previous' : 'next'
|
||||||
const sibling = this.getTopParent(e.target)[`${key}ElementSibling`]
|
const sibling = getTopParent(e.target as HTMLElement)[
|
||||||
if (sibling) {
|
`${key}ElementSibling`
|
||||||
sibling.focus()
|
] as HTMLElement | null
|
||||||
sibling.scrollIntoView({ block: 'center' })
|
sibling?.focus()
|
||||||
}
|
sibling?.scrollIntoView({ block: 'center' })
|
||||||
}
|
}
|
||||||
// FIXME Add `Home` and `End` shorcuts
|
// FIXME Add `Home` and `End` shorcuts
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
|
||||||
window.addEventListener('scroll', this.onScroll)
|
|
||||||
this.$refs.feed.$el.addEventListener('keydown', this.onKeydown)
|
|
||||||
this.onScroll()
|
|
||||||
},
|
|
||||||
|
|
||||||
beforeUpdate() {
|
|
||||||
const slots = this.$slots.default()[0].children
|
|
||||||
if (this.childrenCount !== slots.length) {
|
|
||||||
this.range = this.stacks
|
|
||||||
this.childrenCount = slots.length
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
render() {
|
onMounted(() => {
|
||||||
return h(
|
window.addEventListener('scroll', onScroll)
|
||||||
|
feedElem.value?.$el.addEventListener('keydown', onKeydown)
|
||||||
|
onScroll()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUpdate(() => {
|
||||||
|
const children = slots.default()[0].children
|
||||||
|
if (childrenCount.value !== children.length) {
|
||||||
|
range.value = props.stacks
|
||||||
|
childrenCount.value = children.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('scroll', onScroll)
|
||||||
|
feedElem.value?.$el.removeEventListener('keydown', onKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
const root = () =>
|
||||||
|
h(
|
||||||
BCardGroup,
|
BCardGroup,
|
||||||
{
|
{
|
||||||
deck: true,
|
deck: true,
|
||||||
role: 'feed',
|
role: 'feed',
|
||||||
'aria-busy': this.busy.toString(),
|
'aria-busy': busy.value,
|
||||||
ref: 'feed',
|
ref: feedElem,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
default: () => this.$slots.default()[0].children.slice(0, this.range),
|
default: () => slots.default()[0].children.slice(0, range.value),
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
},
|
|
||||||
|
|
||||||
beforeUnmount() {
|
|
||||||
window.removeEventListener('scroll', this.onScroll)
|
|
||||||
this.$refs.feed.$el.removeEventListener('keydown', this.onKeydown)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
{{ busy }}
|
||||||
|
<root />
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,3 +1,64 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BaseValidation } from '@vuelidate/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { filterObject } from '@/helpers/commons'
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
tabId: string
|
||||||
|
panels: Obj[]
|
||||||
|
forms: Obj<Obj>
|
||||||
|
v: BaseValidation
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const slots = defineSlots<{
|
||||||
|
'tab-top': any
|
||||||
|
'tab-before': any
|
||||||
|
'tab-after': any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
apply: [
|
||||||
|
value:
|
||||||
|
| { id: string; form: Obj }
|
||||||
|
| { id: string; form: Obj; action: string; name: string },
|
||||||
|
]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const panel = computed(() => {
|
||||||
|
// FIXME throw error if no panel?
|
||||||
|
return props.panels.find((panel) => panel.id === props.tabId)
|
||||||
|
})
|
||||||
|
|
||||||
|
const validation = computed(() => {
|
||||||
|
return props.v.forms[panel.value?.id]
|
||||||
|
})
|
||||||
|
|
||||||
|
function onApply() {
|
||||||
|
const panelId = panel.value?.id
|
||||||
|
|
||||||
|
emit('apply', {
|
||||||
|
id: panelId,
|
||||||
|
form: props.forms[panelId],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAction(sectionId: string, actionId: string, actionFields) {
|
||||||
|
const panelId = panel.value?.id
|
||||||
|
const actionFieldsKeys = Object.keys(actionFields)
|
||||||
|
|
||||||
|
emit('apply', {
|
||||||
|
id: panelId,
|
||||||
|
form: filterObject(props.forms[panelId], ([key]) =>
|
||||||
|
actionFieldsKeys.includes(key),
|
||||||
|
),
|
||||||
|
action: [panelId, sectionId, actionId].join('.'),
|
||||||
|
name: actionId,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<AbstractForm
|
<AbstractForm
|
||||||
v-if="panel"
|
v-if="panel"
|
||||||
|
@ -12,7 +73,7 @@
|
||||||
<slot name="tab-top" />
|
<slot name="tab-top" />
|
||||||
|
|
||||||
<template v-if="panel.help" #disclaimer>
|
<template v-if="panel.help" #disclaimer>
|
||||||
<div class="alert alert-info" v-html="help" />
|
<div class="alert alert-info" v-html="panel.help" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<slot name="tab-before" />
|
<slot name="tab-before" />
|
||||||
|
@ -50,56 +111,6 @@
|
||||||
</AbstractForm>
|
</AbstractForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { filterObject } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ConfigPanel',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
tabId: { type: String, required: true },
|
|
||||||
panels: { type: Array, default: undefined },
|
|
||||||
forms: { type: Object, default: undefined },
|
|
||||||
v: { type: Object, default: undefined },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
panel() {
|
|
||||||
return this.panels.find((panel) => panel.id === this.tabId)
|
|
||||||
},
|
|
||||||
|
|
||||||
validation() {
|
|
||||||
return this.v.forms[this.panel.id]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onApply() {
|
|
||||||
const panelId = this.panel.id
|
|
||||||
|
|
||||||
this.$emit('apply', {
|
|
||||||
id: panelId,
|
|
||||||
form: this.forms[panelId],
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
onAction(sectionId, actionId, actionFields) {
|
|
||||||
const panelId = this.panel.id
|
|
||||||
const actionFieldsKeys = Object.keys(actionFields)
|
|
||||||
|
|
||||||
this.$emit('apply', {
|
|
||||||
id: panelId,
|
|
||||||
form: filterObject(this.forms[panelId], ([key]) =>
|
|
||||||
actionFieldsKeys.includes(key),
|
|
||||||
),
|
|
||||||
action: [panelId, sectionId, actionId].join('.'),
|
|
||||||
name: actionId,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card-title {
|
.card-title {
|
||||||
margin-bottom: 1em;
|
margin-bottom: 1em;
|
||||||
|
|
|
@ -1,10 +1,72 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate, type BaseValidation } from '@vuelidate/core'
|
||||||
|
import { computed, defineAsyncComponent, toRef } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import type { CustomRoute, Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const RoutableTabs = defineAsyncComponent(
|
||||||
|
() => import('@/components/RoutableTabs.vue'),
|
||||||
|
)
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
panels: Obj[]
|
||||||
|
forms: Obj<Obj>
|
||||||
|
validations: BaseValidation
|
||||||
|
externalResults: Obj
|
||||||
|
errors?: Obj // never used
|
||||||
|
noRedirect?: boolean
|
||||||
|
routes?: CustomRoute[]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
errors: undefined,
|
||||||
|
routes: undefined,
|
||||||
|
noRedirect: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const slots = defineSlots<{
|
||||||
|
'tab-top': any
|
||||||
|
'tab-before': any
|
||||||
|
'tab-after': any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const externalResults = toRef(props, 'externalResults')
|
||||||
|
const rules = computed(() => ({ forms: props.validations }))
|
||||||
|
const v$ = useVuelidate(rules, props.forms, {
|
||||||
|
$externalResults: externalResults,
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const routes = computed(() => {
|
||||||
|
return (
|
||||||
|
props.routes ||
|
||||||
|
props.panels.map((panel) => ({
|
||||||
|
to: { params: { tabId: panel.id } },
|
||||||
|
text: panel.name,
|
||||||
|
icon: panel.icon || 'wrench',
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!props.noRedirect && !route.params.tabId) {
|
||||||
|
router.replace({ params: { tabId: props.panels[0].id } })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="config-panel">
|
<div class="config-panel">
|
||||||
<!-- FIXME vue3 - weird stuff with event binding, need to propagate by hand for now -->
|
<!-- FIXME vue3 - weird stuff with event binding, need to propagate by hand for now -->
|
||||||
<RoutableTabs
|
<RoutableTabs
|
||||||
v-if="routes_.length > 1"
|
v-if="routes.length > 1"
|
||||||
v-bind="{ panels, forms, v: v$, ...$attrs }"
|
v-bind="{ panels, forms, v: v$, ...$attrs }"
|
||||||
:routes="routes_"
|
:routes="routes"
|
||||||
>
|
>
|
||||||
<template #tab-top>
|
<template #tab-top>
|
||||||
<slot name="tab-top" />
|
<slot name="tab-top" />
|
||||||
|
@ -17,66 +79,10 @@
|
||||||
</template>
|
</template>
|
||||||
</RoutableTabs>
|
</RoutableTabs>
|
||||||
|
|
||||||
<YCard v-else :title="routes_[0].text" :icon="routes_[0].icon">
|
<YCard v-else :title="routes[0].text" :icon="routes[0].icon">
|
||||||
<slot name="tab-top" />
|
<slot name="tab-top" />
|
||||||
<slot name="tab-before" />
|
<slot name="tab-before" />
|
||||||
<slot name="tab-after" />
|
<slot name="tab-after" />
|
||||||
</YCard>
|
</YCard>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { toRef } from 'vue'
|
|
||||||
import { defineAsyncComponent } from 'vue'
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ConfigPanels',
|
|
||||||
|
|
||||||
inheritAttrs: false,
|
|
||||||
|
|
||||||
components: {
|
|
||||||
RoutableTabs: defineAsyncComponent(
|
|
||||||
() => import('@/components/RoutableTabs.vue'),
|
|
||||||
),
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
panels: { type: Array, default: undefined },
|
|
||||||
forms: { type: Object, default: undefined },
|
|
||||||
validations: { type: Object, default: undefined },
|
|
||||||
errors: { type: Object, default: undefined }, // never used
|
|
||||||
routes: { type: Array, default: null },
|
|
||||||
noRedirect: { type: Boolean, default: false },
|
|
||||||
externalResults: { type: Object, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup(props) {
|
|
||||||
const externalResults = toRef(props, 'externalResults')
|
|
||||||
return {
|
|
||||||
v$: useVuelidate({ $externalResults: externalResults }),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
routes_() {
|
|
||||||
if (this.routes) return this.routes
|
|
||||||
return this.panels.map((panel) => ({
|
|
||||||
to: { params: { tabId: panel.id } },
|
|
||||||
text: panel.name,
|
|
||||||
icon: panel.icon || 'wrench',
|
|
||||||
}))
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return { forms: this.validations }
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
if (!this.noRedirect && !this.$route.params.tabId) {
|
|
||||||
this.$router.replace({ params: { tabId: this.panels[0].id } })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,34 +1,37 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<div class="lazy-renderer" :style="`min-height: ${fixedMinHeight}px`">
|
import { onBeforeUnmount, onMounted, ref } from 'vue'
|
||||||
<slot v-if="render" />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
const props = withDefaults(
|
||||||
export default {
|
defineProps<{
|
||||||
name: 'LazyRenderer',
|
unrender?: boolean
|
||||||
|
minHeight?: number
|
||||||
props: {
|
renderDelay?: number
|
||||||
unrender: { type: Boolean, default: true },
|
unrenderDelay?: number
|
||||||
minHeight: { type: Number, default: 0 },
|
rootMargin?: string
|
||||||
renderDelay: { type: Number, default: 100 },
|
}>(),
|
||||||
unrenderDelay: { type: Number, default: 2000 },
|
{
|
||||||
rootMargin: { type: String, default: '300px' },
|
unrender: true,
|
||||||
|
minHeight: 0,
|
||||||
|
renderDelay: 100,
|
||||||
|
unrenderDelay: 2000,
|
||||||
|
rootMargin: '300px',
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
|
||||||
data() {
|
defineSlots<{
|
||||||
return {
|
default: any
|
||||||
observer: null,
|
}>()
|
||||||
render: false,
|
|
||||||
fixedMinHeight: this.minHeight,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
mounted() {
|
const observer = ref<IntersectionObserver | null>(null)
|
||||||
let unrenderTimer
|
const render = ref(false)
|
||||||
let renderTimer
|
const fixedMinHeight = ref(props.minHeight)
|
||||||
|
const rootElem = ref<HTMLDivElement | null>(null)
|
||||||
|
|
||||||
this.observer = new IntersectionObserver(
|
onMounted(() => {
|
||||||
|
let unrenderTimer: NodeJS.Timeout
|
||||||
|
let renderTimer: NodeJS.Timeout
|
||||||
|
|
||||||
|
observer.value = new IntersectionObserver(
|
||||||
(entries) => {
|
(entries) => {
|
||||||
let intersecting = entries[0].isIntersecting
|
let intersecting = entries[0].isIntersecting
|
||||||
|
|
||||||
|
@ -36,7 +39,7 @@ export default {
|
||||||
// Intersection is triggered but even if the element is indeed in the viewport,
|
// Intersection is triggered but even if the element is indeed in the viewport,
|
||||||
// isIntersecting is `false`, so we have to manually check this…
|
// isIntersecting is `false`, so we have to manually check this…
|
||||||
// FIXME Would be great to find out why this is happening
|
// FIXME Would be great to find out why this is happening
|
||||||
if (!intersecting && this.$el.offsetTop < window.innerHeight) {
|
if (!intersecting && rootElem.value!.offsetTop < window.innerHeight) {
|
||||||
intersecting = true
|
intersecting = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -45,32 +48,41 @@ export default {
|
||||||
// Show the component after a delay (to avoid rendering while scrolling fast)
|
// Show the component after a delay (to avoid rendering while scrolling fast)
|
||||||
renderTimer = setTimeout(
|
renderTimer = setTimeout(
|
||||||
() => {
|
() => {
|
||||||
this.render = true
|
render.value = true
|
||||||
},
|
},
|
||||||
this.unrender ? this.renderDelay : 0,
|
props.unrender ? props.renderDelay : 0,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (!this.unrender) {
|
if (!props.unrender) {
|
||||||
// Stop listening to intersections after first appearance if unrendering is not activated
|
// Stop listening to intersections after first appearance if unrendering is not activated
|
||||||
this.observer.disconnect()
|
this.observer.disconnect()
|
||||||
}
|
}
|
||||||
} else if (this.unrender) {
|
} else if (props.unrender) {
|
||||||
clearTimeout(renderTimer)
|
clearTimeout(renderTimer)
|
||||||
// Hide the component after a delay if it's no longer in the viewport
|
// Hide the component after a delay if it's no longer in the viewport
|
||||||
unrenderTimer = setTimeout(() => {
|
unrenderTimer = setTimeout(() => {
|
||||||
this.fixedMinHeight = this.$el.clientHeight
|
fixedMinHeight.value = rootElem.value!.clientHeight
|
||||||
this.render = false
|
render.value = false
|
||||||
}, this.unrenderDelay)
|
}, props.unrenderDelay)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ rootMargin: this.rootMargin },
|
{ rootMargin: props.rootMargin },
|
||||||
)
|
)
|
||||||
|
|
||||||
this.observer.observe(this.$el)
|
observer.value.observe(rootElem.value!)
|
||||||
},
|
})
|
||||||
|
|
||||||
beforeUnmount() {
|
onBeforeUnmount(() => {
|
||||||
this.observer.disconnect()
|
observer.value!.disconnect()
|
||||||
},
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
ref="rootElem"
|
||||||
|
class="lazy-renderer"
|
||||||
|
:style="`min-height: ${fixedMinHeight}px`"
|
||||||
|
>
|
||||||
|
<slot v-if="render" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,6 +1,58 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BListGroup, ColorVariant } from 'bootstrap-vue-next'
|
||||||
|
import { computed, nextTick, watch, ref } from 'vue'
|
||||||
|
|
||||||
|
type ActionMessage = { color: ColorVariant; text: string }
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
messages: ActionMessage[]
|
||||||
|
fixedHeight?: boolean
|
||||||
|
bordered?: boolean
|
||||||
|
autoScroll?: boolean
|
||||||
|
limit?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
fixedHeight: false,
|
||||||
|
bordered: false,
|
||||||
|
autoScroll: false,
|
||||||
|
limit: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const auto = ref(true)
|
||||||
|
const rootElem = ref<InstanceType<typeof BListGroup> | null>(null)
|
||||||
|
|
||||||
|
if (props.autoScroll) {
|
||||||
|
watch(() => props.messages, scrollToEnd, { deep: true })
|
||||||
|
}
|
||||||
|
|
||||||
|
const reducedMessages = computed(() => {
|
||||||
|
const len = props.messages.length
|
||||||
|
if (!props.limit || len <= props.limit) {
|
||||||
|
return props.messages
|
||||||
|
}
|
||||||
|
return props.messages.slice(len - props.limit)
|
||||||
|
})
|
||||||
|
|
||||||
|
function scrollToEnd() {
|
||||||
|
if (!auto.value) return
|
||||||
|
nextTick(() => {
|
||||||
|
rootElem.value!.$el.scrollTo(
|
||||||
|
0,
|
||||||
|
rootElem.value!.$el.lastElementChild.offsetTop,
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onScroll(e: Event) {
|
||||||
|
const target = e.target as HTMLElement
|
||||||
|
auto.value = target.scrollHeight === target.scrollTop + target.clientHeight
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<BListGroup
|
<BListGroup
|
||||||
v-bind="$attrs"
|
ref="rootElem"
|
||||||
flush
|
flush
|
||||||
:class="{ 'fixed-height': fixedHeight, bordered: bordered }"
|
:class="{ 'fixed-height': fixedHeight, bordered: bordered }"
|
||||||
@scroll="onScroll"
|
@scroll="onScroll"
|
||||||
|
@ -22,55 +74,6 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'MessageListGroup',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
messages: { type: Array, required: true },
|
|
||||||
fixedHeight: { type: Boolean, default: false },
|
|
||||||
bordered: { type: Boolean, default: false },
|
|
||||||
autoScroll: { type: Boolean, default: false },
|
|
||||||
limit: { type: Number, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
auto: true,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
reducedMessages() {
|
|
||||||
const len = this.messages.length
|
|
||||||
if (!this.limit || len <= this.limit) {
|
|
||||||
return this.messages
|
|
||||||
}
|
|
||||||
return this.messages.slice(len - this.limit)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
scrollToEnd() {
|
|
||||||
if (!this.auto) return
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.$el.scrollTo(0, this.$el.lastElementChild.offsetTop)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
onScroll({ target }) {
|
|
||||||
this.auto = target.scrollHeight === target.scrollTop + target.clientHeight
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
if (this.autoScroll) {
|
|
||||||
this.$watch('messages', this.scrollToEnd, { deep: true })
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.fixed-height {
|
.fixed-height {
|
||||||
max-height: 20vh;
|
max-height: 20vh;
|
||||||
|
|
|
@ -1,5 +1,54 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
request: Obj
|
||||||
|
statusSize?: string
|
||||||
|
showTime?: boolean
|
||||||
|
showError?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
statusSize: '',
|
||||||
|
showTime: false,
|
||||||
|
showError: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const color = computed(() => {
|
||||||
|
const statuses = {
|
||||||
|
pending: 'primary',
|
||||||
|
success: 'success',
|
||||||
|
warning: 'warning',
|
||||||
|
error: 'danger',
|
||||||
|
}
|
||||||
|
return statuses[props.request.status]
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorsCount = computed(() => {
|
||||||
|
return props.request.messages.filter(({ type }) => type === 'danger').length
|
||||||
|
})
|
||||||
|
|
||||||
|
const warningsCount = computed(() => {
|
||||||
|
return props.request.messages.filter(({ type }) => type === 'warning').length
|
||||||
|
})
|
||||||
|
|
||||||
|
function reviewError() {
|
||||||
|
store.dispatch('REVIEW_ERROR', props.request)
|
||||||
|
}
|
||||||
|
|
||||||
|
function hour(date: Date) {
|
||||||
|
return new Date(date).toLocaleTimeString()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-bind="$attrs" class="query-header w-100">
|
<div class="query-header w-100">
|
||||||
<!-- STATUS -->
|
<!-- STATUS -->
|
||||||
<span
|
<span
|
||||||
class="status"
|
class="status"
|
||||||
|
@ -47,51 +96,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'QueryHeader',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
request: { type: Object, required: true },
|
|
||||||
statusSize: { type: String, default: '' },
|
|
||||||
showTime: { type: Boolean, default: false },
|
|
||||||
showError: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
color() {
|
|
||||||
const statuses = {
|
|
||||||
pending: 'primary',
|
|
||||||
success: 'success',
|
|
||||||
warning: 'warning',
|
|
||||||
error: 'danger',
|
|
||||||
}
|
|
||||||
return statuses[this.request.status]
|
|
||||||
},
|
|
||||||
|
|
||||||
errorsCount() {
|
|
||||||
return this.request.messages.filter(({ type }) => type === 'danger')
|
|
||||||
.length
|
|
||||||
},
|
|
||||||
|
|
||||||
warningsCount() {
|
|
||||||
return this.request.messages.filter(({ type }) => type === 'warning')
|
|
||||||
.length
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
reviewError() {
|
|
||||||
this.$store.dispatch('REVIEW_ERROR', this.request)
|
|
||||||
},
|
|
||||||
|
|
||||||
hour(date) {
|
|
||||||
return new Date(date).toLocaleTimeString()
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
div {
|
div {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,3 +1,35 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
tree: Obj
|
||||||
|
flush?: boolean
|
||||||
|
last?: boolean
|
||||||
|
toggleText?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
flush: false,
|
||||||
|
last: undefined,
|
||||||
|
toggleText: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
default: (props: any) => any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
function getClasses(node: Obj, i: number) {
|
||||||
|
const children = node.height > 0
|
||||||
|
const opened = children && node.data?.opened
|
||||||
|
const last =
|
||||||
|
props.last !== false &&
|
||||||
|
(!children || !opened) &&
|
||||||
|
i === props.tree.children.length - 1
|
||||||
|
return { collapsible: children, uncollapsible: !children, opened, last }
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BListGroup :flush="flush" :style="{ '--depth': tree.depth }">
|
<BListGroup :flush="flush" :style="{ '--depth': tree.depth }">
|
||||||
<template v-for="(node, i) in tree.children" :key="node.id">
|
<template v-for="(node, i) in tree.children" :key="node.id">
|
||||||
|
@ -9,7 +41,7 @@
|
||||||
<slot name="default" v-bind="node" />
|
<slot name="default" v-bind="node" />
|
||||||
|
|
||||||
<BButton
|
<BButton
|
||||||
v-if="node.children"
|
v-if="node.height > 0"
|
||||||
size="xs"
|
size="xs"
|
||||||
variant="outline-secondary"
|
variant="outline-secondary"
|
||||||
:aria-expanded="node.data.opened ? 'true' : 'false'"
|
:aria-expanded="node.data.opened ? 'true' : 'false'"
|
||||||
|
@ -24,7 +56,7 @@
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
|
|
||||||
<BCollapse
|
<BCollapse
|
||||||
v-if="node.children"
|
v-if="node.height > 0"
|
||||||
v-model="node.data.opened"
|
v-model="node.data.opened"
|
||||||
:id="'collapse-' + node.id"
|
:id="'collapse-' + node.id"
|
||||||
>
|
>
|
||||||
|
@ -43,31 +75,6 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.list-group {
|
.list-group {
|
||||||
.collapse {
|
.collapse {
|
||||||
|
|
|
@ -1,3 +1,21 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CustomRoute } from '@/types/commons'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
routes: CustomRoute[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineSlots<{
|
||||||
|
'tab-top': any
|
||||||
|
'tab-before': any
|
||||||
|
'tab-after': any
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard no-body>
|
<BCard no-body>
|
||||||
<BCardHeader header-tag="nav">
|
<BCardHeader header-tag="nav">
|
||||||
|
@ -32,17 +50,3 @@
|
||||||
</RouterView>
|
</RouterView>
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'RoutableTabs',
|
|
||||||
|
|
||||||
// Thanks to `v-bind="$attrs"` and `inheritAttrs: false`, this component can forward
|
|
||||||
// arbitrary attributes (props) directly to its children.
|
|
||||||
inheritAttrs: false,
|
|
||||||
|
|
||||||
props: {
|
|
||||||
routes: { type: Array, required: true },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,12 +1,64 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BaseValidation } from '@vuelidate/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
submitText?: string
|
||||||
|
validation?: BaseValidation
|
||||||
|
serverError?: string
|
||||||
|
inline?: boolean
|
||||||
|
noFooter?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: 'ynh-form',
|
||||||
|
submitText: undefined,
|
||||||
|
validation: undefined,
|
||||||
|
serverError: '',
|
||||||
|
inline: false,
|
||||||
|
noFooter: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [e: SubmitEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const errorFeedback = computed(() => {
|
||||||
|
const v = props.validation
|
||||||
|
return (
|
||||||
|
props.serverError ||
|
||||||
|
(v && v.$errors.length ? t('form_errors.invalid_form') : '')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(e: Event) {
|
||||||
|
const v = props.validation
|
||||||
|
if (v) {
|
||||||
|
v.$touch()
|
||||||
|
if (v.$pending || v.$errors.length) return
|
||||||
|
}
|
||||||
|
emit('submit', e as SubmitEvent)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<BCardBody>
|
<BCardBody>
|
||||||
<slot name="disclaimer" />
|
<slot name="disclaimer" />
|
||||||
|
|
||||||
<BForm
|
<BForm
|
||||||
|
v-bind="$attrs"
|
||||||
:id="id"
|
:id="id"
|
||||||
:inline="inline"
|
:inline="inline"
|
||||||
:class="formClasses"
|
|
||||||
novalidate
|
novalidate
|
||||||
@submit.prevent.stop="onSubmit"
|
@submit.prevent.stop="onSubmit"
|
||||||
>
|
>
|
||||||
|
@ -35,44 +87,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'AbstractForm',
|
|
||||||
|
|
||||||
inheritAttrs: false,
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: 'ynh-form' },
|
|
||||||
submitText: { type: String, default: null },
|
|
||||||
validation: { type: Object, default: null },
|
|
||||||
serverError: { type: String, default: '' },
|
|
||||||
inline: { type: Boolean, default: false },
|
|
||||||
formClasses: { type: [Array, String, Object], default: null },
|
|
||||||
noFooter: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
errorFeedback() {
|
|
||||||
if (this.serverError) return this.serverError
|
|
||||||
else if (this.validation && this.validation.$errors.length) {
|
|
||||||
return this.$t('form_errors.invalid_form')
|
|
||||||
} else return ''
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onSubmit(e) {
|
|
||||||
const v = this.validation
|
|
||||||
if (v) {
|
|
||||||
v.$touch()
|
|
||||||
if (v.$pending || v.$errors.length) return
|
|
||||||
}
|
|
||||||
this.$emit('submit')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card-footer {
|
.card-footer {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,8 +1,62 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BaseValidation } from '@vuelidate/core'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { VueClass } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
submitText?: string
|
||||||
|
validation?: BaseValidation
|
||||||
|
serverError?: string
|
||||||
|
inline?: boolean
|
||||||
|
formClasses?: VueClass
|
||||||
|
noFooter?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: 'ynh-form',
|
||||||
|
submitText: undefined,
|
||||||
|
validation: undefined,
|
||||||
|
serverError: '',
|
||||||
|
inline: false,
|
||||||
|
formClasses: undefined,
|
||||||
|
noFooter: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
submit: [e: SubmitEvent]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const errorFeedback = computed(() => {
|
||||||
|
const v = props.validation
|
||||||
|
return (
|
||||||
|
props.serverError ||
|
||||||
|
(v && v.$errors.length ? t('form_errors.invalid_form') : '')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onSubmit(e: Event) {
|
||||||
|
const v = props.validation
|
||||||
|
if (v) {
|
||||||
|
v.$touch()
|
||||||
|
if (v.$pending || v.$errors.length) return
|
||||||
|
}
|
||||||
|
emit('submit', e as SubmitEvent)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
<!-- FIXME inheritAttrs false? probably remove vbind instead -->
|
||||||
<YCard v-bind="$attrs" class="card-form">
|
<YCard v-bind="$attrs" class="card-form">
|
||||||
<template #default>
|
<template #default>
|
||||||
<slot name="disclaimer" />
|
<slot name="disclaimer" />
|
||||||
|
|
||||||
|
{{ serverError }}
|
||||||
<BForm
|
<BForm
|
||||||
:id="id"
|
:id="id"
|
||||||
:inline="inline"
|
:inline="inline"
|
||||||
|
@ -34,41 +88,3 @@
|
||||||
</template>
|
</template>
|
||||||
</YCard>
|
</YCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'CardForm',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: 'ynh-form' },
|
|
||||||
submitText: { type: String, default: null },
|
|
||||||
validation: { type: Object, default: null },
|
|
||||||
serverError: { type: String, default: '' },
|
|
||||||
inline: { type: Boolean, default: false },
|
|
||||||
formClasses: { type: [Array, String, Object], default: null },
|
|
||||||
noFooter: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
errorFeedback() {
|
|
||||||
if (this.serverError) return this.serverError
|
|
||||||
else if (this.validation && this.validation.$errors.length) {
|
|
||||||
return this.$t('form_errors.invalid_form')
|
|
||||||
} else return ''
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onSubmit(e) {
|
|
||||||
const v = this.validation
|
|
||||||
if (v) {
|
|
||||||
v.$touch()
|
|
||||||
if (v.$pending || v.$invalid) return
|
|
||||||
}
|
|
||||||
this.$emit('submit', e)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss"></style>
|
|
||||||
|
|
|
@ -1,6 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import type { Cols } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
term?: string
|
||||||
|
details?: string
|
||||||
|
cols?: Cols
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
term: undefined,
|
||||||
|
details: undefined,
|
||||||
|
cols: () => ({ md: 4, xl: 3 }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const cols = computed<Cols>(() => ({
|
||||||
|
md: 4,
|
||||||
|
xl: 3,
|
||||||
|
...props.cols,
|
||||||
|
}))
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BRow no-gutters class="description-row">
|
<BRow no-gutters class="description-row">
|
||||||
<BCol v-bind="cols_">
|
<BCol v-bind="cols">
|
||||||
<slot name="term">
|
<slot name="term">
|
||||||
<strong>{{ term }}</strong>
|
<strong>{{ term }}</strong>
|
||||||
</slot>
|
</slot>
|
||||||
|
@ -14,24 +39,6 @@
|
||||||
</BRow>
|
</BRow>
|
||||||
</template>
|
</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>
|
<style lang="scss" scoped>
|
||||||
.description-row {
|
.description-row {
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
|
|
|
@ -1,3 +1,22 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ColorVariant } from 'bootstrap-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id: string
|
||||||
|
title: string
|
||||||
|
content: string
|
||||||
|
variant?: ColorVariant
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: 'info',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const open = ref(false)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span class="explain-what">
|
<span class="explain-what">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
|
@ -27,31 +46,6 @@
|
||||||
</span>
|
</span>
|
||||||
</template>
|
</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)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.explain-what {
|
.explain-what {
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
|
|
|
@ -1,9 +1,130 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BaseValidation } from '@vuelidate/core'
|
||||||
|
import type { BaseColorVariant } from 'bootstrap-vue-next'
|
||||||
|
import { computed, provide, useAttrs, type Component } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
name: 'FormField',
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
// Component props (other <form-group> related attrs are passed thanks to $attrs)
|
||||||
|
id?: string
|
||||||
|
description?: string
|
||||||
|
descriptionVariant?: BaseColorVariant
|
||||||
|
link?: { href: string; text: string }
|
||||||
|
component?: Component | string // FIXME limit to formItems?
|
||||||
|
modelValue?: unknown
|
||||||
|
props?: Obj
|
||||||
|
validation?: BaseValidation
|
||||||
|
validationIndex?: number
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
description: undefined,
|
||||||
|
descriptionVariant: undefined,
|
||||||
|
link: undefined,
|
||||||
|
component: 'InputItem',
|
||||||
|
modelValue: undefined,
|
||||||
|
props: () => ({}),
|
||||||
|
validation: undefined,
|
||||||
|
validationIndex: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function touch(name: string) {
|
||||||
|
if (props.validation) {
|
||||||
|
// For fields that have multiple elements
|
||||||
|
if (name) {
|
||||||
|
props.validation[name].$touch()
|
||||||
|
} else {
|
||||||
|
props.validation.$touch()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
provide('touch', touch)
|
||||||
|
|
||||||
|
const attrs_ = useAttrs()
|
||||||
|
const attrs = computed(() => {
|
||||||
|
const attrs = { ...attrs_ }
|
||||||
|
|
||||||
|
if ('label' in attrs) {
|
||||||
|
const defaultAttrs = {
|
||||||
|
'label-cols-md': 4,
|
||||||
|
'label-cols-lg': 3,
|
||||||
|
'label-class': ['fw-bold', 'py-0'],
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!('label-cols' in attrs)) {
|
||||||
|
let attr: keyof typeof defaultAttrs
|
||||||
|
for (attr in defaultAttrs) {
|
||||||
|
if (!(attr in attrs)) attrs[attr] = defaultAttrs[attr]
|
||||||
|
}
|
||||||
|
} else if (!('label-class' in attrs)) {
|
||||||
|
attrs['label-class'] = defaultAttrs['label-class']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return attrs
|
||||||
|
})
|
||||||
|
|
||||||
|
const id = computed(() => {
|
||||||
|
if (props.id) return props.id
|
||||||
|
const childId = props.props.id || attrs_['label-for']
|
||||||
|
return childId ? childId + '_group' : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const error = computed(() => {
|
||||||
|
const v = props.validation
|
||||||
|
if (v) {
|
||||||
|
if (props.validationIndex !== undefined) {
|
||||||
|
const errors = v.$each.$response.$errors[props.validationIndex]
|
||||||
|
const err = Object.values(errors).find((part) => {
|
||||||
|
return part.length
|
||||||
|
})
|
||||||
|
return err?.length ? err[0] : null
|
||||||
|
}
|
||||||
|
return v.$errors.length ? { ...v.$errors[0], $model: v.$model } : null
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
const state = computed(() => {
|
||||||
|
// Need to set state as null if no error, else component turn green
|
||||||
|
return error.value ? false : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const errorMessage = computed(() => {
|
||||||
|
const err = error.value
|
||||||
|
if (err) {
|
||||||
|
if (err.$message) return err.$message
|
||||||
|
return t('form_errors.' + err.$validator, {
|
||||||
|
value: err.$model,
|
||||||
|
...err.$params,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- v-bind="$attrs" allow to pass default attrs not specified in this component slots -->
|
<!-- v-bind="$attrs" allow to pass default attrs not specified in this component slots -->
|
||||||
<BFormGroup
|
<BFormGroup
|
||||||
v-bind="attrs"
|
v-bind="attrs"
|
||||||
:id="_id"
|
:id="id"
|
||||||
:label-for="$attrs['label-for'] || props.id"
|
:label-for="attrs['label-for'] || props.id"
|
||||||
:state="state"
|
:state="state"
|
||||||
@touch="touch"
|
@touch="touch"
|
||||||
>
|
>
|
||||||
|
@ -11,10 +132,10 @@
|
||||||
<slot v-bind="{ self: { ...props, state }, touch }">
|
<slot v-bind="{ self: { ...props, state }, touch }">
|
||||||
<!-- if no component was passed as slot, render a component from the props -->
|
<!-- if no component was passed as slot, render a component from the props -->
|
||||||
<Component
|
<Component
|
||||||
v-bind="props"
|
v-bind="props.props"
|
||||||
:is="component"
|
:is="component"
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
@update:modelValue="$emit('update:modelValue', $event)"
|
@update:modelValue="emit('update:modelValue', $event)"
|
||||||
:state="state"
|
:state="state"
|
||||||
:required="validation ? 'required' in validation : false"
|
:required="validation ? 'required' in validation : false"
|
||||||
/>
|
/>
|
||||||
|
@ -47,107 +168,6 @@
|
||||||
</BFormGroup>
|
</BFormGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { provide } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'FormField',
|
|
||||||
|
|
||||||
inheritAttrs: false,
|
|
||||||
|
|
||||||
props: {
|
|
||||||
// Component props (other <form-group> related attrs are passed thanks to $attrs)
|
|
||||||
id: { type: String, default: null },
|
|
||||||
description: { type: String, default: null },
|
|
||||||
descriptionVariant: { type: String, default: null },
|
|
||||||
link: { type: Object, default: null },
|
|
||||||
// Rendered field component props
|
|
||||||
component: { type: String, default: 'InputItem' },
|
|
||||||
modelValue: { type: null, default: null },
|
|
||||||
props: { type: Object, default: () => ({}) },
|
|
||||||
validation: { type: Object, default: null },
|
|
||||||
validationIndex: { type: Number, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup(props) {
|
|
||||||
function touch(name) {
|
|
||||||
if (props.validation) {
|
|
||||||
// For fields that have multiple elements
|
|
||||||
if (name) {
|
|
||||||
props.validation[name].$touch()
|
|
||||||
} else {
|
|
||||||
props.validation.$touch()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
provide('touch', touch)
|
|
||||||
|
|
||||||
return { touch }
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
_id() {
|
|
||||||
if (this.id) return this.id
|
|
||||||
const childId = this.props.id || this.$attrs['label-for']
|
|
||||||
return childId ? childId + '_group' : null
|
|
||||||
},
|
|
||||||
|
|
||||||
attrs() {
|
|
||||||
const attrs = { ...this.$attrs }
|
|
||||||
if ('label' in attrs) {
|
|
||||||
const defaultAttrs = {
|
|
||||||
'label-cols-md': 4,
|
|
||||||
'label-cols-lg': 3,
|
|
||||||
'label-class': ['fw-bold', 'py-0'],
|
|
||||||
}
|
|
||||||
if (!('label-cols' in attrs)) {
|
|
||||||
for (const attr in defaultAttrs) {
|
|
||||||
if (!(attr in attrs)) attrs[attr] = defaultAttrs[attr]
|
|
||||||
}
|
|
||||||
} else if (!('label-class' in attrs)) {
|
|
||||||
attrs['label-class'] = defaultAttrs['label-class']
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return attrs
|
|
||||||
},
|
|
||||||
|
|
||||||
error() {
|
|
||||||
if (this.validation) {
|
|
||||||
if (this.validationIndex !== null) {
|
|
||||||
const errors =
|
|
||||||
this.validation.$each.$response.$errors[this.validationIndex]
|
|
||||||
const err = Object.values(errors).find((part) => {
|
|
||||||
return part.length
|
|
||||||
})
|
|
||||||
return err?.length ? err[0] : null
|
|
||||||
}
|
|
||||||
return this.validation.$errors.length
|
|
||||||
? { ...this.validation.$errors[0], $model: this.validation.$model }
|
|
||||||
: null
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
|
|
||||||
state() {
|
|
||||||
// Need to set state as null if no error, else component turn green
|
|
||||||
return this.error ? false : null
|
|
||||||
},
|
|
||||||
|
|
||||||
errorMessage() {
|
|
||||||
const err = this.error
|
|
||||||
if (err) {
|
|
||||||
if (err.$message) return err.$message
|
|
||||||
return this.$t('form_errors.' + err.$validator, {
|
|
||||||
value: err.$model,
|
|
||||||
...err.$params,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return ''
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
:deep(.invalid-feedback code) {
|
:deep(.invalid-feedback code) {
|
||||||
background-color: $gray-200;
|
background-color: $gray-200;
|
||||||
|
|
|
@ -1,6 +1,56 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import type { Cols } from '@/types/commons'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
component?: string
|
||||||
|
// FIXME modelValue? not a modelValue but idk
|
||||||
|
value?: any
|
||||||
|
cols?: Cols
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
component: 'InputItem',
|
||||||
|
value: null,
|
||||||
|
cols: () => ({ md: 4, lg: 3 }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const cols = computed<Cols>(() => ({
|
||||||
|
md: 4,
|
||||||
|
xl: 3,
|
||||||
|
...props.cols,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const text = computed(() => {
|
||||||
|
return parseValue(props.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function parseValue(value: any) {
|
||||||
|
const item = props.component
|
||||||
|
if (item === 'FileItem') value = value.file ? value.file.name : null
|
||||||
|
if (item === 'CheckboxItem') value = t(value ? 'yes' : 'no')
|
||||||
|
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
value = value.length ? value.join(t('words.separator')) : null
|
||||||
|
}
|
||||||
|
if ([null, undefined, ''].includes(props.value)) value = t('words.none')
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BRow no-gutters class="description-row">
|
<BRow no-gutters class="description-row">
|
||||||
<BCol v-bind="cols_" class="fw-bold">
|
<BCol v-bind="cols" class="fw-bold">
|
||||||
{{ label }}
|
{{ label }}
|
||||||
</BCol>
|
</BCol>
|
||||||
|
|
||||||
|
@ -11,46 +61,6 @@
|
||||||
</BRow>
|
</BRow>
|
||||||
</template>
|
</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.$t(value ? 'yes' : 'no')
|
|
||||||
if (item === 'TextAreaItem') value = value.replaceAll('\n', '<br>')
|
|
||||||
if (Array.isArray(value)) {
|
|
||||||
value = value.length ? value.join(this.$t('words.separator')) : null
|
|
||||||
}
|
|
||||||
if ([null, undefined, ''].includes(this.value))
|
|
||||||
value = this.$t('words.none')
|
|
||||||
return value
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.description-row {
|
.description-row {
|
||||||
@include media-breakpoint-up(md) {
|
@include media-breakpoint-up(md) {
|
||||||
|
|
|
@ -1,50 +1,37 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { CustomRoute } from '@/types/commons'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
label?: string
|
||||||
|
button?: CustomRoute
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const slots = defineSlots<{
|
||||||
|
'group-left': any
|
||||||
|
'group-right': any
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BButtonToolbar :aria-label="label" id="top-bar">
|
<BButtonToolbar :aria-label="label" id="top-bar">
|
||||||
<div id="top-bar-left" class="top-bar-group" v-if="hasLeftSlot">
|
<div id="top-bar-left" class="top-bar-group" v-if="slots['group-left']">
|
||||||
<slot name="group-left" />
|
<slot name="group-left" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="top-bar-right" class="top-bar-group" v-if="hasRightSlot || button">
|
<div
|
||||||
<slot v-if="hasRightSlot" name="group-right" />
|
v-if="slots['group-right'] || button"
|
||||||
|
id="top-bar-right"
|
||||||
|
class="top-bar-group"
|
||||||
|
>
|
||||||
|
<slot v-if="slots['group-right']" name="group-right" />
|
||||||
|
|
||||||
<BButton v-else variant="success" :to="button.to">
|
<BButton v-else-if="button" variant="success" :to="button.to">
|
||||||
<YIcon v-if="button.icon" :iname="button.icon" /> {{ button.text }}
|
<YIcon v-if="button.icon" :iname="button.icon" /> {{ button.text }}
|
||||||
</BButton>
|
</BButton>
|
||||||
</div>
|
</div>
|
||||||
</BButtonToolbar>
|
</BButtonToolbar>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'TopBar',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
label: { type: String, default: null },
|
|
||||||
button: {
|
|
||||||
type: Object,
|
|
||||||
default: null,
|
|
||||||
validator(value) {
|
|
||||||
return ['text', 'to'].every((prop) => prop in value)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
hasLeftSlot: null,
|
|
||||||
hasRightSlot: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
this.$nextTick(() => {
|
|
||||||
this.hasLeftSlot = 'group-left' in this.$slots
|
|
||||||
this.hasRightSlot = 'group-right' in this.$slots
|
|
||||||
})
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
#top-bar {
|
#top-bar {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
|
@ -1,6 +1,72 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
// FIXME type queries
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
queries?: any[]
|
||||||
|
queriesWait?: boolean
|
||||||
|
skeleton?: string | Component
|
||||||
|
loading?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
queries: undefined,
|
||||||
|
queriesWait: false,
|
||||||
|
skeleton: undefined,
|
||||||
|
loading: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const slots = defineSlots<{
|
||||||
|
'top-bar-group-left': any
|
||||||
|
'top-bar-group-right': any
|
||||||
|
'top-bar': any
|
||||||
|
top(props: { loading: boolean }): any
|
||||||
|
default(props: { loading: boolean }): any
|
||||||
|
bot(props: { loading: boolean }): any
|
||||||
|
skeleton: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'queries-response': any[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineExpose({ fetchQueries })
|
||||||
|
|
||||||
|
const fallbackLoading = ref(
|
||||||
|
props.loading === undefined && props.queries !== undefined ? true : null,
|
||||||
|
)
|
||||||
|
|
||||||
|
const isLoading = computed(() => {
|
||||||
|
if (props.loading !== undefined) return props.loading
|
||||||
|
return fallbackLoading.value
|
||||||
|
})
|
||||||
|
|
||||||
|
function fetchQueries({ triggerLoading = false } = {}) {
|
||||||
|
if (triggerLoading) {
|
||||||
|
fallbackLoading.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
return api
|
||||||
|
.fetchAll(props.queries, { wait: props.queriesWait, initial: true })
|
||||||
|
.then((responses) => {
|
||||||
|
emit('queries-response', ...responses)
|
||||||
|
fallbackLoading.value = false
|
||||||
|
return responses
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.queries) {
|
||||||
|
fetchQueries()
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<TopBar v-if="hasTopBar">
|
<TopBar v-if="slots['top-bar-group-left'] || slots['top-bar-group-right']">
|
||||||
<template #group-left>
|
<template #group-left>
|
||||||
<slot name="top-bar-group-left" />
|
<slot name="top-bar-group-left" />
|
||||||
</template>
|
</template>
|
||||||
|
@ -28,58 +94,3 @@
|
||||||
<slot name="bot" v-bind="{ loading: isLoading }" />
|
<slot name="bot" v-bind="{ loading: isLoading }" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ViewBase',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
queries: { type: Array, default: null },
|
|
||||||
queriesWait: { type: Boolean, default: false },
|
|
||||||
skeleton: { type: [String, Array], default: null },
|
|
||||||
// Optional prop to take control of the loading value
|
|
||||||
loading: { type: Boolean, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
fallback_loading:
|
|
||||||
this.loading === null && this.queries !== null ? true : null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
isLoading() {
|
|
||||||
if (this.loading !== null) return this.loading
|
|
||||||
return this.fallback_loading
|
|
||||||
},
|
|
||||||
|
|
||||||
hasTopBar() {
|
|
||||||
return ['top-bar-group-left', 'top-bar-group-right'].some(
|
|
||||||
(slotName) => slotName in this.$slots,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
fetchQueries({ triggerLoading = false } = {}) {
|
|
||||||
if (triggerLoading) {
|
|
||||||
this.fallback_loading = true
|
|
||||||
}
|
|
||||||
|
|
||||||
api
|
|
||||||
.fetchAll(this.queries, { wait: this.queriesWait, initial: true })
|
|
||||||
.then((responses) => {
|
|
||||||
this.$emit('queries-response', ...responses)
|
|
||||||
this.fallback_loading = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
if (this.queries) this.fetchQueries()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,9 +1,43 @@
|
||||||
|
<script setup lang="ts" generic="T extends Obj">
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
items: T[] | null
|
||||||
|
itemsName: string | null
|
||||||
|
filteredItems: T[] | null
|
||||||
|
search?: string
|
||||||
|
skeleton?: string | Component
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
search: undefined,
|
||||||
|
skeleton: 'ListGroupSkeleton',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const slots = defineSlots<{
|
||||||
|
'top-bar': any
|
||||||
|
'top-bar-buttons': any
|
||||||
|
top: any
|
||||||
|
'alert-message': any
|
||||||
|
default: any
|
||||||
|
bot: any
|
||||||
|
skeleton: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:search': [value: string]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase v-bind="$attrs" :skeleton="skeleton">
|
<ViewBase :skeleton="skeleton">
|
||||||
<template v-if="hasCustomTopBar" #top-bar>
|
<template v-if="slots['top-bar']" #top-bar>
|
||||||
<slot name="top-bar" />
|
<slot name="top-bar" />
|
||||||
</template>
|
</template>
|
||||||
<template v-if="!hasCustomTopBar" #top-bar-group-left>
|
<template v-if="!slots['top-bar']" #top-bar-group-left>
|
||||||
<BInputGroup class="w-100">
|
<BInputGroup class="w-100">
|
||||||
<BInputGroupText>
|
<BInputGroupText>
|
||||||
<YIcon iname="search" />
|
<YIcon iname="search" />
|
||||||
|
@ -12,7 +46,7 @@
|
||||||
<BFormInput
|
<BFormInput
|
||||||
id="top-bar-search"
|
id="top-bar-search"
|
||||||
:modelValue="search"
|
:modelValue="search"
|
||||||
@update:modelValue="$emit('update:search', $event)"
|
@update:modelValue="emit('update:search', $event)"
|
||||||
:placeholder="
|
:placeholder="
|
||||||
$t('search.for', { items: $t('items.' + itemsName, 2) })
|
$t('search.for', { items: $t('items.' + itemsName, 2) })
|
||||||
"
|
"
|
||||||
|
@ -20,7 +54,7 @@
|
||||||
/>
|
/>
|
||||||
</BInputGroup>
|
</BInputGroup>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="!hasCustomTopBar" #top-bar-group-right>
|
<template v-if="!slots['top-bar']" #top-bar-group-right>
|
||||||
<slot name="top-bar-buttons" />
|
<slot name="top-bar-buttons" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
@ -60,23 +94,3 @@
|
||||||
</template>
|
</template>
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'ViewSearch',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
items: { type: null, required: true },
|
|
||||||
itemsName: { type: String, required: true },
|
|
||||||
filteredItems: { type: null, required: true },
|
|
||||||
search: { type: String, default: null },
|
|
||||||
skeleton: { type: String, default: 'ListGroupSkeleton' },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
hasCustomTopBar() {
|
|
||||||
return 'top-bar' in this.$slots
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,26 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
alert?: boolean
|
||||||
|
variant?: keyof typeof DEFAULT_STATUS_ICON
|
||||||
|
icon?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
alert: false,
|
||||||
|
variant: 'info',
|
||||||
|
icon: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
return props.icon || DEFAULT_STATUS_ICON[props.variant]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<Component
|
<Component
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
|
@ -7,31 +30,10 @@
|
||||||
:class="{ ['alert alert-' + variant]: !alert }"
|
:class="{ ['alert alert-' + variant]: !alert }"
|
||||||
class="yuno-alert d-flex flex-column flex-md-row align-items-center"
|
class="yuno-alert d-flex flex-column flex-md-row align-items-center"
|
||||||
>
|
>
|
||||||
<YIcon :iname="_icon" class="me-md-3 mb-md-0 mb-2 md" />
|
<YIcon :iname="icon" class="me-md-3 mb-md-0 mb-2 md" />
|
||||||
|
|
||||||
<div class="w-100">
|
<div class="w-100">
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</div>
|
</div>
|
||||||
</Component>
|
</Component>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'YAlert',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
alert: { type: Boolean, default: false },
|
|
||||||
variant: { type: String, default: 'info' },
|
|
||||||
icon: { type: String, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
_icon() {
|
|
||||||
if (this.icon) return this.icon
|
|
||||||
return DEFAULT_STATUS_ICON[this.variant]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,16 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const { breadcrumb } = useStoreGetters()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.breadcrumb {
|
||||||
|
border: none;
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BBreadcrumb v-if="breadcrumb.length">
|
<BBreadcrumb v-if="breadcrumb.length">
|
||||||
<BBreadcrumbItem to="/">
|
<BBreadcrumbItem to="/">
|
||||||
|
@ -15,22 +28,3 @@
|
||||||
</BBreadcrumbItem>
|
</BBreadcrumbItem>
|
||||||
</BBreadcrumb>
|
</BBreadcrumb>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'YBreadcrumb',
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['breadcrumb']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.breadcrumb {
|
|
||||||
border: none;
|
|
||||||
background-color: transparent;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|
|
@ -1,5 +1,41 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Breakpoint } from 'bootstrap-vue-next'
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
title?: string
|
||||||
|
titleTag?: string
|
||||||
|
icon?: string
|
||||||
|
collapsable?: boolean
|
||||||
|
collapsed?: boolean
|
||||||
|
buttonUnbreak?: Breakpoint
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: 'ynh-form',
|
||||||
|
title: undefined,
|
||||||
|
titleTag: 'h2',
|
||||||
|
icon: undefined,
|
||||||
|
collapsable: false,
|
||||||
|
collapsed: false,
|
||||||
|
buttonUnbreak: 'md',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const slots = defineSlots<{
|
||||||
|
header: any
|
||||||
|
'header-next': any
|
||||||
|
'header-buttons': any
|
||||||
|
default: any
|
||||||
|
buttons: any
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const visible = ref(!props.collapsed)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard v-bind="$attrs" :no-body="collapsable ? true : $attrs['no-body']">
|
<BCard :no-body="collapsable ? true : $attrs['no-body']">
|
||||||
<template #header>
|
<template #header>
|
||||||
<div class="w-100 d-flex align-items-center flex-wrap custom-header">
|
<div class="w-100 d-flex align-items-center flex-wrap custom-header">
|
||||||
<slot name="header">
|
<slot name="header">
|
||||||
|
@ -10,7 +46,7 @@
|
||||||
</slot>
|
</slot>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="hasButtons"
|
v-if="slots['header-buttons']"
|
||||||
class="mt-2 w-100 custom-header-buttons"
|
class="mt-2 w-100 custom-header-buttons"
|
||||||
:class="{
|
:class="{
|
||||||
[`ms-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
|
[`ms-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
|
||||||
|
@ -48,40 +84,12 @@
|
||||||
<slot name="default" />
|
<slot name="default" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template #footer v-if="'buttons' in $slots">
|
<template #footer v-if="slots['buttons']">
|
||||||
<slot name="buttons" />
|
<slot name="buttons" />
|
||||||
</template>
|
</template>
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'YCard',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: 'ynh-form' },
|
|
||||||
title: { type: String, default: null },
|
|
||||||
titleTag: { type: String, default: 'h2' },
|
|
||||||
icon: { type: String, default: null },
|
|
||||||
collapsable: { type: Boolean, default: false },
|
|
||||||
collapsed: { type: Boolean, default: false },
|
|
||||||
buttonUnbreak: { type: String, default: 'md' },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
visible: !this.collapsed,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
hasButtons() {
|
|
||||||
return 'header-buttons' in this.$slots
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
:deep(.card-header) {
|
:deep(.card-header) {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,3 +1,12 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { ColorVariant } from 'bootstrap-vue-next'
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
iname: string
|
||||||
|
variant?: ColorVariant
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<span
|
<span
|
||||||
:class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']"
|
:class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']"
|
||||||
|
@ -5,16 +14,6 @@
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'YIcon',
|
|
||||||
props: {
|
|
||||||
iname: { type: String, required: true },
|
|
||||||
variant: { type: String, default: null },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.icon {
|
.icon {
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
|
@ -1,7 +1,46 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { Breakpoint, ColorVariant } from 'bootstrap-vue-next'
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
variant?: ColorVariant
|
||||||
|
icon?: string
|
||||||
|
noIcon?: boolean
|
||||||
|
noStatus?: boolean
|
||||||
|
size?: Breakpoint
|
||||||
|
faded?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
variant: 'light',
|
||||||
|
icon: undefined,
|
||||||
|
noIcon: false,
|
||||||
|
noStatus: false,
|
||||||
|
size: undefined,
|
||||||
|
faded: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const icon = computed(() => {
|
||||||
|
if (props.noIcon) return
|
||||||
|
return props.icon || DEFAULT_STATUS_ICON[props.variant]
|
||||||
|
})
|
||||||
|
const class_ = computed(() => {
|
||||||
|
const baseClass = 'yuno-list-group-item-'
|
||||||
|
return [
|
||||||
|
baseClass + props.size,
|
||||||
|
baseClass + props.variant,
|
||||||
|
{ [baseClass + 'faded']: props.faded },
|
||||||
|
]
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BListGroupItem v-bind="$attrs" class="yuno-list-group-item" :class="_class">
|
<BListGroupItem v-bind="$attrs" class="yuno-list-group-item" :class="class_">
|
||||||
<div v-if="!noStatus" class="yuno-list-group-item-status">
|
<div v-if="!noStatus" class="yuno-list-group-item-status">
|
||||||
<YIcon v-if="_icon" :iname="_icon" :class="['icon-' + variant]" />
|
<YIcon v-if="icon" :iname="icon" :class="['icon-' + variant]" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="yuno-list-group-item-content">
|
<div class="yuno-list-group-item-content">
|
||||||
|
@ -10,38 +49,6 @@
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'YListGroupItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
variant: { type: String, default: 'white' },
|
|
||||||
icon: { type: String, default: null },
|
|
||||||
noIcon: { type: Boolean, default: false },
|
|
||||||
noStatus: { type: Boolean, default: false },
|
|
||||||
size: { type: String, default: 'md' },
|
|
||||||
faded: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
_icon() {
|
|
||||||
return this.noIcon ? null : this.icon || DEFAULT_STATUS_ICON[this.variant]
|
|
||||||
},
|
|
||||||
|
|
||||||
_class() {
|
|
||||||
const baseClass = 'yuno-list-group-item-'
|
|
||||||
return [
|
|
||||||
baseClass + this.size,
|
|
||||||
baseClass + this.variant,
|
|
||||||
{ [baseClass + 'faded']: this.faded },
|
|
||||||
]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.yuno-list-group-item {
|
.yuno-list-group-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,19 +1,13 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const { spinner } = useStoreGetters()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :class="['custom-spinner', spinner]" />
|
<div :class="['custom-spinner', spinner]" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'YSpinner',
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['spinner']),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.custom-spinner {
|
.custom-spinner {
|
||||||
animation: 8s linear infinite;
|
animation: 8s linear infinite;
|
||||||
|
|
|
@ -1,30 +1,26 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<BButton
|
import { computed } from 'vue'
|
||||||
:id="id"
|
|
||||||
:variant="type"
|
|
||||||
@click="$emit('action', $event)"
|
|
||||||
:disabled="!enabled"
|
|
||||||
class="d-block mb-3"
|
|
||||||
>
|
|
||||||
<YIcon :iname="icon_" class="me-2" />
|
|
||||||
<span v-html="label" />
|
|
||||||
</BButton>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
const props = withDefaults(
|
||||||
export default {
|
defineProps<{
|
||||||
name: 'ButtonItem',
|
label?: string
|
||||||
|
id?: string
|
||||||
props: {
|
type?: 'success' | 'info' | 'warning' | 'danger'
|
||||||
label: { type: String, default: null },
|
icon?: string
|
||||||
id: { type: String, default: null },
|
enabled?: string | boolean
|
||||||
type: { type: String, default: 'success' },
|
}>(),
|
||||||
icon: { type: String, default: null },
|
{
|
||||||
enabled: { type: [Boolean, String], default: true },
|
label: undefined,
|
||||||
|
id: undefined,
|
||||||
|
type: 'success',
|
||||||
|
icon: undefined,
|
||||||
|
enabled: true,
|
||||||
},
|
},
|
||||||
|
)
|
||||||
|
|
||||||
computed: {
|
const emit = defineEmits(['action'])
|
||||||
icon_() {
|
|
||||||
|
const icon = computed(() => {
|
||||||
const icons = {
|
const icons = {
|
||||||
success: 'thumbs-up',
|
success: 'thumbs-up',
|
||||||
info: 'info',
|
info: 'info',
|
||||||
|
@ -32,8 +28,19 @@ export default {
|
||||||
danger: 'times',
|
danger: 'times',
|
||||||
}
|
}
|
||||||
|
|
||||||
return this.icon || icons[this.type]
|
return props.icon || icons[props.type]
|
||||||
},
|
})
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BButton
|
||||||
|
:id="id"
|
||||||
|
:variant="type"
|
||||||
|
@click="emit('action', $event)"
|
||||||
|
:disabled="!enabled"
|
||||||
|
class="d-block mb-3"
|
||||||
|
>
|
||||||
|
<YIcon :iname="icon" class="me-2" />
|
||||||
|
<span v-html="label" />
|
||||||
|
</BButton>
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,7 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
labels?: { true: string; false: string }
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
label: undefined,
|
||||||
|
labels: () => ({ true: 'yes', false: 'no' }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BFormCheckbox
|
<BFormCheckbox
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
@update:modelValue="$emit('update:modelValue', $event)"
|
@update:modelValue="emit('update:modelValue', $event)"
|
||||||
:id="id"
|
:id="id"
|
||||||
:aria-describedby="$parent.id + '__BV_description_'"
|
:aria-describedby="$parent.id + '__BV_description_'"
|
||||||
switch
|
switch
|
||||||
|
@ -9,16 +29,3 @@
|
||||||
{{ label || $t(labels[modelValue]) }}
|
{{ label || $t(labels[modelValue]) }}
|
||||||
</BFormCheckbox>
|
</BFormCheckbox>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'CheckboxItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: { type: Boolean, required: true },
|
|
||||||
id: { type: String, default: null },
|
|
||||||
label: { type: String, default: null },
|
|
||||||
labels: { type: Object, default: () => ({ true: 'yes', false: 'no' }) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,16 +1,18 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
label?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
label: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<p v-text="label" />
|
<p v-text="label" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'DisplayTextItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: null },
|
|
||||||
label: { type: String, default: null },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,8 +1,84 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BFormFile } from 'bootstrap-vue-next'
|
||||||
|
import { computed, inject, ref } from 'vue'
|
||||||
|
|
||||||
|
import { getFileContent } from '@/helpers/commons'
|
||||||
|
|
||||||
|
type CustomFile = {
|
||||||
|
file: File | null
|
||||||
|
content?: string | null
|
||||||
|
current?: boolean
|
||||||
|
removed?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
id?: string
|
||||||
|
modelValue?: CustomFile
|
||||||
|
placeholder?: string
|
||||||
|
dropPlaceholder?: string
|
||||||
|
accept?: string
|
||||||
|
state?: string
|
||||||
|
required?: boolean
|
||||||
|
name?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
id: undefined,
|
||||||
|
modelValue: () => ({ file: null }),
|
||||||
|
placeholder: 'Choose a file or drop it here...',
|
||||||
|
dropPlaceholder: undefined,
|
||||||
|
accept: '',
|
||||||
|
state: undefined,
|
||||||
|
required: false,
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: CustomFile]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const touch = inject('touch')
|
||||||
|
const inputElem = ref<InstanceType<typeof BFormFile> | null>(null)
|
||||||
|
|
||||||
|
const _placeholder = computed(() => {
|
||||||
|
return props.modelValue.file === null
|
||||||
|
? props.placeholder
|
||||||
|
: props.modelValue.file.name
|
||||||
|
})
|
||||||
|
|
||||||
|
function onInput(file: File | File[] | null) {
|
||||||
|
const value = {
|
||||||
|
file: file as File | null,
|
||||||
|
content: file !== null ? '' : null,
|
||||||
|
current: false,
|
||||||
|
removed: false,
|
||||||
|
}
|
||||||
|
// Update the value with the new File and an empty content for now
|
||||||
|
emit('update:modelValue', value)
|
||||||
|
|
||||||
|
// Asynchronously load the File content and update the value again
|
||||||
|
getFileContent(file as File).then((content) => {
|
||||||
|
emit('update:modelValue', { ...value, content })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFiles() {
|
||||||
|
inputElem.value!.reset()
|
||||||
|
emit('update:modelValue', {
|
||||||
|
file: null,
|
||||||
|
content: '',
|
||||||
|
current: false,
|
||||||
|
removed: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BInputGroup class="w-100">
|
<BInputGroup class="w-100">
|
||||||
<template #append>
|
<template #append>
|
||||||
<BButton
|
<BButton
|
||||||
v-if="!this.required && this.modelValue.file !== null"
|
v-if="!required && modelValue.file !== null"
|
||||||
@click="clearFiles"
|
@click="clearFiles"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
>
|
>
|
||||||
|
@ -13,7 +89,7 @@
|
||||||
|
|
||||||
<BFormFile
|
<BFormFile
|
||||||
:modelValue="modelValue.file"
|
:modelValue="modelValue.file"
|
||||||
ref="input-file"
|
ref="inputElem"
|
||||||
:id="id"
|
:id="id"
|
||||||
:required="required"
|
:required="required"
|
||||||
:placeholder="_placeholder"
|
:placeholder="_placeholder"
|
||||||
|
@ -28,68 +104,6 @@
|
||||||
</BInputGroup>
|
</BInputGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { inject } from 'vue'
|
|
||||||
import { getFileContent } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'FileItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: null },
|
|
||||||
modelValue: { 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: '' },
|
|
||||||
state: { type: Boolean, default: null },
|
|
||||||
required: { type: Boolean, default: false },
|
|
||||||
name: { type: String, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
touch: inject('touch'),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
_placeholder: function () {
|
|
||||||
return this.modelValue.file === null
|
|
||||||
? this.placeholder
|
|
||||||
: this.modelValue.file.name
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
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('update:modelValue', value)
|
|
||||||
|
|
||||||
// Asynchronously load the File content and update the value again
|
|
||||||
getFileContent(file).then((content) => {
|
|
||||||
this.$emit('update:modelValue', { ...value, content })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
clearFiles() {
|
|
||||||
this.$refs['input-file'].reset()
|
|
||||||
this.$emit('update:modelValue', {
|
|
||||||
file: null,
|
|
||||||
content: '',
|
|
||||||
current: false,
|
|
||||||
removed: true,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
// fix https://getbootstrap.com/docs/5.2/migration/#forms
|
// fix https://getbootstrap.com/docs/5.2/migration/#forms
|
||||||
:deep(.custom-file-label) {
|
:deep(.custom-file-label) {
|
||||||
|
|
|
@ -1,7 +1,53 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string | number | null
|
||||||
|
id?: string
|
||||||
|
placeholder?: string
|
||||||
|
type?: string
|
||||||
|
required?: boolean
|
||||||
|
state?: false | null
|
||||||
|
min?: number
|
||||||
|
max?: number
|
||||||
|
step?: number
|
||||||
|
trim?: boolean
|
||||||
|
autocomplete?: string
|
||||||
|
// FIXME pattern?
|
||||||
|
pattern?: object
|
||||||
|
name?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: null,
|
||||||
|
id: undefined,
|
||||||
|
placeholder: undefined,
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
state: undefined,
|
||||||
|
min: undefined,
|
||||||
|
max: undefined,
|
||||||
|
step: undefined,
|
||||||
|
trim: true,
|
||||||
|
autocomplete: undefined,
|
||||||
|
pattern: undefined,
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | number | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const touch = inject('touch')
|
||||||
|
|
||||||
|
const autocomplete =
|
||||||
|
props.autocomplete || props.type === 'password' ? 'new-password' : null
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BFormInput
|
<BFormInput
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
@update:modelValue="$emit('update:modelValue', $event)"
|
@update:modelValue="emit('update:modelValue', $event)"
|
||||||
:id="id"
|
:id="id"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:type="type"
|
:type="type"
|
||||||
|
@ -11,47 +57,7 @@
|
||||||
:max="max"
|
:max="max"
|
||||||
:step="step"
|
:step="step"
|
||||||
:trim="trim"
|
:trim="trim"
|
||||||
:autocomplete="autocomplete_"
|
:autocomplete="autocomplete"
|
||||||
@blur="touch(name)"
|
@blur="touch(name)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { inject } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'InputItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: { type: [String, Number], default: null },
|
|
||||||
id: { type: String, default: null },
|
|
||||||
placeholder: { type: String, default: null },
|
|
||||||
type: { type: String, default: 'text' },
|
|
||||||
required: { type: Boolean, default: false },
|
|
||||||
state: { type: Boolean, default: null },
|
|
||||||
min: { type: Number, default: null },
|
|
||||||
max: { type: Number, default: null },
|
|
||||||
step: { type: Number, default: null },
|
|
||||||
trim: { type: Boolean, default: true },
|
|
||||||
autocomplete: { type: String, default: null },
|
|
||||||
pattern: { type: Object, default: null },
|
|
||||||
name: { type: String, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
touch: inject('touch'),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
autocomplete_: this.autocomplete
|
|
||||||
? this.autocomplete
|
|
||||||
: this.type === 'password'
|
|
||||||
? 'new-password'
|
|
||||||
: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,14 +1,10 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
label: string
|
||||||
|
id?: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<VueShowdown :markdown="label" />
|
<VueShowdown :markdown="label" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'MarkdownItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, default: null },
|
|
||||||
label: { type: String, default: null },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,36 +1,33 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<BAlert
|
import { computed } from 'vue'
|
||||||
class="d-flex flex-column flex-md-row align-items-center"
|
|
||||||
:variant="type"
|
|
||||||
:modelValue="true"
|
|
||||||
>
|
|
||||||
<YIcon :iname="icon_" class="me-md-3 mb-md-0 mb-2" :variant="type" />
|
|
||||||
|
|
||||||
<VueShowdown :markdown="label" tag="span" class="markdown" />
|
const props = defineProps<{
|
||||||
</BAlert>
|
label: string
|
||||||
</template>
|
id?: string
|
||||||
|
type?: 'success' | 'info' | 'warning' | 'danger'
|
||||||
|
icon?: string
|
||||||
|
}>()
|
||||||
|
|
||||||
<script>
|
const icon = computed(() => {
|
||||||
export default {
|
|
||||||
name: 'ReadOnlyAlertItem',
|
|
||||||
|
|
||||||
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 = {
|
const icons = {
|
||||||
success: 'thumbs-up',
|
success: 'thumbs-up',
|
||||||
info: 'info',
|
info: 'info',
|
||||||
warning: 'exclamation',
|
warning: 'exclamation',
|
||||||
danger: 'times',
|
danger: 'times',
|
||||||
}
|
}
|
||||||
return this.icon || icons[this.type]
|
|
||||||
},
|
return props.icon || icons[props.type]
|
||||||
},
|
})
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<BAlert
|
||||||
|
class="d-flex flex-column flex-md-row align-items-center"
|
||||||
|
:variant="type"
|
||||||
|
:modelValue="true"
|
||||||
|
>
|
||||||
|
<YIcon :iname="icon" class="me-md-3 mb-md-0 mb-2" :variant="type" />
|
||||||
|
|
||||||
|
<VueShowdown :markdown="label" tag="span" class="markdown" />
|
||||||
|
</BAlert>
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,32 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string | null
|
||||||
|
id?: string
|
||||||
|
choices: string[]
|
||||||
|
required?: boolean
|
||||||
|
name?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: null,
|
||||||
|
id: undefined,
|
||||||
|
required: false,
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string | null]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const touch = inject('touch')
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BFormSelect
|
<BFormSelect
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
@update:modelValue="$emit('update:modelValue', $event)"
|
@update:modelValue="emit('update:modelValue', $event)"
|
||||||
:id="id"
|
:id="id"
|
||||||
:options="choices"
|
:options="choices"
|
||||||
:required="required"
|
:required="required"
|
||||||
@blur="touch(name)"
|
@blur="touch(name)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { inject } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'SelectItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: { type: [String, null], default: null },
|
|
||||||
id: { type: String, default: null },
|
|
||||||
choices: { type: Array, required: true },
|
|
||||||
required: { type: Boolean, default: false },
|
|
||||||
name: { type: String, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
touch: inject('touch'),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: unknown[] | null
|
||||||
|
id?: string
|
||||||
|
placeholder?: string
|
||||||
|
limit?: number
|
||||||
|
required?: boolean
|
||||||
|
state?: boolean
|
||||||
|
name?: string
|
||||||
|
// FIXME no options on BFormTags
|
||||||
|
options?: unknown[]
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: null,
|
||||||
|
id: undefined,
|
||||||
|
placeholder: undefined,
|
||||||
|
limit: undefined,
|
||||||
|
required: false,
|
||||||
|
state: undefined,
|
||||||
|
name: undefined,
|
||||||
|
options: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const touch = inject('touch')
|
||||||
|
|
||||||
|
// FIXME rework for options/choices
|
||||||
|
// https://bootstrap-vue-next.github.io/bootstrap-vue-next/docs/components/form-tags.html#using-custom-form-components
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BFormTags
|
<BFormTags
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
|
@ -13,28 +46,3 @@
|
||||||
@blur="touch(name)"
|
@blur="touch(name)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { inject } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'TagsItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: { type: Array, default: null },
|
|
||||||
id: { type: String, default: null },
|
|
||||||
placeholder: { type: String, default: null },
|
|
||||||
limit: { type: Number, default: null },
|
|
||||||
required: { type: Boolean, default: false },
|
|
||||||
state: { type: Boolean, default: null },
|
|
||||||
name: { type: String, default: null },
|
|
||||||
options: { type: Array, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
touch: inject('touch'),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,9 +1,110 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
// FIXME addTag removeTag types
|
||||||
|
import type { BDropdown, BFormInput } from 'bootstrap-vue-next'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
type TagUpdateArgs = {
|
||||||
|
action: 'add' | 'remove'
|
||||||
|
option: string
|
||||||
|
applyFn: (tag: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue: string[]
|
||||||
|
// FIXME typing
|
||||||
|
options: string[]
|
||||||
|
id: string
|
||||||
|
placeholder?: string
|
||||||
|
limit?: number
|
||||||
|
name?: string
|
||||||
|
itemsName: string
|
||||||
|
disabledItems?: string[]
|
||||||
|
auto?: boolean
|
||||||
|
noTags?: boolean
|
||||||
|
label?: string
|
||||||
|
tagIcon?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
placeholder: undefined,
|
||||||
|
limit: undefined,
|
||||||
|
name: undefined,
|
||||||
|
disabledItems: () => [],
|
||||||
|
auto: false,
|
||||||
|
noTags: false,
|
||||||
|
label: undefined,
|
||||||
|
tagIcon: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string[]]
|
||||||
|
'tag-update': [value: TagUpdateArgs]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const searchElem = ref<InstanceType<typeof BDropdown> | null>(null)
|
||||||
|
const dropdownElem = ref<InstanceType<typeof BFormInput> | null>(null)
|
||||||
|
|
||||||
|
const criteria = computed(() => {
|
||||||
|
return search.value.trim().toLowerCase()
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableOptions = computed(() => {
|
||||||
|
const options = props.options.filter((opt) => {
|
||||||
|
return (
|
||||||
|
props.modelValue.indexOf(opt) === -1 && !props.disabledItems.includes(opt)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
if (criteria.value) {
|
||||||
|
return options.filter(
|
||||||
|
(opt) => opt.toLowerCase().indexOf(criteria.value) > -1,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return options
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchState = computed(() => {
|
||||||
|
return criteria.value && availableOptions.value.length === 0 ? false : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function onAddTag(option: string, applyFn: TagUpdateArgs['applyFn']) {
|
||||||
|
emit('tag-update', { action: 'add', option, applyFn })
|
||||||
|
search.value = ''
|
||||||
|
if (props.auto) {
|
||||||
|
applyFn(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRemoveTag(option: string, applyFn: TagUpdateArgs['applyFn']) {
|
||||||
|
emit('tag-update', { action: 'remove', option, applyFn })
|
||||||
|
if (props.auto) {
|
||||||
|
applyFn(option)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onDropdownKeydown(e) {
|
||||||
|
// Allow to start searching after dropdown opening
|
||||||
|
// FIXME check if dropdownElem.value!.firstElementChild works (removed the $el)
|
||||||
|
if (
|
||||||
|
!['Tab', 'Space'].includes(e.code) &&
|
||||||
|
e.target === dropdownElem.value!.$el.firstElementChild
|
||||||
|
) {
|
||||||
|
searchElem.value!.$el.focus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="tags-selectize">
|
<div class="tags-selectize">
|
||||||
<BFormTags
|
<BFormTags
|
||||||
v-bind="$attrs"
|
v-bind="$attrs"
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
@update:modelValue="$emit('update:modelValue', $event)"
|
@update:modelValue="emit('update:modelValue', $event)"
|
||||||
:id="id"
|
:id="id"
|
||||||
size="lg"
|
size="lg"
|
||||||
class="p-0 border-0"
|
class="p-0 border-0"
|
||||||
|
@ -20,7 +121,7 @@
|
||||||
class="list-inline-item"
|
class="list-inline-item"
|
||||||
>
|
>
|
||||||
<BFormTag
|
<BFormTag
|
||||||
@remove="onRemoveTag({ option: tag, removeTag })"
|
@remove="onRemoveTag(tag, removeTag)"
|
||||||
:title="tag"
|
:title="tag"
|
||||||
:disabled="disabled || disabledItems.includes(tag)"
|
:disabled="disabled || disabledItems.includes(tag)"
|
||||||
class="border border-dark mb-2"
|
class="border border-dark mb-2"
|
||||||
|
@ -31,7 +132,7 @@
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<BDropdown
|
<BDropdown
|
||||||
ref="dropdown"
|
ref="dropdownElem"
|
||||||
variant="outline-dark"
|
variant="outline-dark"
|
||||||
block
|
block
|
||||||
menu-class="w-100"
|
menu-class="w-100"
|
||||||
|
@ -62,7 +163,7 @@
|
||||||
class="mb-0"
|
class="mb-0"
|
||||||
>
|
>
|
||||||
<BFormInput
|
<BFormInput
|
||||||
ref="search-input"
|
ref="searchElem"
|
||||||
v-model="search"
|
v-model="search"
|
||||||
:id="id + '-search-input'"
|
:id="id + '-search-input'"
|
||||||
type="search"
|
type="search"
|
||||||
|
@ -77,7 +178,7 @@
|
||||||
<BDropdownItemButton
|
<BDropdownItemButton
|
||||||
v-for="option in availableOptions"
|
v-for="option in availableOptions"
|
||||||
:key="option"
|
:key="option"
|
||||||
@click="onAddTag({ option, addTag })"
|
@click="onAddTag(option, addTag)"
|
||||||
>
|
>
|
||||||
{{ option }}
|
{{ option }}
|
||||||
</BDropdownItemButton>
|
</BDropdownItemButton>
|
||||||
|
@ -99,91 +200,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'TagsSelectizeItem',
|
|
||||||
|
|
||||||
inheritAttrs: false,
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: { type: Array, required: true },
|
|
||||||
options: { type: Array, required: true },
|
|
||||||
id: { type: String, required: true },
|
|
||||||
placeholder: { type: String, default: null },
|
|
||||||
limit: { type: Number, default: null },
|
|
||||||
name: { type: String, default: null },
|
|
||||||
itemsName: { type: String, required: true },
|
|
||||||
disabledItems: { type: Array, default: () => [] },
|
|
||||||
// By default `addTag` and `removeTag` have to be executed manually by listening to 'tag-update'.
|
|
||||||
auto: { type: Boolean, default: false },
|
|
||||||
noTags: { type: Boolean, default: false },
|
|
||||||
label: { type: String, default: null },
|
|
||||||
tagIcon: { type: String, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
search: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
criteria() {
|
|
||||||
return this.search.trim().toLowerCase()
|
|
||||||
},
|
|
||||||
|
|
||||||
availableOptions() {
|
|
||||||
const criteria = this.criteria
|
|
||||||
const options = this.options.filter((opt) => {
|
|
||||||
return (
|
|
||||||
this.modelValue.indexOf(opt) === -1 &&
|
|
||||||
!this.disabledItems.includes(opt)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
if (criteria) {
|
|
||||||
return options.filter((opt) => opt.toLowerCase().indexOf(criteria) > -1)
|
|
||||||
}
|
|
||||||
return options
|
|
||||||
},
|
|
||||||
|
|
||||||
searchState() {
|
|
||||||
return this.criteria && this.availableOptions.length === 0 ? false : null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onAddTag({ option, addTag }) {
|
|
||||||
this.$emit('tag-update', { action: 'add', option, applyMethod: addTag })
|
|
||||||
this.search = ''
|
|
||||||
if (this.auto) {
|
|
||||||
addTag(option)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onRemoveTag({ option, removeTag }) {
|
|
||||||
this.$emit('tag-update', {
|
|
||||||
action: 'remove',
|
|
||||||
option,
|
|
||||||
applyMethod: removeTag,
|
|
||||||
})
|
|
||||||
if (this.auto) {
|
|
||||||
removeTag(option)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onDropdownKeydown(e) {
|
|
||||||
// Allow to start searching after dropdown opening
|
|
||||||
if (
|
|
||||||
!['Tab', 'Space'].includes(e.code) &&
|
|
||||||
e.target === this.$refs.dropdown.$el.firstElementChild
|
|
||||||
) {
|
|
||||||
this.$refs['search-input'].focus()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
:deep(.dropdown-menu) {
|
:deep(.dropdown-menu) {
|
||||||
max-height: 300px;
|
max-height: 300px;
|
||||||
|
|
|
@ -1,7 +1,38 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { inject } from 'vue'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
modelValue?: string | null
|
||||||
|
id?: string
|
||||||
|
placeholder?: string
|
||||||
|
type?: string // FIXME unused?
|
||||||
|
required?: boolean
|
||||||
|
state?: boolean | null
|
||||||
|
name?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
modelValue: null,
|
||||||
|
id: undefined,
|
||||||
|
placeholder: undefined,
|
||||||
|
type: 'text',
|
||||||
|
required: false,
|
||||||
|
state: undefined,
|
||||||
|
name: undefined,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: string]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const touch = inject('touch')
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BFormTextarea
|
<BFormTextarea
|
||||||
:modelValue="modelValue"
|
:modelValue="modelValue"
|
||||||
@update:modelValue="$emit('update:modelValue', $event)"
|
@update:modelValue="emit('update:modelValue', $event)"
|
||||||
:id="id"
|
:id="id"
|
||||||
:placeholder="placeholder"
|
:placeholder="placeholder"
|
||||||
:required="required"
|
:required="required"
|
||||||
|
@ -10,27 +41,3 @@
|
||||||
@blur="touch(name)"
|
@blur="touch(name)"
|
||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { inject } from 'vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'TextAreaItem',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
modelValue: { type: String, default: null },
|
|
||||||
id: { type: String, default: null },
|
|
||||||
placeholder: { type: String, default: null },
|
|
||||||
type: { type: String, default: 'text' },
|
|
||||||
required: { type: Boolean, default: false },
|
|
||||||
state: { type: Boolean, default: null },
|
|
||||||
name: { type: String, default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
touch: inject('touch'),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,18 +1,14 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
defineProps<{
|
||||||
|
height: string
|
||||||
|
width: string
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div :style="{ height, width }" class="b-skeleton" />
|
<div :style="{ height, width }" class="b-skeleton" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'BSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
height: { type: String, required: true },
|
|
||||||
width: { type: String, required: true },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.b-skeleton {
|
.b-skeleton {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
withDefaults(defineProps<{ loading?: boolean }>(), { loading: false })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div v-if="loading" class="b-skeleton-wrapper">
|
<div v-if="loading" class="b-skeleton-wrapper">
|
||||||
<slot name="loading" />
|
<slot name="loading" />
|
||||||
|
@ -5,16 +9,6 @@
|
||||||
<slot v-else name="default" />
|
<slot v-else name="default" />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'BSkeletonWrapper',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
loading: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.b-skeleton-wrapper {
|
.b-skeleton-wrapper {
|
||||||
cursor: wait;
|
cursor: wait;
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 4 })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard>
|
<BCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
|
@ -16,17 +22,3 @@
|
||||||
</div>
|
</div>
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CardButtonsSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,19 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
import type { Cols } from '@/types/commons'
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
itemCount?: number
|
||||||
|
cols: Cols
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
itemCount: 5,
|
||||||
|
cols: () => ({ md: 4, lg: 2 }),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard>
|
<BCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
|
@ -42,23 +58,3 @@
|
||||||
</template>
|
</template>
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CardFormSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
cols: {
|
|
||||||
type: [Object, null],
|
|
||||||
default() {
|
|
||||||
return { md: 4, lg: 2 }
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard>
|
<BCard>
|
||||||
<template #header>
|
<template #header>
|
||||||
|
@ -14,17 +20,3 @@
|
||||||
</BRow>
|
</BRow>
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CardInfoSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard no-body>
|
<BCard no-body>
|
||||||
<template #header>
|
<template #header>
|
||||||
|
@ -18,17 +24,3 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'CardListSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
|
||||||
|
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BListGroup>
|
<BListGroup>
|
||||||
<BListGroupItem v-for="count in itemCount" :key="count">
|
<BListGroupItem v-for="count in itemCount" :key="count">
|
||||||
|
@ -6,17 +12,3 @@
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ListGroupSkeleton',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
itemCount: { type: Number, default: 5 },
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: { randint },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
12
app/src/store/utils.ts
Normal file
12
app/src/store/utils.ts
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
export function useStoreGetters() {
|
||||||
|
const store = useStore()
|
||||||
|
return Object.fromEntries(
|
||||||
|
Object.keys(store.getters).map((getter) => [
|
||||||
|
getter,
|
||||||
|
computed(() => store.getters[getter]),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
}
|
25
app/src/types/commons.ts
Normal file
25
app/src/types/commons.ts
Normal file
|
@ -0,0 +1,25 @@
|
||||||
|
import type { Breakpoint } from 'bootstrap-vue-next'
|
||||||
|
import type { RouteLocationNamedRaw } from 'vue-router'
|
||||||
|
|
||||||
|
export type Obj<T = any> = Record<string, T>
|
||||||
|
|
||||||
|
// Vue
|
||||||
|
|
||||||
|
export type VueClass =
|
||||||
|
| string
|
||||||
|
| Record<string, boolean>
|
||||||
|
| (string | Record<string, boolean>)[]
|
||||||
|
|
||||||
|
// BVN (not exported types for now)
|
||||||
|
|
||||||
|
// eslint-disable-next-line prettier/prettier
|
||||||
|
type ColsNumbers = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' | '10' | '11' | '12'
|
||||||
|
export type Cols = Partial<Record<Breakpoint, boolean | ColsNumbers | 'auto'>>
|
||||||
|
|
||||||
|
// CUSTOM
|
||||||
|
|
||||||
|
export type CustomRoute = {
|
||||||
|
to: RouteLocationNamedRaw
|
||||||
|
text: string
|
||||||
|
icon?: string
|
||||||
|
}
|
|
@ -1,3 +1,19 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
const menu = [
|
||||||
|
{ routeName: 'user-list', icon: 'users', translation: 'users' },
|
||||||
|
{ routeName: 'domain-list', icon: 'globe', translation: 'domains' },
|
||||||
|
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' },
|
||||||
|
{ routeName: 'update', icon: 'refresh', translation: 'system_update' },
|
||||||
|
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
|
||||||
|
{
|
||||||
|
routeName: 'diagnosis',
|
||||||
|
icon: 'stethoscope',
|
||||||
|
translation: 'diagnosis',
|
||||||
|
},
|
||||||
|
{ routeName: 'backup', icon: 'archive', translation: 'backup' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="home">
|
<div class="home">
|
||||||
<BListGroup class="menu-list">
|
<BListGroup class="menu-list">
|
||||||
|
@ -14,30 +30,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'HomeView',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
menu: [
|
|
||||||
{ routeName: 'user-list', icon: 'users', translation: 'users' },
|
|
||||||
{ routeName: 'domain-list', icon: 'globe', translation: 'domains' },
|
|
||||||
{ routeName: 'app-list', icon: 'cubes', translation: 'applications' },
|
|
||||||
{ routeName: 'update', icon: 'refresh', translation: 'system_update' },
|
|
||||||
{ routeName: 'tool-list', icon: 'wrench', translation: 'tools' },
|
|
||||||
{
|
|
||||||
routeName: 'diagnosis',
|
|
||||||
icon: 'stethoscope',
|
|
||||||
translation: 'diagnosis',
|
|
||||||
},
|
|
||||||
{ routeName: 'backup', icon: 'archive', translation: 'backup' },
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.home {
|
.home {
|
||||||
margin-top: 2rem;
|
margin-top: 2rem;
|
||||||
|
|
|
@ -1,6 +1,85 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter, type LocationQueryValue } from 'vue-router'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import { alphalownumdot_, minLength, required } from '@/helpers/validators'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
forceReload?: boolean
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
forceReload: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const { installed } = useStoreGetters()
|
||||||
|
const serverError = ref('')
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
})
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
{
|
||||||
|
username: { required, alphalownumdot_ },
|
||||||
|
password: { required, passwordLenght: minLength(4) },
|
||||||
|
},
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
|
||||||
|
console.log(v$.value)
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
username: {
|
||||||
|
label: t('user_username'),
|
||||||
|
props: {
|
||||||
|
id: 'username',
|
||||||
|
autocomplete: 'username',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
password: {
|
||||||
|
label: t('password'),
|
||||||
|
props: {
|
||||||
|
id: 'password',
|
||||||
|
type: 'password',
|
||||||
|
autocomplete: 'current-password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
const credentials = [form.username, form.password].join(':')
|
||||||
|
store
|
||||||
|
.dispatch('LOGIN', credentials)
|
||||||
|
.then(() => {
|
||||||
|
if (props.forceReload) {
|
||||||
|
window.location.href = '/yunohost/admin/'
|
||||||
|
} else {
|
||||||
|
router.push(
|
||||||
|
(router.currentRoute.value.query.redirect as LocationQueryValue) || {
|
||||||
|
name: 'home',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name !== 'APIUnauthorizedError') throw err
|
||||||
|
serverError.value = t('wrong_password_or_username')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardForm
|
<CardForm
|
||||||
:title="$t('login')"
|
:title="t('login')"
|
||||||
icon="lock"
|
icon="lock"
|
||||||
:validation="v$"
|
:validation="v$"
|
||||||
:server-error="serverError"
|
:server-error="serverError"
|
||||||
|
@ -10,14 +89,14 @@
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="fields.username"
|
v-bind="fields.username"
|
||||||
v-model="form.username"
|
v-model="form.username"
|
||||||
:validation="v$.form.username"
|
:validation="v$.username"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- ADMIN PASSWORD -->
|
<!-- ADMIN PASSWORD -->
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="fields.password"
|
v-bind="fields.password"
|
||||||
v-model="form.password"
|
v-model="form.password"
|
||||||
:validation="v$.form.password"
|
:validation="v$.password"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<template #buttons>
|
<template #buttons>
|
||||||
|
@ -27,91 +106,8 @@
|
||||||
:disabled="!installed"
|
:disabled="!installed"
|
||||||
form="ynh-form"
|
form="ynh-form"
|
||||||
>
|
>
|
||||||
{{ $t('login') }}
|
{{ t('login') }}
|
||||||
</BButton>
|
</BButton>
|
||||||
</template>
|
</template>
|
||||||
</CardForm>
|
</CardForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
import { alphalownumdot_, required, minLength } from '@/helpers/validators'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'LoginView',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
forceReload: { type: Boolean, default: false },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
serverError: '',
|
|
||||||
form: {
|
|
||||||
username: '',
|
|
||||||
password: '',
|
|
||||||
},
|
|
||||||
fields: {
|
|
||||||
username: {
|
|
||||||
label: this.$t('user_username'),
|
|
||||||
props: {
|
|
||||||
id: 'username',
|
|
||||||
autocomplete: 'username',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
label: this.$t('password'),
|
|
||||||
props: {
|
|
||||||
id: 'password',
|
|
||||||
type: 'password',
|
|
||||||
autocomplete: 'current-password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['installed']),
|
|
||||||
},
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
username: { required, alphalownumdot_ },
|
|
||||||
password: { required, passwordLenght: minLength(4) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
login() {
|
|
||||||
const credentials = [this.form.username, this.form.password].join(':')
|
|
||||||
this.$store
|
|
||||||
.dispatch('LOGIN', credentials)
|
|
||||||
.then(() => {
|
|
||||||
if (this.forceReload) {
|
|
||||||
window.location.href = '/yunohost/admin/'
|
|
||||||
} else {
|
|
||||||
this.$router.push(
|
|
||||||
this.$router.currentRoute.value.query.redirect || {
|
|
||||||
name: 'home',
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIUnauthorizedError') throw err
|
|
||||||
this.serverError = this.$t('wrong_password_or_username')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,133 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import {
|
||||||
|
alphalownumdot_,
|
||||||
|
minLength,
|
||||||
|
name,
|
||||||
|
required,
|
||||||
|
sameAs,
|
||||||
|
} from '@/helpers/validators'
|
||||||
|
import { formatFormData } from '@/helpers/yunohostArguments'
|
||||||
|
import LoginView from '@/views/LoginView.vue'
|
||||||
|
import { DomainForm } from '@/views/_partials'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const step = ref('start')
|
||||||
|
const serverError = ref('')
|
||||||
|
const domain = ref(undefined)
|
||||||
|
const dyndns_recovery_password = ref('')
|
||||||
|
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
fullname: '',
|
||||||
|
password: '',
|
||||||
|
confirmation: '',
|
||||||
|
})
|
||||||
|
const rules = computed(() => ({
|
||||||
|
username: { required, alphalownumdot_ },
|
||||||
|
fullname: { required, name },
|
||||||
|
password: { required, passwordLenght: minLength(8) },
|
||||||
|
confirmation: { required, passwordMatch: sameAs(form.password) },
|
||||||
|
}))
|
||||||
|
const v$ = useVuelidate(rules, form)
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
username: {
|
||||||
|
label: t('user_username'),
|
||||||
|
props: {
|
||||||
|
id: 'username',
|
||||||
|
placeholder: t('placeholder.username'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fullname: {
|
||||||
|
label: t('user_fullname'),
|
||||||
|
props: {
|
||||||
|
id: 'fullname',
|
||||||
|
placeholder: t('placeholder.fullname'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
password: {
|
||||||
|
label: t('password'),
|
||||||
|
description: t('good_practices_about_admin_password'),
|
||||||
|
descriptionVariant: 'warning',
|
||||||
|
props: { id: 'password', placeholder: '••••••••', type: 'password' },
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmation: {
|
||||||
|
label: t('password_confirmation'),
|
||||||
|
props: {
|
||||||
|
id: 'confirmation',
|
||||||
|
placeholder: '••••••••',
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
function goToStep(step_) {
|
||||||
|
serverError.value = ''
|
||||||
|
step.value = step_
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDomain(data) {
|
||||||
|
domain.value = data.domain
|
||||||
|
dyndns_recovery_password.value = data.dyndns_recovery_password
|
||||||
|
goToStep('user')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setUser() {
|
||||||
|
const confirmed = await modalConfirm(
|
||||||
|
t('confirm_postinstall', { domain: domain.value }),
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
performPostInstall()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performPostInstall(force = false) {
|
||||||
|
// FIXME update formatFormData to unwrap ref auto
|
||||||
|
const data = await formatFormData({
|
||||||
|
domain: domain.value,
|
||||||
|
dyndns_recovery_password: dyndns_recovery_password.value,
|
||||||
|
username: form.username,
|
||||||
|
fullname: form.fullname,
|
||||||
|
password: form.password,
|
||||||
|
})
|
||||||
|
|
||||||
|
// FIXME does the api will throw an error for bad passwords ?
|
||||||
|
api
|
||||||
|
.post('postinstall' + (force ? '?force_diskspace' : ''), data, {
|
||||||
|
key: 'postinstall',
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
// Display success message and allow the user to login
|
||||||
|
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') {
|
||||||
|
step.value = 'rootfsspace-error'
|
||||||
|
} else if (hasWordsInError(['domain', 'dyndns'])) {
|
||||||
|
step.value = 'domain'
|
||||||
|
} else if (hasWordsInError(['password', 'user'])) {
|
||||||
|
step.value = 'user'
|
||||||
|
} else {
|
||||||
|
throw err
|
||||||
|
}
|
||||||
|
serverError.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="post-install">
|
<div class="post-install">
|
||||||
<!-- START STEP -->
|
<!-- START STEP -->
|
||||||
|
@ -51,11 +181,11 @@
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
v-for="(field, name) in fields"
|
v-for="(field, key) in fields"
|
||||||
:key="name"
|
:key="key"
|
||||||
v-bind="field"
|
v-bind="field"
|
||||||
v-model="user[name]"
|
v-model="form[key]"
|
||||||
:validation="v$.user[name]"
|
:validation="v$.form[key]"
|
||||||
/>
|
/>
|
||||||
</CardForm>
|
</CardForm>
|
||||||
|
|
||||||
|
@ -87,152 +217,3 @@
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { DomainForm } from '@/views/_partials'
|
|
||||||
import LoginView from '@/views/LoginView.vue'
|
|
||||||
import { formatFormData } from '@/helpers/yunohostArguments'
|
|
||||||
import {
|
|
||||||
alphalownumdot_,
|
|
||||||
required,
|
|
||||||
minLength,
|
|
||||||
name,
|
|
||||||
sameAs,
|
|
||||||
} from '@/helpers/validators'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'PostInstall',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
DomainForm,
|
|
||||||
LoginView,
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
step: 'start',
|
|
||||||
serverError: '',
|
|
||||||
domain: undefined,
|
|
||||||
dyndns_recovery_password: '',
|
|
||||||
user: {
|
|
||||||
username: '',
|
|
||||||
fullname: '',
|
|
||||||
password: '',
|
|
||||||
confirmation: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
fields: {
|
|
||||||
username: {
|
|
||||||
label: this.$t('user_username'),
|
|
||||||
props: {
|
|
||||||
id: 'username',
|
|
||||||
placeholder: this.$t('placeholder.username'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
fullname: {
|
|
||||||
label: this.$t('user_fullname'),
|
|
||||||
props: {
|
|
||||||
id: 'fullname',
|
|
||||||
placeholder: this.$t('placeholder.fullname'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
password: {
|
|
||||||
label: this.$t('password'),
|
|
||||||
description: this.$t('good_practices_about_admin_password'),
|
|
||||||
descriptionVariant: 'warning',
|
|
||||||
props: { id: 'password', placeholder: '••••••••', type: 'password' },
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmation: {
|
|
||||||
label: this.$t('password_confirmation'),
|
|
||||||
props: {
|
|
||||||
id: 'confirmation',
|
|
||||||
placeholder: '••••••••',
|
|
||||||
type: 'password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
goToStep(step) {
|
|
||||||
this.serverError = ''
|
|
||||||
this.step = step
|
|
||||||
},
|
|
||||||
|
|
||||||
setDomain({ domain, dyndns_recovery_password }) {
|
|
||||||
this.domain = domain
|
|
||||||
this.dyndns_recovery_password = dyndns_recovery_password
|
|
||||||
this.goToStep('user')
|
|
||||||
},
|
|
||||||
|
|
||||||
async setUser() {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_postinstall', { domain: this.domain }),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
this.performPostInstall()
|
|
||||||
},
|
|
||||||
|
|
||||||
async performPostInstall(force = false) {
|
|
||||||
const data = await formatFormData({
|
|
||||||
domain: this.domain,
|
|
||||||
dyndns_recovery_password: this.dyndns_recovery_password,
|
|
||||||
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' : ''), 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 (hasWordsInError(['domain', 'dyndns'])) {
|
|
||||||
this.step = 'domain'
|
|
||||||
} else if (hasWordsInError(['password', 'user'])) {
|
|
||||||
this.step = 'user'
|
|
||||||
} else {
|
|
||||||
throw err
|
|
||||||
}
|
|
||||||
this.serverError = err.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return {
|
|
||||||
user: {
|
|
||||||
username: { required, alphalownumdot_ },
|
|
||||||
fullname: { required, name },
|
|
||||||
password: { required, passwordLenght: minLength(8) },
|
|
||||||
confirmation: { required, passwordMatch: sameAs(this.user.password) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,148 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import AdressInputSelect from '@/components/AdressInputSelect.vue'
|
||||||
|
import {
|
||||||
|
domain,
|
||||||
|
dynDomain,
|
||||||
|
minLength,
|
||||||
|
required,
|
||||||
|
sameAs,
|
||||||
|
} from '@/helpers/validators'
|
||||||
|
import { formatFormData } from '@/helpers/yunohostArguments'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
inheritAttrs: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
title: string
|
||||||
|
submitText?: string | null
|
||||||
|
serverError?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
submitText: null,
|
||||||
|
serverError: '',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
const emit = defineEmits(['submit'])
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const { domains } = useStoreGetters()
|
||||||
|
const dynDomains = ['nohost.me', 'noho.st', 'ynh.fr']
|
||||||
|
|
||||||
|
const dynDnsForbiden = computed(() => {
|
||||||
|
if (!domains.value) return false
|
||||||
|
return domains.value.some((domain) => {
|
||||||
|
return dynDomains.some((dynDomain) => domain.includes(dynDomain))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
const selected = ref(dynDnsForbiden.value ? 'domain' : '')
|
||||||
|
const form = reactive({
|
||||||
|
domain: '',
|
||||||
|
dynDomain: { localPart: '', separator: '.', domain: 'nohost.me' },
|
||||||
|
dynDomainPassword: '',
|
||||||
|
dynDomainPasswordConfirmation: '',
|
||||||
|
localDomain: { localPart: '', separator: '.', domain: 'local' },
|
||||||
|
})
|
||||||
|
|
||||||
|
const rules = computed(() => ({
|
||||||
|
selected: { required },
|
||||||
|
form: ['domain', 'localDomain'].includes(selected.value)
|
||||||
|
? {
|
||||||
|
[selected.value]:
|
||||||
|
selected.value === 'domain'
|
||||||
|
? { required, domain }
|
||||||
|
: { localPart: { required, dynDomain } },
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
dynDomain: { localPart: { required, dynDomain } },
|
||||||
|
dynDomainPassword: { passwordLenght: minLength(8) },
|
||||||
|
dynDomainPasswordConfirmation: {
|
||||||
|
passwordMatch: sameAs(form.dynDomainPassword),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}))
|
||||||
|
const v$ = useVuelidate(rules, { selected, form })
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
domain: {
|
||||||
|
label: t('domain_name'),
|
||||||
|
props: {
|
||||||
|
id: 'domain',
|
||||||
|
placeholder: t('placeholder.domain'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dynDomain: {
|
||||||
|
label: t('domain_name'),
|
||||||
|
props: {
|
||||||
|
id: 'dyn-domain',
|
||||||
|
placeholder: t('placeholder.domain').split('.')[0],
|
||||||
|
type: 'domain',
|
||||||
|
choices: dynDomains,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dynDomainPassword: {
|
||||||
|
label: t('domain.add.dyn_dns_password'),
|
||||||
|
description: t('domain.add.dyn_dns_password_desc'),
|
||||||
|
props: {
|
||||||
|
id: 'dyn-dns-password',
|
||||||
|
placeholder: '••••••••',
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
dynDomainPasswordConfirmation: {
|
||||||
|
label: t('password_confirmation'),
|
||||||
|
props: {
|
||||||
|
id: 'dyn-dns-password-confirmation',
|
||||||
|
placeholder: '••••••••',
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
localDomain: {
|
||||||
|
label: t('domain_name'),
|
||||||
|
props: {
|
||||||
|
id: 'dyn-domain',
|
||||||
|
placeholder: t('placeholder.domain').split('.')[0],
|
||||||
|
type: 'domain',
|
||||||
|
choices: ['local', 'test'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const domainIsVisible = computed(() => {
|
||||||
|
return selected.value === 'domain'
|
||||||
|
})
|
||||||
|
|
||||||
|
const dynDomainIsVisible = computed(() => {
|
||||||
|
return selected.value === 'dynDomain'
|
||||||
|
})
|
||||||
|
|
||||||
|
const localDomainIsVisible = computed(() => {
|
||||||
|
return selected.value === 'localDomain'
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const domainType = selected.value
|
||||||
|
const data = await formatFormData({
|
||||||
|
domain: form[domainType],
|
||||||
|
dyndns_recovery_password:
|
||||||
|
domainType === 'dynDomain' ? form.dynDomainPassword : '',
|
||||||
|
})
|
||||||
|
emit('submit', data)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardForm
|
<CardForm
|
||||||
:title="title"
|
:title="title"
|
||||||
|
@ -32,7 +177,7 @@
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="fields.domain"
|
v-bind="fields.domain"
|
||||||
v-model="form.domain"
|
v-model="form.domain"
|
||||||
:validation="v$.form.domain"
|
:validation="v$.domain"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
/>
|
/>
|
||||||
</BCollapse>
|
</BCollapse>
|
||||||
|
@ -58,7 +203,7 @@
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="fields.dynDomain"
|
v-bind="fields.dynDomain"
|
||||||
:validation="v$.form.dynDomain"
|
:validation="v$.dynDomain"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
>
|
>
|
||||||
<template #default="{ self }">
|
<template #default="{ self }">
|
||||||
|
@ -68,13 +213,13 @@
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="fields.dynDomainPassword"
|
v-bind="fields.dynDomainPassword"
|
||||||
:validation="v$.form.dynDomainPassword"
|
:validation="v$.dynDomainPassword"
|
||||||
v-model="form.dynDomainPassword"
|
v-model="form.dynDomainPassword"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="fields.dynDomainPasswordConfirmation"
|
v-bind="fields.dynDomainPasswordConfirmation"
|
||||||
:validation="v$.form.dynDomainPasswordConfirmation"
|
:validation="v$.dynDomainPasswordConfirmation"
|
||||||
v-model="form.dynDomainPasswordConfirmation"
|
v-model="form.dynDomainPasswordConfirmation"
|
||||||
/>
|
/>
|
||||||
</BCollapse>
|
</BCollapse>
|
||||||
|
@ -103,7 +248,7 @@
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="fields.localDomain"
|
v-bind="fields.localDomain"
|
||||||
:validation="v$.form.localDomain"
|
:validation="v$.localDomain"
|
||||||
class="mt-3"
|
class="mt-3"
|
||||||
>
|
>
|
||||||
<template #default="{ self }">
|
<template #default="{ self }">
|
||||||
|
@ -113,165 +258,3 @@
|
||||||
</BCollapse>
|
</BCollapse>
|
||||||
</CardForm>
|
</CardForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import AdressInputSelect from '@/components/AdressInputSelect.vue'
|
|
||||||
import { formatFormData } from '@/helpers/yunohostArguments'
|
|
||||||
import {
|
|
||||||
required,
|
|
||||||
domain,
|
|
||||||
dynDomain,
|
|
||||||
minLength,
|
|
||||||
sameAs,
|
|
||||||
} from '@/helpers/validators'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DomainForm',
|
|
||||||
|
|
||||||
inheritAttrs: false,
|
|
||||||
|
|
||||||
props: {
|
|
||||||
title: { type: String, required: true },
|
|
||||||
submitText: { type: String, default: null },
|
|
||||||
serverError: { type: String, default: '' },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
selected: '',
|
|
||||||
|
|
||||||
form: {
|
|
||||||
domain: '',
|
|
||||||
dynDomain: { localPart: '', separator: '.', domain: 'nohost.me' },
|
|
||||||
dynDomainPassword: '',
|
|
||||||
dynDomainPasswordConfirmation: '',
|
|
||||||
localDomain: { localPart: '', separator: '.', domain: 'local' },
|
|
||||||
},
|
|
||||||
|
|
||||||
fields: {
|
|
||||||
domain: {
|
|
||||||
label: this.$t('domain_name'),
|
|
||||||
props: {
|
|
||||||
id: 'domain',
|
|
||||||
placeholder: this.$t('placeholder.domain'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
dynDomain: {
|
|
||||||
label: this.$t('domain_name'),
|
|
||||||
props: {
|
|
||||||
id: 'dyn-domain',
|
|
||||||
placeholder: this.$t('placeholder.domain').split('.')[0],
|
|
||||||
type: 'domain',
|
|
||||||
choices: ['nohost.me', 'noho.st', 'ynh.fr'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
dynDomainPassword: {
|
|
||||||
label: this.$t('domain.add.dyn_dns_password'),
|
|
||||||
description: this.$t('domain.add.dyn_dns_password_desc'),
|
|
||||||
props: {
|
|
||||||
id: 'dyn-dns-password',
|
|
||||||
placeholder: '••••••••',
|
|
||||||
type: 'password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
dynDomainPasswordConfirmation: {
|
|
||||||
label: this.$t('password_confirmation'),
|
|
||||||
props: {
|
|
||||||
id: 'dyn-dns-password-confirmation',
|
|
||||||
placeholder: '••••••••',
|
|
||||||
type: 'password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
localDomain: {
|
|
||||||
label: this.$t('domain_name'),
|
|
||||||
props: {
|
|
||||||
id: 'dyn-domain',
|
|
||||||
placeholder: this.$t('placeholder.domain').split('.')[0],
|
|
||||||
type: 'domain',
|
|
||||||
choices: ['local', 'test'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['domains']),
|
|
||||||
|
|
||||||
dynDnsForbiden() {
|
|
||||||
if (!this.domains) return false
|
|
||||||
const dynDomains = this.fields.dynDomain.props.choices
|
|
||||||
return this.domains.some((domain) => {
|
|
||||||
return dynDomains.some((dynDomain) => domain.includes(dynDomain))
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
domainIsVisible() {
|
|
||||||
return this.selected === 'domain'
|
|
||||||
},
|
|
||||||
|
|
||||||
dynDomainIsVisible() {
|
|
||||||
return this.selected === 'dynDomain'
|
|
||||||
},
|
|
||||||
|
|
||||||
localDomainIsVisible() {
|
|
||||||
return this.selected === 'localDomain'
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return {
|
|
||||||
selected: { required },
|
|
||||||
form: ['domain', 'localDomain'].includes(this.selected)
|
|
||||||
? {
|
|
||||||
[this.selected]:
|
|
||||||
this.selected === 'domain'
|
|
||||||
? { required, domain }
|
|
||||||
: { localPart: { required, dynDomain } },
|
|
||||||
}
|
|
||||||
: {
|
|
||||||
dynDomain: { localPart: { required, dynDomain } },
|
|
||||||
dynDomainPassword: { passwordLenght: minLength(8) },
|
|
||||||
dynDomainPasswordConfirmation: {
|
|
||||||
passwordMatch: sameAs('dynDomainPassword'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async onSubmit() {
|
|
||||||
const domainType = this.selected
|
|
||||||
const form = await formatFormData({
|
|
||||||
domain: this.form[domainType],
|
|
||||||
dyndns_recovery_password:
|
|
||||||
domainType === 'dynDomain' ? this.form.dynDomainPassword : '',
|
|
||||||
})
|
|
||||||
this.$emit('submit', form)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
if (this.dynDnsForbiden) {
|
|
||||||
this.selected = 'domain'
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
components: {
|
|
||||||
AdressInputSelect,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,34 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import MessageListGroup from '@/components/MessageListGroup.vue'
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
request: Obj
|
||||||
|
}>(),
|
||||||
|
{},
|
||||||
|
)
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const error = computed(() => {
|
||||||
|
return props.request.error
|
||||||
|
})
|
||||||
|
|
||||||
|
const messages = computed(() => {
|
||||||
|
const messages = props.request.messages
|
||||||
|
if (messages && messages.length > 0) return messages
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
store.dispatch('DISMISS_ERROR', props.request)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
||||||
<div>
|
<div>
|
||||||
|
@ -23,7 +54,7 @@
|
||||||
|
|
||||||
<p>
|
<p>
|
||||||
<strong v-t="'api_error.error_message'" />
|
<strong v-t="'api_error.error_message'" />
|
||||||
<BAlert :modelValue="true" class="mt-2" variant="danger">
|
<BAlert :model-value="true" class="mt-2" variant="danger">
|
||||||
<div v-html="error.message" />
|
<div v-html="error.message" />
|
||||||
</BAlert>
|
</BAlert>
|
||||||
</p>
|
</p>
|
||||||
|
@ -50,40 +81,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import MessageListGroup from '@/components/MessageListGroup.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ErrorDisplay',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
MessageListGroup,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
request: { type: [Object, null], default: null },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
error() {
|
|
||||||
return this.request.error
|
|
||||||
},
|
|
||||||
|
|
||||||
messages() {
|
|
||||||
const messages = this.request.messages
|
|
||||||
if (messages && messages.length > 0) return messages
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
dismiss() {
|
|
||||||
this.$store.dispatch('DISMISS_ERROR', this.request)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
code,
|
code,
|
||||||
pre code {
|
pre code {
|
||||||
|
|
|
@ -1,5 +1,122 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { BCard } from 'bootstrap-vue-next'
|
||||||
|
import { getCurrentInstance, nextTick, ref } from 'vue'
|
||||||
|
|
||||||
|
import MessageListGroup from '@/components/MessageListGroup.vue'
|
||||||
|
import QueryHeader from '@/components/QueryHeader.vue'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
// FIXME prop `value` not used?
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
value?: boolean
|
||||||
|
height?: number | string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
value: false,
|
||||||
|
height: 30,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { history, lastAction, waiting, error } = useStoreGetters()
|
||||||
|
const rootElem = ref<InstanceType<typeof BCard> | null>(null)
|
||||||
|
const historyElem = ref<HTMLElement | null>(null)
|
||||||
|
const open = ref(false)
|
||||||
|
|
||||||
|
function scrollToAction(actionIndex: number) {
|
||||||
|
const actionCard = rootElem.value!.$el.querySelector(
|
||||||
|
'#messages-collapse-' + actionIndex,
|
||||||
|
).parentElement
|
||||||
|
const headerOffset = actionCard.firstElementChild.offsetHeight
|
||||||
|
// Can't use `scrollIntoView()` here since it will also scroll in the main content.
|
||||||
|
historyElem.value!.scrollTop = actionCard.offsetTop - headerOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onLastActionClick() {
|
||||||
|
if (!open.value) {
|
||||||
|
open.value = true
|
||||||
|
await nextTick()
|
||||||
|
}
|
||||||
|
const hElem = historyElem.value!
|
||||||
|
const lastActionCard = hElem.lastElementChild as HTMLElement
|
||||||
|
const lastCollapsable = lastActionCard.querySelector('.collapse')
|
||||||
|
|
||||||
|
if (lastCollapsable && !lastCollapsable.classList.contains('show')) {
|
||||||
|
// FIXME not sure root emits still work with bvn
|
||||||
|
const { emit: rootEmit } = getCurrentInstance()!
|
||||||
|
rootEmit('bv::toggle::collapse', lastCollapsable.id)
|
||||||
|
// `scrollToAction` will be triggered and will handle the scrolling.
|
||||||
|
} else {
|
||||||
|
const headerElem = lastActionCard.firstElementChild as HTMLElement
|
||||||
|
hElem.scrollTop = lastActionCard.offsetTop - headerElem.offsetHeight
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistoryBarKey(e: KeyboardEvent) {
|
||||||
|
// FIXME interactive element in another is not valid, need to find another way.
|
||||||
|
const { nodeName, parentElement } = e.target as HTMLElement
|
||||||
|
if (nodeName === 'BUTTON' || parentElement?.nodeName === 'BUTTON') return
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
|
||||||
|
function onHistoryBarClick(e: MouseEvent) {
|
||||||
|
// FIXME interactive element in another is not valid, need to find another way.
|
||||||
|
const { nodeName, parentElement } = e.target as HTMLElement
|
||||||
|
if (nodeName === 'BUTTON' || parentElement?.nodeName === 'BUTTON') return
|
||||||
|
|
||||||
|
const hElem = historyElem.value!
|
||||||
|
let mousePos = e.clientY
|
||||||
|
|
||||||
|
const onMouseMove = ({ clientY }: MouseEvent) => {
|
||||||
|
if (!open.value) {
|
||||||
|
hElem.style.height = '0px'
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
|
const currentHeight = hElem.offsetHeight
|
||||||
|
const move = mousePos - clientY
|
||||||
|
const nextSize = currentHeight + move
|
||||||
|
if (nextSize < 10 && nextSize < currentHeight) {
|
||||||
|
// Close the console and reset its size if the user reduce it to less than 10px.
|
||||||
|
mousePos = e.clientY
|
||||||
|
hElem.style.height = ''
|
||||||
|
onMouseUp()
|
||||||
|
} else {
|
||||||
|
hElem.style.height = nextSize + 'px'
|
||||||
|
// Simulate scroll when reducing the box so the content doesn't move.
|
||||||
|
if (nextSize < currentHeight) {
|
||||||
|
hElem.scrollBy(0, -move)
|
||||||
|
}
|
||||||
|
mousePos = clientY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delay the mouse move listener to distinguish a click from a drag.
|
||||||
|
const listenToMouseMove = setTimeout(() => {
|
||||||
|
hElem.style.height = hElem.offsetHeight + 'px'
|
||||||
|
hElem.classList.add('no-max')
|
||||||
|
window.addEventListener('mousemove', onMouseMove)
|
||||||
|
}, 200)
|
||||||
|
|
||||||
|
const onMouseUp = () => {
|
||||||
|
// Toggle opening if no mouse movement.
|
||||||
|
if (mousePos === e.clientY) {
|
||||||
|
// remove the free height class if the box's height is not custom
|
||||||
|
if (!hElem.style.height) {
|
||||||
|
hElem.classList.remove('no-max')
|
||||||
|
}
|
||||||
|
open.value = !open.value
|
||||||
|
}
|
||||||
|
clearTimeout(listenToMouseMove)
|
||||||
|
window.removeEventListener('mousemove', onMouseMove)
|
||||||
|
window.removeEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('mouseup', onMouseUp)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BCard no-body id="console">
|
<BCard id="console" ref="rootElem" no-body>
|
||||||
<!-- HISTORY BAR -->
|
<!-- HISTORY BAR -->
|
||||||
<BCardHeader
|
<BCardHeader
|
||||||
role="button"
|
role="button"
|
||||||
|
@ -40,7 +157,7 @@
|
||||||
</BCardHeader>
|
</BCardHeader>
|
||||||
|
|
||||||
<BCollapse id="console-collapse" v-model="open">
|
<BCollapse id="console-collapse" v-model="open">
|
||||||
<div class="accordion" role="tablist" id="history" ref="history">
|
<div class="accordion" role="tablist" id="history" ref="historyElem">
|
||||||
<p v-if="history.length === 0" class="alert m-0 px-2 py-1">
|
<p v-if="history.length === 0" class="alert m-0 px-2 py-1">
|
||||||
{{ $t('history.is_empty') }}
|
{{ $t('history.is_empty') }}
|
||||||
</p>
|
</p>
|
||||||
|
@ -62,7 +179,7 @@
|
||||||
<QueryHeader
|
<QueryHeader
|
||||||
role="tab"
|
role="tab"
|
||||||
v-b-toggle="
|
v-b-toggle="
|
||||||
action.messages.length ? 'messages-collapse-' + i : false
|
action.messages.length ? 'messages-collapse-' + i : undefined
|
||||||
"
|
"
|
||||||
:request="action"
|
:request="action"
|
||||||
show-time
|
show-time
|
||||||
|
@ -87,134 +204,6 @@
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import QueryHeader from '@/components/QueryHeader.vue'
|
|
||||||
import MessageListGroup from '@/components/MessageListGroup.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'HistoryConsole',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
QueryHeader,
|
|
||||||
MessageListGroup,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
value: { type: Boolean, default: false },
|
|
||||||
height: { type: [Number, String], default: 30 },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
open: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['history', 'lastAction', 'waiting', 'error']),
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
scrollToAction(actionIndex) {
|
|
||||||
const actionCard = this.$el.querySelector(
|
|
||||||
'#messages-collapse-' + actionIndex,
|
|
||||||
).parentElement
|
|
||||||
const headerOffset = actionCard.firstElementChild.offsetHeight
|
|
||||||
// Can't use `scrollIntoView()` here since it will also scroll in the main content.
|
|
||||||
this.$refs.history.scrollTop = actionCard.offsetTop - headerOffset
|
|
||||||
},
|
|
||||||
|
|
||||||
async onLastActionClick() {
|
|
||||||
if (!this.open) {
|
|
||||||
this.open = true
|
|
||||||
await this.$nextTick()
|
|
||||||
}
|
|
||||||
const historyElem = this.$refs.history
|
|
||||||
const lastActionCard = historyElem.lastElementChild
|
|
||||||
const lastCollapsable = lastActionCard.querySelector('.collapse')
|
|
||||||
|
|
||||||
if (lastCollapsable && !lastCollapsable.classList.contains('show')) {
|
|
||||||
this.$root.$emit('bv::toggle::collapse', lastCollapsable.id)
|
|
||||||
// `scrollToAction` will be triggered and will handle the scrolling.
|
|
||||||
} else {
|
|
||||||
const headerOffset = lastActionCard.firstElementChild.offsetHeight
|
|
||||||
historyElem.scrollTop = lastActionCard.offsetTop - headerOffset
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
onHistoryBarKey(e) {
|
|
||||||
// FIXME interactive element in another is not valid, need to find another way.
|
|
||||||
if (
|
|
||||||
e.target.nodeName === 'BUTTON' ||
|
|
||||||
e.target.parentElement.nodeName === 'BUTTON'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
this.open = !this.open
|
|
||||||
},
|
|
||||||
|
|
||||||
onHistoryBarClick(e) {
|
|
||||||
// FIXME interactive element in another is not valid, need to find another way.
|
|
||||||
if (
|
|
||||||
e.target.nodeName === 'BUTTON' ||
|
|
||||||
e.target.parentElement.nodeName === 'BUTTON'
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
const historyElem = this.$refs.history
|
|
||||||
let mousePos = e.clientY
|
|
||||||
|
|
||||||
const onMouseMove = ({ clientY }) => {
|
|
||||||
if (!this.open) {
|
|
||||||
historyElem.style.height = '0px'
|
|
||||||
this.open = true
|
|
||||||
}
|
|
||||||
const currentHeight = historyElem.offsetHeight
|
|
||||||
const move = mousePos - clientY
|
|
||||||
const nextSize = currentHeight + move
|
|
||||||
if (nextSize < 10 && nextSize < currentHeight) {
|
|
||||||
// Close the console and reset its size if the user reduce it to less than 10px.
|
|
||||||
mousePos = e.clientY
|
|
||||||
historyElem.style.height = ''
|
|
||||||
onMouseUp()
|
|
||||||
} else {
|
|
||||||
historyElem.style.height = nextSize + 'px'
|
|
||||||
// Simulate scroll when reducing the box so the content doesn't move.
|
|
||||||
if (nextSize < currentHeight) {
|
|
||||||
historyElem.scrollBy(0, -move)
|
|
||||||
}
|
|
||||||
mousePos = clientY
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delay the mouse move listener to distinguish a click from a drag.
|
|
||||||
const listenToMouseMove = setTimeout(() => {
|
|
||||||
historyElem.style.height = historyElem.offsetHeight + 'px'
|
|
||||||
historyElem.classList.add('no-max')
|
|
||||||
window.addEventListener('mousemove', onMouseMove)
|
|
||||||
}, 200)
|
|
||||||
|
|
||||||
const onMouseUp = () => {
|
|
||||||
// Toggle opening if no mouse movement.
|
|
||||||
if (mousePos === e.clientY) {
|
|
||||||
// remove the free height class if the box's height is not custom
|
|
||||||
if (!historyElem.style.height) {
|
|
||||||
historyElem.classList.remove('no-max')
|
|
||||||
}
|
|
||||||
this.open = !this.open
|
|
||||||
}
|
|
||||||
clearTimeout(listenToMouseMove)
|
|
||||||
window.removeEventListener('mousemove', onMouseMove)
|
|
||||||
window.removeEventListener('mouseup', onMouseUp)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('mouseup', onMouseUp)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
// reset default style
|
// reset default style
|
||||||
.card + .card {
|
.card + .card {
|
||||||
|
|
|
@ -1,3 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
import LoginView from '@/views/LoginView.vue'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const { reconnecting } = useStoreGetters()
|
||||||
|
const status = ref('reconnecting')
|
||||||
|
const origin = ref(reconnecting.value.origin || 'unknown')
|
||||||
|
|
||||||
|
function tryToReconnect(initialDelay = 0) {
|
||||||
|
status.value = 'reconnecting'
|
||||||
|
api
|
||||||
|
.tryToReconnect({ ...reconnecting.value, initialDelay })
|
||||||
|
.then(() => {
|
||||||
|
store.commit('SET_RECONNECTING', null)
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name === 'APIUnauthorizedError') {
|
||||||
|
status.value = 'expired'
|
||||||
|
} else {
|
||||||
|
status.value = 'failed'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
tryToReconnect(reconnecting.value.initialDelay)
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
||||||
<BCardBody>
|
<BCardBody>
|
||||||
|
@ -39,52 +72,3 @@
|
||||||
</template>
|
</template>
|
||||||
</BCardBody>
|
</BCardBody>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
import LoginView from '@/views/LoginView.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ReconnectingDisplay',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
LoginView,
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
status: 'reconnecting',
|
|
||||||
origin: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['reconnecting']),
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
tryToReconnect(initialDelay = 0) {
|
|
||||||
this.status = 'reconnecting'
|
|
||||||
api
|
|
||||||
.tryToReconnect({ ...this.reconnecting, initialDelay })
|
|
||||||
.then(() => {
|
|
||||||
this.$store.commit('SET_RECONNECTING', null)
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name === 'APIUnauthorizedError') {
|
|
||||||
this.status = 'expired'
|
|
||||||
} else {
|
|
||||||
this.status = 'failed'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
this.origin = this.reconnecting.origin || 'unknown'
|
|
||||||
this.tryToReconnect(this.reconnecting.initialDelay)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,31 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import QueryHeader from '@/components/QueryHeader.vue'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
import {
|
||||||
|
ErrorDisplay,
|
||||||
|
ReconnectingDisplay,
|
||||||
|
WaitingDisplay,
|
||||||
|
WarningDisplay,
|
||||||
|
} from '@/views/_partials'
|
||||||
|
|
||||||
|
const { waiting, reconnecting, error, currentRequest, dark } = useStoreGetters()
|
||||||
|
const component = computed(() => {
|
||||||
|
const request = currentRequest.value
|
||||||
|
// FIXME should we pass refs or unwrap refs as props?
|
||||||
|
if (error.value) {
|
||||||
|
return { is: ErrorDisplay, request: error.value }
|
||||||
|
} else if (request.showWarningMessage) {
|
||||||
|
return { is: WarningDisplay, request: currentRequest.value }
|
||||||
|
} else if (reconnecting.value) {
|
||||||
|
return { is: ReconnectingDisplay }
|
||||||
|
} else {
|
||||||
|
return { is: WaitingDisplay, request: currentRequest.value }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<BOverlay
|
<BOverlay
|
||||||
:variant="dark ? 'dark' : 'light'"
|
:variant="dark ? 'dark' : 'light'"
|
||||||
|
@ -13,59 +41,12 @@
|
||||||
<QueryHeader :request="error || currentRequest" status-size="lg" />
|
<QueryHeader :request="error || currentRequest" status-size="lg" />
|
||||||
</BCardHeader>
|
</BCardHeader>
|
||||||
|
|
||||||
<Component :is="component.name" :request="component.request" />
|
<Component :is="component.is" :request="component.request" />
|
||||||
</BCard>
|
</BCard>
|
||||||
</template>
|
</template>
|
||||||
</BOverlay>
|
</BOverlay>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
import {
|
|
||||||
ErrorDisplay,
|
|
||||||
WarningDisplay,
|
|
||||||
WaitingDisplay,
|
|
||||||
ReconnectingDisplay,
|
|
||||||
} from '@/views/_partials'
|
|
||||||
import QueryHeader from '@/components/QueryHeader.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ViewLockOverlay',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
ErrorDisplay,
|
|
||||||
WarningDisplay,
|
|
||||||
WaitingDisplay,
|
|
||||||
ReconnectingDisplay,
|
|
||||||
QueryHeader,
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters([
|
|
||||||
'waiting',
|
|
||||||
'reconnecting',
|
|
||||||
'error',
|
|
||||||
'currentRequest',
|
|
||||||
'dark',
|
|
||||||
]),
|
|
||||||
|
|
||||||
component() {
|
|
||||||
const { error, reconnecting, currentRequest: request } = this
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
return { name: 'ErrorDisplay', request: error }
|
|
||||||
} else if (request.showWarningMessage) {
|
|
||||||
return { name: 'WarningDisplay', request }
|
|
||||||
} else if (reconnecting) {
|
|
||||||
return { name: 'ReconnectingDisplay' }
|
|
||||||
} else {
|
|
||||||
return { name: 'WaitingDisplay', request }
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
// Style for `*Display`'s cards
|
// Style for `*Display`'s cards
|
||||||
.card-overlay {
|
.card-overlay {
|
||||||
|
|
|
@ -1,3 +1,27 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
import MessageListGroup from '@/components/MessageListGroup.vue'
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
request: Obj
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const hasMessages = computed(() => {
|
||||||
|
return props.request.messages && props.request.messages.length > 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const progress = computed(() => {
|
||||||
|
const progress = props.request.progress
|
||||||
|
if (!progress) return null
|
||||||
|
return {
|
||||||
|
values: progress,
|
||||||
|
max: progress.reduce((sum, value) => sum + value, 0),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
||||||
<BCardBody>
|
<BCardBody>
|
||||||
|
@ -25,34 +49,3 @@
|
||||||
/>
|
/>
|
||||||
</BCardBody>
|
</BCardBody>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import MessageListGroup from '@/components/MessageListGroup.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'WaitingDisplay',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
MessageListGroup,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
request: { type: Object, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
hasMessages() {
|
|
||||||
return this.request.messages && this.request.messages.length > 0
|
|
||||||
},
|
|
||||||
|
|
||||||
progress() {
|
|
||||||
const progress = this.request.progress
|
|
||||||
if (!progress) return null
|
|
||||||
return {
|
|
||||||
values: progress,
|
|
||||||
max: progress.reduce((sum, value) => sum + value, 0),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,24 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
request: Obj
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const warning = computed(() => {
|
||||||
|
const messages = props.request.messages
|
||||||
|
return messages[messages.length - 1]
|
||||||
|
})
|
||||||
|
|
||||||
|
function dismiss() {
|
||||||
|
store.dispatch('DISMISS_WARNING', props.request)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
|
||||||
<div>
|
<div>
|
||||||
|
@ -11,29 +32,6 @@
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'WarningDisplay',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
request: { type: Object, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
warning() {
|
|
||||||
const messages = this.request.messages
|
|
||||||
return messages[messages.length - 1]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
dismiss() {
|
|
||||||
this.$store.dispatch('DISMISS_WARNING', this.request)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.card-body {
|
.card-body {
|
||||||
padding-bottom: 1.5rem !important;
|
padding-bottom: 1.5rem !important;
|
||||||
|
|
|
@ -1,3 +1,183 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import CardDeckFeed from '@/components/CardDeckFeed.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { randint } from '@/helpers/commons'
|
||||||
|
import { appRepoUrl, required } from '@/helpers/validators'
|
||||||
|
|
||||||
|
const props = withDefaults(
|
||||||
|
defineProps<{
|
||||||
|
search?: string
|
||||||
|
quality?: string
|
||||||
|
category?: string | null
|
||||||
|
subtag?: string
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
search: '',
|
||||||
|
quality: 'decent_quality',
|
||||||
|
category: null,
|
||||||
|
subtag: 'all',
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const queries = [['GET', 'apps/catalog?full&with_categories&with_antifeatures']]
|
||||||
|
|
||||||
|
const apps = ref()
|
||||||
|
const selectedApp = ref()
|
||||||
|
const antifeatures = ref()
|
||||||
|
const url = ref()
|
||||||
|
const v$ = useVuelidate({ url: { required, appRepoUrl } }, { url })
|
||||||
|
|
||||||
|
const qualityOptions = [
|
||||||
|
{ value: 'high_quality', text: t('only_highquality_apps') },
|
||||||
|
{
|
||||||
|
value: 'decent_quality',
|
||||||
|
text: t('only_decent_quality_apps'),
|
||||||
|
},
|
||||||
|
{ value: 'working', text: t('only_working_apps') },
|
||||||
|
{ value: 'all', text: t('all_apps') },
|
||||||
|
]
|
||||||
|
const categories = reactive([
|
||||||
|
{ text: t('app_choose_category'), value: null },
|
||||||
|
{ text: t('all_apps'), value: 'all', icon: 'search' },
|
||||||
|
// The rest is filled from api data
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredApps = computed(() => {
|
||||||
|
if (!apps.value || props.category === null) return
|
||||||
|
const search = props.search.toLowerCase()
|
||||||
|
|
||||||
|
if (props.quality === 'all' && props.category === 'all' && search === '') {
|
||||||
|
return apps.value
|
||||||
|
}
|
||||||
|
const filtered = apps.value.filter((app) => {
|
||||||
|
// app doesn't match quality filter
|
||||||
|
if (props.quality !== 'all' && !app[props.quality]) return false
|
||||||
|
// app doesn't match category filter
|
||||||
|
if (props.category !== 'all' && app.category !== props.category)
|
||||||
|
return false
|
||||||
|
if (props.subtag !== 'all') {
|
||||||
|
const appMatchSubtag =
|
||||||
|
props.subtag === 'others'
|
||||||
|
? app.subtags.length === 0
|
||||||
|
: app.subtags.includes(props.subtag)
|
||||||
|
// app doesn't match subtag filter
|
||||||
|
if (!appMatchSubtag) return false
|
||||||
|
}
|
||||||
|
if (search === '') return true
|
||||||
|
if (app.searchValues.includes(search)) return true
|
||||||
|
return false
|
||||||
|
})
|
||||||
|
return filtered.length ? filtered : null
|
||||||
|
})
|
||||||
|
|
||||||
|
const subtags = computed(() => {
|
||||||
|
// build an options array for subtags v-model/options
|
||||||
|
if (props.category && categories.length > 2) {
|
||||||
|
const category = categories.find((cat) => cat.value === props.category)
|
||||||
|
if (category.subtags) {
|
||||||
|
const subtags = [{ text: t('all'), value: 'all' }]
|
||||||
|
category.subtags.forEach((subtag) => {
|
||||||
|
subtags.push({ text: subtag.title, value: subtag.id })
|
||||||
|
})
|
||||||
|
subtags.push({ text: t('others'), value: 'others' })
|
||||||
|
return subtags
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
})
|
||||||
|
|
||||||
|
function onQueriesResponse(data) {
|
||||||
|
const apps = []
|
||||||
|
for (const key in data.apps) {
|
||||||
|
const app = data.apps[key]
|
||||||
|
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.searchValues = [
|
||||||
|
app.id,
|
||||||
|
app.state,
|
||||||
|
app.manifest.name,
|
||||||
|
app.manifest.description,
|
||||||
|
app.potential_alternative_to.join(' '),
|
||||||
|
]
|
||||||
|
.join(' ')
|
||||||
|
.toLowerCase()
|
||||||
|
apps.push(app)
|
||||||
|
}
|
||||||
|
apps.value = apps.sort((a, b) => (a.id > b.id ? 1 : -1))
|
||||||
|
|
||||||
|
// CATEGORIES
|
||||||
|
data.categories.forEach(({ title, id, icon, subtags, description }) => {
|
||||||
|
categories.push({
|
||||||
|
text: title,
|
||||||
|
value: id,
|
||||||
|
icon,
|
||||||
|
subtags,
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
antifeatures.value = Object.fromEntries(
|
||||||
|
data.antifeatures.map((af) => [af.id, af]),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateQuery(key, value) {
|
||||||
|
// Update the query string without reloading the page
|
||||||
|
router.replace({
|
||||||
|
query: {
|
||||||
|
...route.query,
|
||||||
|
// allow search without selecting a category
|
||||||
|
category: route.query.category || 'all',
|
||||||
|
[key]: value,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSTALL APP
|
||||||
|
async function onInstallClick(appId: string) {
|
||||||
|
const app = apps.value.find((app) => app.id === appId)
|
||||||
|
if (!app.decent_quality) {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_install_app_' + app.state))
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
router.push({ name: 'app-install', params: { id: app.id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
// INSTALL CUSTOM APP
|
||||||
|
async function onCustomInstallClick() {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_install_custom_app'))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
const url_ = url.value
|
||||||
|
router.push({
|
||||||
|
name: 'app-install-custom',
|
||||||
|
params: { id: url_.endsWith('/') ? url_ : url_ + '/' },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch
|
||||||
:items="apps"
|
:items="apps"
|
||||||
|
@ -224,219 +404,6 @@
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import CardDeckFeed from '@/components/CardDeckFeed.vue'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { required, appRepoUrl } from '@/helpers/validators'
|
|
||||||
import { randint } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'AppCatalog',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
CardDeckFeed,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
search: { type: String, default: '' },
|
|
||||||
quality: { type: String, default: 'decent_quality' },
|
|
||||||
category: { type: String, default: null },
|
|
||||||
subtag: { type: String, default: 'all' },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', 'apps/catalog?full&with_categories&with_antifeatures']],
|
|
||||||
|
|
||||||
// Data
|
|
||||||
apps: undefined,
|
|
||||||
selectedApp: undefined,
|
|
||||||
antifeatures: undefined,
|
|
||||||
|
|
||||||
// Filtering options
|
|
||||||
qualityOptions: [
|
|
||||||
{ value: 'high_quality', text: this.$t('only_highquality_apps') },
|
|
||||||
{
|
|
||||||
value: 'decent_quality',
|
|
||||||
text: this.$t('only_decent_quality_apps'),
|
|
||||||
},
|
|
||||||
{ value: 'working', text: this.$t('only_working_apps') },
|
|
||||||
{ value: 'all', text: this.$t('all_apps') },
|
|
||||||
],
|
|
||||||
categories: [
|
|
||||||
{ text: this.$t('app_choose_category'), value: null },
|
|
||||||
{ text: this.$t('all_apps'), value: 'all', icon: 'search' },
|
|
||||||
// The rest is filled from api data
|
|
||||||
],
|
|
||||||
|
|
||||||
// Custom install form
|
|
||||||
customInstall: {
|
|
||||||
field: {
|
|
||||||
label: this.$t('url'),
|
|
||||||
props: {
|
|
||||||
id: 'custom-install',
|
|
||||||
placeholder: 'https://some.git.forge.tld/USER/REPOSITORY',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
url: '',
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
filteredApps() {
|
|
||||||
if (!this.apps || this.category === null) return
|
|
||||||
const search = this.search.toLowerCase()
|
|
||||||
|
|
||||||
if (this.quality === 'all' && this.category === 'all' && search === '') {
|
|
||||||
return this.apps
|
|
||||||
}
|
|
||||||
const filtered = this.apps.filter((app) => {
|
|
||||||
// app doesn't match quality filter
|
|
||||||
if (this.quality !== 'all' && !app[this.quality]) return false
|
|
||||||
// app doesn't match category filter
|
|
||||||
if (this.category !== 'all' && app.category !== this.category)
|
|
||||||
return false
|
|
||||||
if (this.subtag !== 'all') {
|
|
||||||
const appMatchSubtag =
|
|
||||||
this.subtag === 'others'
|
|
||||||
? app.subtags.length === 0
|
|
||||||
: app.subtags.includes(this.subtag)
|
|
||||||
// app doesn't match subtag filter
|
|
||||||
if (!appMatchSubtag) return false
|
|
||||||
}
|
|
||||||
if (search === '') return true
|
|
||||||
if (app.searchValues.includes(search)) return true
|
|
||||||
return false
|
|
||||||
})
|
|
||||||
return filtered.length ? filtered : null
|
|
||||||
},
|
|
||||||
|
|
||||||
subtags() {
|
|
||||||
// build an options array for subtags v-model/options
|
|
||||||
if (this.category && this.categories.length > 2) {
|
|
||||||
const category = this.categories.find(
|
|
||||||
(cat) => cat.value === this.category,
|
|
||||||
)
|
|
||||||
if (category.subtags) {
|
|
||||||
const subtags = [{ text: this.$t('all'), value: 'all' }]
|
|
||||||
category.subtags.forEach((subtag) => {
|
|
||||||
subtags.push({ text: subtag.title, value: subtag.id })
|
|
||||||
})
|
|
||||||
subtags.push({ text: this.$t('others'), value: 'others' })
|
|
||||||
return subtags
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
validations: {
|
|
||||||
customInstall: {
|
|
||||||
url: { required, appRepoUrl },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(data) {
|
|
||||||
const apps = []
|
|
||||||
for (const key in data.apps) {
|
|
||||||
const app = data.apps[key]
|
|
||||||
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.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))
|
|
||||||
|
|
||||||
// CATEGORIES
|
|
||||||
data.categories.forEach(({ title, id, icon, subtags, description }) => {
|
|
||||||
this.categories.push({
|
|
||||||
text: title,
|
|
||||||
value: id,
|
|
||||||
icon,
|
|
||||||
subtags,
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
this.antifeatures = Object.fromEntries(
|
|
||||||
data.antifeatures.map((af) => [af.id, af]),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
updateQuery(key, value) {
|
|
||||||
// Update the query string without reloading the page
|
|
||||||
this.$router.replace({
|
|
||||||
query: {
|
|
||||||
...this.$route.query,
|
|
||||||
// allow search without selecting a category
|
|
||||||
category: this.$route.query.category || 'all',
|
|
||||||
[key]: value,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
// INSTALL APP
|
|
||||||
async onInstallClick(appId) {
|
|
||||||
const app = this.apps.find((app) => app.id === appId)
|
|
||||||
if (!app.decent_quality) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_install_app_' + app.state),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
this.$router.push({ name: 'app-install', params: { id: app.id } })
|
|
||||||
},
|
|
||||||
|
|
||||||
// INSTALL CUSTOM APP
|
|
||||||
async onCustomInstallClick() {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_install_custom_app'),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
const url = this.customInstall.url
|
|
||||||
this.$router.push({
|
|
||||||
name: 'app-install-custom',
|
|
||||||
params: { id: url.endsWith('/') ? url : url + '/' },
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
randint,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
#view-top-bar {
|
#view-top-bar {
|
||||||
margin-bottom: 2rem;
|
margin-bottom: 2rem;
|
||||||
|
|
|
@ -1,9 +1,338 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api, { objectToParams } from '@/api'
|
||||||
|
import { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
|
import ConfigPanels from '@/components/ConfigPanels.vue'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { isEmptyValue } from '@/helpers/commons'
|
||||||
|
import { humanPermissionName } from '@/helpers/filters/human'
|
||||||
|
import { helpers, required } from '@/helpers/validators'
|
||||||
|
import {
|
||||||
|
formatFormData,
|
||||||
|
formatI18nField,
|
||||||
|
formatYunoHostConfigPanels,
|
||||||
|
} from '@/helpers/yunohostArguments'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const { domains } = useStoreGetters()
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
// FIXME
|
||||||
|
type AppForm = {
|
||||||
|
labels: { label: string; show_tile: boolean }[]
|
||||||
|
url: { domain: string; path: string }
|
||||||
|
}
|
||||||
|
const form: AppForm = reactive({
|
||||||
|
labels: [],
|
||||||
|
url: { domain: '', path: '' },
|
||||||
|
})
|
||||||
|
const rules = computed(() => ({
|
||||||
|
labels: {
|
||||||
|
$each: helpers.forEach({
|
||||||
|
label: { required },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
url: { path: { required } },
|
||||||
|
}))
|
||||||
|
const externalResults = reactive({})
|
||||||
|
const v$ = useVuelidate(rules, form, { $externalResults: externalResults })
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', `apps/${props.id}?full`],
|
||||||
|
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
|
||||||
|
['GET', { uri: 'domains' }],
|
||||||
|
]
|
||||||
|
const loading = ref(true)
|
||||||
|
const app = ref()
|
||||||
|
const purge = ref(false)
|
||||||
|
const config_panel_err = ref(null)
|
||||||
|
const config = ref({
|
||||||
|
panels: [
|
||||||
|
// Fake integration of operations in config panels
|
||||||
|
{
|
||||||
|
hasApplyButton: false,
|
||||||
|
id: 'operations',
|
||||||
|
name: t('operations'),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
validations: {},
|
||||||
|
})
|
||||||
|
const doc = ref()
|
||||||
|
|
||||||
|
const currentTab = computed(() => {
|
||||||
|
return route.params.tabId
|
||||||
|
})
|
||||||
|
|
||||||
|
const allowedGroups = computed(() => {
|
||||||
|
if (!app.value) return
|
||||||
|
return app.value.permissions[0].allowed
|
||||||
|
})
|
||||||
|
|
||||||
|
function appLinksIcons(linkType) {
|
||||||
|
const linksIcons = {
|
||||||
|
license: 'institution',
|
||||||
|
website: 'globe',
|
||||||
|
admindoc: 'book',
|
||||||
|
userdoc: 'book',
|
||||||
|
code: 'code',
|
||||||
|
package: 'code',
|
||||||
|
package_license: 'institution',
|
||||||
|
forum: 'comments',
|
||||||
|
}
|
||||||
|
return linksIcons[linkType]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onQueriesResponse(app_: Obj) {
|
||||||
|
// const form = { labels: [] }
|
||||||
|
|
||||||
|
const mainPermission = app_.permissions[props.id + '.main']
|
||||||
|
mainPermission.name = props.id + '.main'
|
||||||
|
mainPermission.title = t('permission_main')
|
||||||
|
mainPermission.tileAvailable =
|
||||||
|
mainPermission.url !== null && !mainPermission.url.startsWith('re:')
|
||||||
|
form.labels.push({
|
||||||
|
label: mainPermission.label,
|
||||||
|
show_tile: mainPermission.show_tile,
|
||||||
|
})
|
||||||
|
|
||||||
|
const permissions = [mainPermission]
|
||||||
|
for (const [name, perm] of Object.entries(app_.permissions)) {
|
||||||
|
if (!name.endsWith('.main')) {
|
||||||
|
permissions.push({
|
||||||
|
...perm,
|
||||||
|
name,
|
||||||
|
label: perm.sublabel,
|
||||||
|
title: humanPermissionName(name),
|
||||||
|
tileAvailable: perm.url !== null && !perm.url.startsWith('re:'),
|
||||||
|
})
|
||||||
|
form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// this.form = form
|
||||||
|
|
||||||
|
const { DESCRIPTION, ADMIN, ...doc } = app_.manifest.doc
|
||||||
|
const notifs = app_.manifest.notifications
|
||||||
|
const {
|
||||||
|
ldap,
|
||||||
|
sso,
|
||||||
|
multi_instance,
|
||||||
|
ram,
|
||||||
|
disk,
|
||||||
|
architectures: archs,
|
||||||
|
} = app_.manifest.integration
|
||||||
|
app.value = {
|
||||||
|
id: props.id,
|
||||||
|
version: app_.version,
|
||||||
|
label: mainPermission.label,
|
||||||
|
domain: app_.settings.domain,
|
||||||
|
alternativeTo: app_.from_catalog.potential_alternative_to?.length
|
||||||
|
? app_.from_catalog.potential_alternative_to.join(t('words.separator'))
|
||||||
|
: null,
|
||||||
|
description: DESCRIPTION ? formatI18nField(DESCRIPTION) : app_.description,
|
||||||
|
integration:
|
||||||
|
app_.manifest.packaging_format >= 2
|
||||||
|
? {
|
||||||
|
archs: Array.isArray(archs)
|
||||||
|
? archs.join(t('words.separator'))
|
||||||
|
: archs,
|
||||||
|
ldap: ldap === 'not_relevant' ? null : ldap,
|
||||||
|
sso: sso === 'not_relevant' ? null : sso,
|
||||||
|
multi_instance,
|
||||||
|
resources: { ram: ram.runtime, disk },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
links: [
|
||||||
|
[
|
||||||
|
'license',
|
||||||
|
`https://spdx.org/licenses/${app_.manifest.upstream.license}`,
|
||||||
|
],
|
||||||
|
...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
|
||||||
|
return [key, app_.manifest.upstream[key]]
|
||||||
|
}),
|
||||||
|
['package', app_.from_catalog.git?.url],
|
||||||
|
['package_license', app_.from_catalog.git?.url + '/blob/master/LICENSE'],
|
||||||
|
['forum', `https://forum.yunohost.org/tag/${app_.manifest.id}`],
|
||||||
|
].filter(([key, val]) => !!val),
|
||||||
|
doc: {
|
||||||
|
notifications: {
|
||||||
|
postInstall:
|
||||||
|
notifs.POST_INSTALL && notifs.POST_INSTALL.main
|
||||||
|
? [['main', formatI18nField(notifs.POST_INSTALL.main)]]
|
||||||
|
: [],
|
||||||
|
postUpgrade: notifs.POST_UPGRADE
|
||||||
|
? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => {
|
||||||
|
return [key, formatI18nField(content)]
|
||||||
|
})
|
||||||
|
: [],
|
||||||
|
},
|
||||||
|
admin: [
|
||||||
|
['admin', formatI18nField(ADMIN)],
|
||||||
|
...Object.keys(doc)
|
||||||
|
.sort()
|
||||||
|
.map((key) => [
|
||||||
|
key.charAt(0) + key.slice(1).toLowerCase(),
|
||||||
|
formatI18nField(doc[key]),
|
||||||
|
]),
|
||||||
|
].filter((doc) => doc[1]),
|
||||||
|
},
|
||||||
|
is_webapp: app_.is_webapp,
|
||||||
|
is_default: app_.is_default,
|
||||||
|
supports_change_url: app_.supports_change_url,
|
||||||
|
supports_config_panel: app_.supports_config_panel,
|
||||||
|
supports_purge: app_.supports_purge,
|
||||||
|
permissions,
|
||||||
|
}
|
||||||
|
if (app_.settings.domain && app_.settings.path) {
|
||||||
|
app.value.url = 'https://' + app_.settings.domain + app_.settings.path
|
||||||
|
form.url = {
|
||||||
|
domain: app_.settings.domain,
|
||||||
|
path: app_.settings.path.slice(1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!Object.values(app.value.doc.notifications).some((notif) => notif.length)
|
||||||
|
) {
|
||||||
|
app.value.doc.notifications = null
|
||||||
|
}
|
||||||
|
|
||||||
|
if (app_.supports_config_panel) {
|
||||||
|
await api
|
||||||
|
.get(`apps/${props.id}/config?full`)
|
||||||
|
.then((cp) => {
|
||||||
|
const config_ = formatYunoHostConfigPanels(cp)
|
||||||
|
// reinject 'operations' fake config tab
|
||||||
|
config_.panels.unshift(config.panels[0])
|
||||||
|
config.value = config_
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
config_panel_err.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfigSubmit({ id, form, action, name }) {
|
||||||
|
const args = await formatFormData(form, {
|
||||||
|
removeEmpty: false,
|
||||||
|
removeNull: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
action
|
||||||
|
? `apps/${props.id}/actions/${action}`
|
||||||
|
: `apps/${props.id}/config/${id}`,
|
||||||
|
isEmptyValue(args) ? {} : { args: objectToParams(args) },
|
||||||
|
{
|
||||||
|
key: `apps.${action ? 'action' : 'update'}_config`,
|
||||||
|
id,
|
||||||
|
name: props.id,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
loading.value = true
|
||||||
|
viewElem.value!.fetchQueries()
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
|
const panel = config.value.panels.find((panel) => panel.id === id)!
|
||||||
|
if (err.data.name) {
|
||||||
|
Object.assign(externalResults, {
|
||||||
|
forms: { [panel.id]: { [err.data.name]: [err.data.error] } },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
panel.serverError = err.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeLabel(permName, data) {
|
||||||
|
data.show_tile = data.show_tile ? 'True' : 'False'
|
||||||
|
api
|
||||||
|
.put('users/permissions/' + permName, data, {
|
||||||
|
key: 'apps.change_label',
|
||||||
|
prevName: app.value.label,
|
||||||
|
nextName: data.label,
|
||||||
|
})
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function changeUrl() {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_app_change_url'))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
const { domain, path } = form.url
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`apps/${props.id}/changeurl`,
|
||||||
|
{ domain, path: '/' + path },
|
||||||
|
{ key: 'apps.change_url', name: app.value.label },
|
||||||
|
)
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAsDefaultDomain(undo = false) {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_app_default'))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`apps/${props.id}/default${undo ? '?undo' : ''}`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
key: 'apps.set_default',
|
||||||
|
name: app.value.label,
|
||||||
|
domain: app.value.domain,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function dismissNotification(name: string) {
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`apps/${props.id}/dismiss_notification/${name}`,
|
||||||
|
{},
|
||||||
|
{ key: 'apps.dismiss_notification', name: app.value.label },
|
||||||
|
)
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
|
||||||
|
async function uninstall() {
|
||||||
|
const data = purge.value === true ? { purge: 1 } : {}
|
||||||
|
api
|
||||||
|
.delete('apps/' + props.id, data, {
|
||||||
|
key: 'apps.uninstall',
|
||||||
|
name: app.value.label,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push({ name: 'app-list' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
@queries-response="onQueriesResponse"
|
@queries-response="onQueriesResponse"
|
||||||
:loading="loading"
|
:loading="loading"
|
||||||
ref="view"
|
ref="viewElem"
|
||||||
>
|
>
|
||||||
<YAlert
|
<YAlert
|
||||||
v-if="
|
v-if="
|
||||||
|
@ -357,350 +686,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import api, { objectToParams } from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { humanPermissionName } from '@/helpers/filters/human'
|
|
||||||
import { helpers, required } from '@/helpers/validators'
|
|
||||||
import { isEmptyValue } from '@/helpers/commons'
|
|
||||||
import {
|
|
||||||
formatFormData,
|
|
||||||
formatI18nField,
|
|
||||||
formatYunoHostConfigPanels,
|
|
||||||
} from '@/helpers/yunohostArguments'
|
|
||||||
import ConfigPanels from '@/components/ConfigPanels.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'AppInfo',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
ConfigPanels,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', `apps/${this.id}?full`],
|
|
||||||
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
|
|
||||||
['GET', { uri: 'domains' }],
|
|
||||||
],
|
|
||||||
loading: true,
|
|
||||||
app: undefined,
|
|
||||||
form: undefined,
|
|
||||||
purge: false,
|
|
||||||
config_panel_err: null,
|
|
||||||
config: {
|
|
||||||
panels: [
|
|
||||||
// Fake integration of operations in config panels
|
|
||||||
{
|
|
||||||
hasApplyButton: false,
|
|
||||||
id: 'operations',
|
|
||||||
name: this.$t('operations'),
|
|
||||||
},
|
|
||||||
],
|
|
||||||
validations: {},
|
|
||||||
},
|
|
||||||
externalResults: {},
|
|
||||||
doc: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['domains']),
|
|
||||||
|
|
||||||
currentTab() {
|
|
||||||
return this.$route.params.tabId
|
|
||||||
},
|
|
||||||
|
|
||||||
allowedGroups() {
|
|
||||||
if (!this.app) return
|
|
||||||
return this.app.permissions[0].allowed
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
labels: {
|
|
||||||
$each: helpers.forEach({
|
|
||||||
label: { required },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
url: { path: { required } },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
appLinksIcons(linkType) {
|
|
||||||
const linksIcons = {
|
|
||||||
license: 'institution',
|
|
||||||
website: 'globe',
|
|
||||||
admindoc: 'book',
|
|
||||||
userdoc: 'book',
|
|
||||||
code: 'code',
|
|
||||||
package: 'code',
|
|
||||||
package_license: 'institution',
|
|
||||||
forum: 'comments',
|
|
||||||
}
|
|
||||||
return linksIcons[linkType]
|
|
||||||
},
|
|
||||||
|
|
||||||
async onQueriesResponse(app) {
|
|
||||||
const form = { labels: [] }
|
|
||||||
|
|
||||||
const mainPermission = app.permissions[this.id + '.main']
|
|
||||||
mainPermission.name = this.id + '.main'
|
|
||||||
mainPermission.title = this.$t('permission_main')
|
|
||||||
mainPermission.tileAvailable =
|
|
||||||
mainPermission.url !== null && !mainPermission.url.startsWith('re:')
|
|
||||||
form.labels.push({
|
|
||||||
label: mainPermission.label,
|
|
||||||
show_tile: mainPermission.show_tile,
|
|
||||||
})
|
|
||||||
|
|
||||||
const permissions = [mainPermission]
|
|
||||||
for (const [name, perm] of Object.entries(app.permissions)) {
|
|
||||||
if (!name.endsWith('.main')) {
|
|
||||||
permissions.push({
|
|
||||||
...perm,
|
|
||||||
name,
|
|
||||||
label: perm.sublabel,
|
|
||||||
title: humanPermissionName(name),
|
|
||||||
tileAvailable: perm.url !== null && !perm.url.startsWith('re:'),
|
|
||||||
})
|
|
||||||
form.labels.push({ label: perm.sublabel, show_tile: perm.show_tile })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
this.form = form
|
|
||||||
|
|
||||||
const { DESCRIPTION, ADMIN, ...doc } = app.manifest.doc
|
|
||||||
const notifs = app.manifest.notifications
|
|
||||||
const {
|
|
||||||
ldap,
|
|
||||||
sso,
|
|
||||||
multi_instance,
|
|
||||||
ram,
|
|
||||||
disk,
|
|
||||||
architectures: archs,
|
|
||||||
} = app.manifest.integration
|
|
||||||
this.app = {
|
|
||||||
id: this.id,
|
|
||||||
version: app.version,
|
|
||||||
label: mainPermission.label,
|
|
||||||
domain: app.settings.domain,
|
|
||||||
alternativeTo: app.from_catalog.potential_alternative_to?.length
|
|
||||||
? app.from_catalog.potential_alternative_to.join(
|
|
||||||
this.$t('words.separator'),
|
|
||||||
)
|
|
||||||
: null,
|
|
||||||
description: DESCRIPTION
|
|
||||||
? formatI18nField(DESCRIPTION)
|
|
||||||
: app.description,
|
|
||||||
integration:
|
|
||||||
app.manifest.packaging_format >= 2
|
|
||||||
? {
|
|
||||||
archs: Array.isArray(archs)
|
|
||||||
? archs.join(this.$t('words.separator'))
|
|
||||||
: archs,
|
|
||||||
ldap: ldap === 'not_relevant' ? null : ldap,
|
|
||||||
sso: sso === 'not_relevant' ? null : sso,
|
|
||||||
multi_instance,
|
|
||||||
resources: { ram: ram.runtime, disk },
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
links: [
|
|
||||||
[
|
|
||||||
'license',
|
|
||||||
`https://spdx.org/licenses/${app.manifest.upstream.license}`,
|
|
||||||
],
|
|
||||||
...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
|
|
||||||
return [key, app.manifest.upstream[key]]
|
|
||||||
}),
|
|
||||||
['package', app.from_catalog.git?.url],
|
|
||||||
[
|
|
||||||
'package_license',
|
|
||||||
app.from_catalog.git?.url + '/blob/master/LICENSE',
|
|
||||||
],
|
|
||||||
['forum', `https://forum.yunohost.org/tag/${app.manifest.id}`],
|
|
||||||
].filter(([key, val]) => !!val),
|
|
||||||
doc: {
|
|
||||||
notifications: {
|
|
||||||
postInstall:
|
|
||||||
notifs.POST_INSTALL && notifs.POST_INSTALL.main
|
|
||||||
? [['main', formatI18nField(notifs.POST_INSTALL.main)]]
|
|
||||||
: [],
|
|
||||||
postUpgrade: notifs.POST_UPGRADE
|
|
||||||
? Object.entries(notifs.POST_UPGRADE).map(([key, content]) => {
|
|
||||||
return [key, formatI18nField(content)]
|
|
||||||
})
|
|
||||||
: [],
|
|
||||||
},
|
|
||||||
admin: [
|
|
||||||
['admin', formatI18nField(ADMIN)],
|
|
||||||
...Object.keys(doc)
|
|
||||||
.sort()
|
|
||||||
.map((key) => [
|
|
||||||
key.charAt(0) + key.slice(1).toLowerCase(),
|
|
||||||
formatI18nField(doc[key]),
|
|
||||||
]),
|
|
||||||
].filter((doc) => doc[1]),
|
|
||||||
},
|
|
||||||
is_webapp: app.is_webapp,
|
|
||||||
is_default: app.is_default,
|
|
||||||
supports_change_url: app.supports_change_url,
|
|
||||||
supports_config_panel: app.supports_config_panel,
|
|
||||||
supports_purge: app.supports_purge,
|
|
||||||
permissions,
|
|
||||||
}
|
|
||||||
if (app.settings.domain && app.settings.path) {
|
|
||||||
this.app.url = 'https://' + app.settings.domain + app.settings.path
|
|
||||||
form.url = {
|
|
||||||
domain: app.settings.domain,
|
|
||||||
path: app.settings.path.slice(1),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
!Object.values(this.app.doc.notifications).some((notif) => notif.length)
|
|
||||||
) {
|
|
||||||
this.app.doc.notifications = null
|
|
||||||
}
|
|
||||||
|
|
||||||
if (app.supports_config_panel) {
|
|
||||||
await api
|
|
||||||
.get(`apps/${this.id}/config?full`)
|
|
||||||
.then((config) => {
|
|
||||||
const config_ = formatYunoHostConfigPanels(config)
|
|
||||||
// reinject 'operations' fake config tab
|
|
||||||
config_.panels.unshift(this.config.panels[0])
|
|
||||||
this.config = config_
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
this.config_panel_err = err.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
this.loading = false
|
|
||||||
},
|
|
||||||
|
|
||||||
async onConfigSubmit({ id, form, action, name }) {
|
|
||||||
const args = await formatFormData(form, {
|
|
||||||
removeEmpty: false,
|
|
||||||
removeNull: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
action
|
|
||||||
? `apps/${this.id}/actions/${action}`
|
|
||||||
: `apps/${this.id}/config/${id}`,
|
|
||||||
isEmptyValue(args) ? {} : { args: objectToParams(args) },
|
|
||||||
{
|
|
||||||
key: `apps.${action ? 'action' : 'update'}_config`,
|
|
||||||
id,
|
|
||||||
name: this.id,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
this.loading = true
|
|
||||||
this.$refs.view.fetchQueries()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
const panel = this.config.panels.find((panel) => panel.id === id)
|
|
||||||
if (err.data.name) {
|
|
||||||
Object.assign(this.externalResults, {
|
|
||||||
forms: { [panel.id]: { [err.data.name]: [err.data.error] } },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
panel.serverError = err.message
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
changeLabel(permName, data) {
|
|
||||||
data.show_tile = data.show_tile ? 'True' : 'False'
|
|
||||||
api
|
|
||||||
.put('users/permissions/' + permName, data, {
|
|
||||||
key: 'apps.change_label',
|
|
||||||
prevName: this.app.label,
|
|
||||||
nextName: data.label,
|
|
||||||
})
|
|
||||||
.then(this.$refs.view.fetchQueries)
|
|
||||||
},
|
|
||||||
|
|
||||||
async changeUrl() {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_app_change_url'),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
const { domain, path } = this.form.url
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
`apps/${this.id}/changeurl`,
|
|
||||||
{ domain, path: '/' + path },
|
|
||||||
{ key: 'apps.change_url', name: this.app.label },
|
|
||||||
)
|
|
||||||
.then(this.$refs.view.fetchQueries)
|
|
||||||
},
|
|
||||||
|
|
||||||
async setAsDefaultDomain(undo = false) {
|
|
||||||
const confirmed = await this.modalConfirm(this.$t('confirm_app_default'))
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
`apps/${this.id}/default${undo ? '?undo' : ''}`,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
key: 'apps.set_default',
|
|
||||||
name: this.app.label,
|
|
||||||
domain: this.app.domain,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.then(this.$refs.view.fetchQueries)
|
|
||||||
},
|
|
||||||
|
|
||||||
async dismissNotification(name) {
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
`apps/${this.id}/dismiss_notification/${name}`,
|
|
||||||
{},
|
|
||||||
{ key: 'apps.dismiss_notification', name: this.app.label },
|
|
||||||
)
|
|
||||||
.then(this.$refs.view.fetchQueries)
|
|
||||||
},
|
|
||||||
|
|
||||||
async uninstall() {
|
|
||||||
const data = this.purge === true ? { purge: 1 } : {}
|
|
||||||
api
|
|
||||||
.delete('apps/' + this.id, data, {
|
|
||||||
key: 'apps.uninstall',
|
|
||||||
name: this.app.label,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.$router.push({ name: 'app-list' })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
select {
|
select {
|
||||||
border-top-right-radius: 0;
|
border-top-right-radius: 0;
|
||||||
|
|
|
@ -1,3 +1,215 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api, { objectToParams } from '@/api'
|
||||||
|
import { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import {
|
||||||
|
formatFormData,
|
||||||
|
formatI18nField,
|
||||||
|
formatYunoHostArguments,
|
||||||
|
} from '@/helpers/yunohostArguments'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const form = reactive({})
|
||||||
|
const validations = ref({})
|
||||||
|
const rules = computed(() => validations)
|
||||||
|
const externalResults = reactive({})
|
||||||
|
const v$ = useVuelidate(rules, form, { $externalResults: externalResults })
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', 'apps/catalog?full&with_categories&with_antifeatures'],
|
||||||
|
['GET', `apps/manifest?app=${props.id}&with_screenshot`],
|
||||||
|
]
|
||||||
|
|
||||||
|
// FIXME
|
||||||
|
const app = ref(undefined)
|
||||||
|
const name = ref(undefined)
|
||||||
|
const fields = ref(undefined)
|
||||||
|
const serverError = ref('')
|
||||||
|
const force = ref(false)
|
||||||
|
|
||||||
|
function appLinksIcons(linkType) {
|
||||||
|
const linksIcons = {
|
||||||
|
license: 'institution',
|
||||||
|
website: 'globe',
|
||||||
|
admindoc: 'book',
|
||||||
|
userdoc: 'book',
|
||||||
|
code: 'code',
|
||||||
|
package: 'code',
|
||||||
|
package_license: 'institution',
|
||||||
|
forum: 'comments',
|
||||||
|
}
|
||||||
|
return linksIcons[linkType]
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueriesResponse(catalog, _app) {
|
||||||
|
const antifeaturesList = Object.fromEntries(
|
||||||
|
catalog.antifeatures.map((af) => [af.id, af]),
|
||||||
|
)
|
||||||
|
|
||||||
|
const { id, name, version, requirements } = _app
|
||||||
|
const {
|
||||||
|
ldap,
|
||||||
|
sso,
|
||||||
|
multi_instance,
|
||||||
|
ram,
|
||||||
|
disk,
|
||||||
|
architectures: archs,
|
||||||
|
} = _app.integration
|
||||||
|
|
||||||
|
const quality = { state: _app.quality.state, variant: 'danger' }
|
||||||
|
if (quality.state === 'working') {
|
||||||
|
if (_app.quality.level <= 0) {
|
||||||
|
quality.state = 'broken'
|
||||||
|
} else if (_app.quality.level <= 4) {
|
||||||
|
quality.state = 'lowquality'
|
||||||
|
quality.variant = 'warning'
|
||||||
|
} else {
|
||||||
|
quality.variant = 'success'
|
||||||
|
quality.state = _app.quality.level >= 8 ? 'highquality' : 'goodquality'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const preInstall = formatI18nField(_app.notifications.PRE_INSTALL.main)
|
||||||
|
const antifeatures = _app.antifeatures?.length
|
||||||
|
? _app.antifeatures.map((af) => antifeaturesList[af])
|
||||||
|
: null
|
||||||
|
|
||||||
|
const hasDanger = quality.variant === 'danger' || !requirements.ram.pass
|
||||||
|
const hasSupport = Object.keys(requirements).every((key) => {
|
||||||
|
// ram support is non-blocking requirement and handled on its own.
|
||||||
|
return key === 'ram' || requirements[key].pass
|
||||||
|
})
|
||||||
|
|
||||||
|
const app_ = {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
alternativeTo:
|
||||||
|
_app.potential_alternative_to && _app.potential_alternative_to.length
|
||||||
|
? _app.potential_alternative_to.join(t('words.separator'))
|
||||||
|
: null,
|
||||||
|
description: formatI18nField(_app.doc.DESCRIPTION || _app.description),
|
||||||
|
screenshot: _app.screenshot,
|
||||||
|
demo: _app.upstream.demo,
|
||||||
|
version,
|
||||||
|
license: _app.upstream.license,
|
||||||
|
integration:
|
||||||
|
_app.packaging_format >= 2
|
||||||
|
? {
|
||||||
|
archs: Array.isArray(archs)
|
||||||
|
? archs.join(t('words.separator'))
|
||||||
|
: archs,
|
||||||
|
ldap: ldap === 'not_relevant' ? null : ldap,
|
||||||
|
sso: sso === 'not_relevant' ? null : sso,
|
||||||
|
multi_instance,
|
||||||
|
resources: { ram: ram.runtime, disk },
|
||||||
|
}
|
||||||
|
: null,
|
||||||
|
links: [
|
||||||
|
['license', `https://spdx.org/licenses/${_app.upstream.license}`],
|
||||||
|
...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
|
||||||
|
return [key, _app.upstream[key]]
|
||||||
|
}),
|
||||||
|
['package', _app.remote.url],
|
||||||
|
['package_license', _app.remote.url + '/blob/master/LICENSE'],
|
||||||
|
['forum', `https://forum.yunohost.org/tag/${id}`],
|
||||||
|
].filter(([key, val]) => !!val),
|
||||||
|
preInstall,
|
||||||
|
antifeatures,
|
||||||
|
quality,
|
||||||
|
requirements,
|
||||||
|
hasWarning: !!preInstall || antifeatures || quality.variant === 'warning',
|
||||||
|
hasDanger,
|
||||||
|
hasSupport,
|
||||||
|
canInstall: hasSupport && !hasDanger,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME yunohost should add the label field by default
|
||||||
|
_app.install.unshift({
|
||||||
|
ask: t('label_for_manifestname', { name }),
|
||||||
|
default: name,
|
||||||
|
name: 'label',
|
||||||
|
help: t('label_for_manifestname_help'),
|
||||||
|
})
|
||||||
|
|
||||||
|
const {
|
||||||
|
form: form_,
|
||||||
|
fields,
|
||||||
|
validations,
|
||||||
|
} = formatYunoHostArguments(_app.install)
|
||||||
|
|
||||||
|
app.value = app_
|
||||||
|
fieds.value = fields
|
||||||
|
Object.assign(form, form_)
|
||||||
|
validations.value = validations
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAppNotifs(notifs) {
|
||||||
|
return Object.keys(notifs).reduce((acc, key) => {
|
||||||
|
return acc + '\n\n' + notifs[key]
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performInstall() {
|
||||||
|
if ('path' in form && form.path === '/') {
|
||||||
|
const confirmed = await this.modalConfirm(
|
||||||
|
t('confirm_install_domain_root', {
|
||||||
|
domain: form.domain,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: args, label } = await formatFormData(form, {
|
||||||
|
extract: ['label'],
|
||||||
|
removeEmpty: false,
|
||||||
|
removeNull: true,
|
||||||
|
})
|
||||||
|
const data = {
|
||||||
|
app: props.id,
|
||||||
|
label,
|
||||||
|
args: Object.entries(args).length ? objectToParams(args) : undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.post('apps', data, { key: 'apps.install', name: app.value.name })
|
||||||
|
.then(async ({ notifications }) => {
|
||||||
|
const postInstall = formatAppNotifs(notifications)
|
||||||
|
if (postInstall) {
|
||||||
|
const message =
|
||||||
|
t('app.install.notifs.post.alert') + '\n\n' + postInstall
|
||||||
|
await modalConfirm(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
title: t('app.install.notifs.post.title', {
|
||||||
|
name: app.value.name,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
{ markdown: true, cancelable: false },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
router.push({ name: 'app-list' })
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
|
if (err.data.name) {
|
||||||
|
externalResults[err.data.name] = err.message
|
||||||
|
} else serverError.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase :queries="queries" @queries-response="onQueriesResponse">
|
<ViewBase :queries="queries" @queries-response="onQueriesResponse">
|
||||||
<template v-if="app">
|
<template v-if="app">
|
||||||
|
@ -205,231 +417,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import api, { objectToParams } from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import {
|
|
||||||
formatYunoHostArguments,
|
|
||||||
formatI18nField,
|
|
||||||
formatFormData,
|
|
||||||
} from '@/helpers/yunohostArguments'
|
|
||||||
import CardCollapse from '@/components/CardCollapse.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'AppInstall',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
CardCollapse,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', 'apps/catalog?full&with_categories&with_antifeatures'],
|
|
||||||
['GET', `apps/manifest?app=${this.id}&with_screenshot`],
|
|
||||||
],
|
|
||||||
app: undefined,
|
|
||||||
name: undefined,
|
|
||||||
form: undefined,
|
|
||||||
fields: undefined,
|
|
||||||
validations: {},
|
|
||||||
errors: undefined,
|
|
||||||
serverError: '',
|
|
||||||
force: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return this.validations
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
appLinksIcons(linkType) {
|
|
||||||
const linksIcons = {
|
|
||||||
license: 'institution',
|
|
||||||
website: 'globe',
|
|
||||||
admindoc: 'book',
|
|
||||||
userdoc: 'book',
|
|
||||||
code: 'code',
|
|
||||||
package: 'code',
|
|
||||||
package_license: 'institution',
|
|
||||||
forum: 'comments',
|
|
||||||
}
|
|
||||||
return linksIcons[linkType]
|
|
||||||
},
|
|
||||||
|
|
||||||
onQueriesResponse(catalog, _app) {
|
|
||||||
const antifeaturesList = Object.fromEntries(
|
|
||||||
catalog.antifeatures.map((af) => [af.id, af]),
|
|
||||||
)
|
|
||||||
|
|
||||||
const { id, name, version, requirements } = _app
|
|
||||||
const {
|
|
||||||
ldap,
|
|
||||||
sso,
|
|
||||||
multi_instance,
|
|
||||||
ram,
|
|
||||||
disk,
|
|
||||||
architectures: archs,
|
|
||||||
} = _app.integration
|
|
||||||
|
|
||||||
const quality = { state: _app.quality.state, variant: 'danger' }
|
|
||||||
if (quality.state === 'working') {
|
|
||||||
if (_app.quality.level <= 0) {
|
|
||||||
quality.state = 'broken'
|
|
||||||
} else if (_app.quality.level <= 4) {
|
|
||||||
quality.state = 'lowquality'
|
|
||||||
quality.variant = 'warning'
|
|
||||||
} else {
|
|
||||||
quality.variant = 'success'
|
|
||||||
quality.state =
|
|
||||||
_app.quality.level >= 8 ? 'highquality' : 'goodquality'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const preInstall = formatI18nField(_app.notifications.PRE_INSTALL.main)
|
|
||||||
const antifeatures = _app.antifeatures?.length
|
|
||||||
? _app.antifeatures.map((af) => antifeaturesList[af])
|
|
||||||
: null
|
|
||||||
|
|
||||||
const hasDanger = quality.variant === 'danger' || !requirements.ram.pass
|
|
||||||
const hasSupport = Object.keys(requirements).every((key) => {
|
|
||||||
// ram support is non-blocking requirement and handled on its own.
|
|
||||||
return key === 'ram' || requirements[key].pass
|
|
||||||
})
|
|
||||||
|
|
||||||
const app = {
|
|
||||||
id,
|
|
||||||
name,
|
|
||||||
alternativeTo:
|
|
||||||
_app.potential_alternative_to && _app.potential_alternative_to.length
|
|
||||||
? _app.potential_alternative_to.join(this.$t('words.separator'))
|
|
||||||
: null,
|
|
||||||
description: formatI18nField(_app.doc.DESCRIPTION || _app.description),
|
|
||||||
screenshot: _app.screenshot,
|
|
||||||
demo: _app.upstream.demo,
|
|
||||||
version,
|
|
||||||
license: _app.upstream.license,
|
|
||||||
integration:
|
|
||||||
_app.packaging_format >= 2
|
|
||||||
? {
|
|
||||||
archs: Array.isArray(archs)
|
|
||||||
? archs.join(this.$t('words.separator'))
|
|
||||||
: archs,
|
|
||||||
ldap: ldap === 'not_relevant' ? null : ldap,
|
|
||||||
sso: sso === 'not_relevant' ? null : sso,
|
|
||||||
multi_instance,
|
|
||||||
resources: { ram: ram.runtime, disk },
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
links: [
|
|
||||||
['license', `https://spdx.org/licenses/${_app.upstream.license}`],
|
|
||||||
...['website', 'admindoc', 'userdoc', 'code'].map((key) => {
|
|
||||||
return [key, _app.upstream[key]]
|
|
||||||
}),
|
|
||||||
['package', _app.remote.url],
|
|
||||||
['package_license', _app.remote.url + '/blob/master/LICENSE'],
|
|
||||||
['forum', `https://forum.yunohost.org/tag/${id}`],
|
|
||||||
].filter(([key, val]) => !!val),
|
|
||||||
preInstall,
|
|
||||||
antifeatures,
|
|
||||||
quality,
|
|
||||||
requirements,
|
|
||||||
hasWarning:
|
|
||||||
!!preInstall || antifeatures || quality.variant === 'warning',
|
|
||||||
hasDanger,
|
|
||||||
hasSupport,
|
|
||||||
canInstall: hasSupport && !hasDanger,
|
|
||||||
}
|
|
||||||
|
|
||||||
// FIXME yunohost should add the label field by default
|
|
||||||
_app.install.unshift({
|
|
||||||
ask: this.$t('label_for_manifestname', { name }),
|
|
||||||
default: name,
|
|
||||||
name: 'label',
|
|
||||||
help: this.$t('label_for_manifestname_help'),
|
|
||||||
})
|
|
||||||
|
|
||||||
const { form, fields, validations, errors } = formatYunoHostArguments(
|
|
||||||
_app.install,
|
|
||||||
)
|
|
||||||
|
|
||||||
this.app = app
|
|
||||||
this.fields = fields
|
|
||||||
this.form = form
|
|
||||||
this.validations = { form: validations }
|
|
||||||
this.errors = errors
|
|
||||||
},
|
|
||||||
|
|
||||||
formatAppNotifs(notifs) {
|
|
||||||
return Object.keys(notifs).reduce((acc, key) => {
|
|
||||||
return acc + '\n\n' + notifs[key]
|
|
||||||
}, '')
|
|
||||||
},
|
|
||||||
|
|
||||||
async performInstall() {
|
|
||||||
if ('path' in this.form && this.form.path === '/') {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_install_domain_root', {
|
|
||||||
domain: this.form.domain,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: args, label } = await formatFormData(this.form, {
|
|
||||||
extract: ['label'],
|
|
||||||
removeEmpty: false,
|
|
||||||
removeNull: true,
|
|
||||||
})
|
|
||||||
const data = {
|
|
||||||
app: this.id,
|
|
||||||
label,
|
|
||||||
args: Object.entries(args).length ? objectToParams(args) : undefined,
|
|
||||||
}
|
|
||||||
|
|
||||||
api
|
|
||||||
.post('apps', data, { key: 'apps.install', name: this.app.name })
|
|
||||||
.then(async ({ notifications }) => {
|
|
||||||
const postInstall = this.formatAppNotifs(notifications)
|
|
||||||
if (postInstall) {
|
|
||||||
const message =
|
|
||||||
this.$t('app.install.notifs.post.alert') + '\n\n' + postInstall
|
|
||||||
await this.modalConfirm(
|
|
||||||
message,
|
|
||||||
{
|
|
||||||
title: this.$t('app.install.notifs.post.title', {
|
|
||||||
name: this.app.name,
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
{ markdown: true, cancelable: false },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
this.$router.push({ name: 'app-list' })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
if (err.data.name) {
|
|
||||||
this.errors[err.data.name].message = err.message
|
|
||||||
} else this.serverError = err.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.antifeatures {
|
.antifeatures {
|
||||||
dt::before {
|
dt::before {
|
||||||
|
|
|
@ -1,3 +1,38 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed } from 'vue'
|
||||||
|
|
||||||
|
const queries = [['GET', 'apps?full']]
|
||||||
|
const search = ref('')
|
||||||
|
const apps = ref()
|
||||||
|
|
||||||
|
const filteredApps = computed(() => {
|
||||||
|
if (!apps.value) return
|
||||||
|
const search_ = search.value.toLowerCase()
|
||||||
|
// Check if any value in apps (label, id, name, description) match the search query.
|
||||||
|
const filtered = apps.value.filter((app) =>
|
||||||
|
Object.values(app).some(
|
||||||
|
(item) => item && item.toLowerCase().includes(search_),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
return filtered.length ? filtered : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function onQueriesResponse({ apps }) {
|
||||||
|
if (apps.length === 0) {
|
||||||
|
apps.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
apps.value = apps
|
||||||
|
.map(({ id, name, description, manifest }) => {
|
||||||
|
return { id, name: manifest.name, label: name, description }
|
||||||
|
})
|
||||||
|
.sort((prev, app) => {
|
||||||
|
return prev.label > app.label ? 1 : -1
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch
|
||||||
v-model:search="search"
|
v-model:search="search"
|
||||||
|
@ -36,45 +71,3 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'AppList',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', 'apps?full']],
|
|
||||||
search: '',
|
|
||||||
apps: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
filteredApps() {
|
|
||||||
if (!this.apps) return
|
|
||||||
const search = this.search.toLowerCase()
|
|
||||||
const match = (item) => item && item.toLowerCase().includes(search)
|
|
||||||
// Check if any value in apps (label, id, name, description) match the search query.
|
|
||||||
const filtered = this.apps.filter((app) => Object.values(app).some(match))
|
|
||||||
return filtered.length ? filtered : null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse({ apps }) {
|
|
||||||
if (apps.length === 0) {
|
|
||||||
this.apps = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,83 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', 'hooks/backup'],
|
||||||
|
['GET', 'apps?with_backup'],
|
||||||
|
]
|
||||||
|
const selected = ref<string[]>([])
|
||||||
|
const system = ref()
|
||||||
|
const apps = ref()
|
||||||
|
|
||||||
|
function formatHooks(hooks) {
|
||||||
|
const data = {}
|
||||||
|
hooks.forEach((hook) => {
|
||||||
|
const groupId = hook.startsWith('conf_')
|
||||||
|
? 'adminjs_group_configuration'
|
||||||
|
: hook
|
||||||
|
if (groupId in data) {
|
||||||
|
data[groupId].value.push(hook)
|
||||||
|
data[groupId].description += ', ' + t('hook_' + hook)
|
||||||
|
} else {
|
||||||
|
data[groupId] = {
|
||||||
|
name: t('hook_' + groupId),
|
||||||
|
value: [hook],
|
||||||
|
description: t(groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueriesResponse({ hooks }, { apps }) {
|
||||||
|
system.value = formatHooks(hooks)
|
||||||
|
// transform app array into literal object to match hooks data structure
|
||||||
|
apps.value = apps.reduce((obj, app) => {
|
||||||
|
obj[app.id] = app
|
||||||
|
return obj
|
||||||
|
}, {})
|
||||||
|
selected.value = [...Object.keys(system.value), ...Object.keys(apps.value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(select: boolean, type: 'system' | 'apps') {
|
||||||
|
const keys = Object.keys((type === 'system' ? system : apps).value)
|
||||||
|
if (select) {
|
||||||
|
const toSelect = keys.filter((item) => !selected.value.includes(item))
|
||||||
|
selected.value = [...selected.value, ...toSelect]
|
||||||
|
} else {
|
||||||
|
selected.value = selected.value.filter(
|
||||||
|
(selected) => !keys.includes(selected),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBackup() {
|
||||||
|
const data = { apps: [], system: [] }
|
||||||
|
for (const item of selected.value) {
|
||||||
|
if (item in system.value) {
|
||||||
|
data.system = [...data.system, ...system.value[item].value]
|
||||||
|
} else {
|
||||||
|
data.apps.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api.post('backups', data, 'backups.create').then(() => {
|
||||||
|
router.push({ name: 'backup-list', params: { id: props.id } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
|
@ -122,91 +202,3 @@
|
||||||
</YCard>
|
</YCard>
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'BackupCreate',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', 'hooks/backup'],
|
|
||||||
['GET', 'apps?with_backup'],
|
|
||||||
],
|
|
||||||
selected: [],
|
|
||||||
// api data
|
|
||||||
system: undefined,
|
|
||||||
apps: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
formatHooks(hooks) {
|
|
||||||
const data = {}
|
|
||||||
hooks.forEach((hook) => {
|
|
||||||
const groupId = hook.startsWith('conf_')
|
|
||||||
? 'adminjs_group_configuration'
|
|
||||||
: hook
|
|
||||||
if (groupId in data) {
|
|
||||||
data[groupId].value.push(hook)
|
|
||||||
data[groupId].description += ', ' + this.$t('hook_' + hook)
|
|
||||||
} else {
|
|
||||||
data[groupId] = {
|
|
||||||
name: this.$t('hook_' + groupId),
|
|
||||||
value: [hook],
|
|
||||||
description: this.$t(
|
|
||||||
groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook,
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
onQueriesResponse({ hooks }, { apps }) {
|
|
||||||
this.system = this.formatHooks(hooks)
|
|
||||||
// transform app array into literal object to match hooks data structure
|
|
||||||
this.apps = apps.reduce((obj, app) => {
|
|
||||||
obj[app.id] = app
|
|
||||||
return obj
|
|
||||||
}, {})
|
|
||||||
this.selected = [...Object.keys(this.system), ...Object.keys(this.apps)]
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleSelected(select, type) {
|
|
||||||
if (select) {
|
|
||||||
const toSelect = Object.keys(this[type]).filter(
|
|
||||||
(item) => !this.selected.includes(item),
|
|
||||||
)
|
|
||||||
this.selected = [...this.selected, ...toSelect]
|
|
||||||
} else {
|
|
||||||
const toUnselect = Object.keys(this[type])
|
|
||||||
this.selected = this.selected.filter(
|
|
||||||
(selected) => !toUnselect.includes(selected),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
createBackup() {
|
|
||||||
const data = { apps: [], system: [] }
|
|
||||||
for (const item of this.selected) {
|
|
||||||
if (item in this.system) {
|
|
||||||
data.system = [...data.system, ...this.system[item].value]
|
|
||||||
} else {
|
|
||||||
data.apps.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api.post('backups', data, 'backups.create').then(() => {
|
|
||||||
this.$router.push({ name: 'backup-list', params: { id: this.id } })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,136 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { isEmptyValue } from '@/helpers/commons'
|
||||||
|
import { readableDate } from '@/helpers/filters/date'
|
||||||
|
import { humanSize } from '@/helpers/filters/human'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useStore()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const queries = [['GET', `backups/${props.name}?with_details`]]
|
||||||
|
const selected = ref<string[]>([])
|
||||||
|
const error = ref('')
|
||||||
|
const isValid = ref<boolean | null>(null)
|
||||||
|
const infos = ref()
|
||||||
|
const apps = ref()
|
||||||
|
const system = ref()
|
||||||
|
|
||||||
|
const hasBackupData = computed(() => {
|
||||||
|
return !isEmptyValue(system.value) || !isEmptyValue(apps.value)
|
||||||
|
})
|
||||||
|
|
||||||
|
function formatHooks(hooks) {
|
||||||
|
const data = {}
|
||||||
|
Object.entries(hooks).forEach(([hook, { size }]) => {
|
||||||
|
const groupId = hook.startsWith('conf_')
|
||||||
|
? 'adminjs_group_configuration'
|
||||||
|
: hook
|
||||||
|
if (groupId in data) {
|
||||||
|
data[groupId].value.push(hook)
|
||||||
|
data[groupId].description += ', ' + t('hook_' + hook)
|
||||||
|
data[groupId].size += size
|
||||||
|
} else {
|
||||||
|
data[groupId] = {
|
||||||
|
name: t('hook_' + groupId),
|
||||||
|
value: [hook],
|
||||||
|
description: t(groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook),
|
||||||
|
size,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
function onQueriesResponse(data) {
|
||||||
|
infos.value = {
|
||||||
|
name: props.name,
|
||||||
|
created_at: data.created_at,
|
||||||
|
size: data.size,
|
||||||
|
path: data.path,
|
||||||
|
}
|
||||||
|
system.value = formatHooks(data.system)
|
||||||
|
apps.value = data.apps
|
||||||
|
|
||||||
|
toggleSelected()
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSelected(select = true) {
|
||||||
|
if (select) {
|
||||||
|
selected.value = [...Object.keys(apps.value), ...Object.keys(system.value)]
|
||||||
|
} else {
|
||||||
|
selected.value = []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function restoreBackup() {
|
||||||
|
const confirmed = await modalConfirm(
|
||||||
|
t('confirm_restore', { name: props.name }),
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
const data = { apps: [], system: [], force: '' }
|
||||||
|
for (const item of selected.value) {
|
||||||
|
if (item in system.value) {
|
||||||
|
data.system = [...data.system, ...system.value[item].value]
|
||||||
|
} else {
|
||||||
|
data.apps.push(item)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(`backups/${props.name}/restore`, data, {
|
||||||
|
key: 'backups.restore',
|
||||||
|
name: props.name,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
isValid.value = null
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
|
error.value = err.message
|
||||||
|
isValid.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteBackup() {
|
||||||
|
const confirmed = await modalConfirm(
|
||||||
|
t('confirm_delete', { name: props.name }),
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api
|
||||||
|
.delete(
|
||||||
|
'backups/' + props.name,
|
||||||
|
{},
|
||||||
|
{ key: 'backups.delete', name: props.name },
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
router.push({ name: 'backup-list', params: { id: props.id } })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadBackup() {
|
||||||
|
const host = store.getters.host
|
||||||
|
window.open(
|
||||||
|
`https://${host}/yunohost/api/backups/${props.name}/download`,
|
||||||
|
'_blank',
|
||||||
|
)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase :queries="queries" @queries-response="onQueriesResponse">
|
<ViewBase :queries="queries" @queries-response="onQueriesResponse">
|
||||||
<!-- BACKUP INFO -->
|
<!-- BACKUP INFO -->
|
||||||
|
@ -106,7 +239,7 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
|
|
||||||
<BFormInvalidFeedback id="backup-restore-feedback" :state="isValid">
|
<BFormInvalidFeedback id="backup-restore-feedback" :state="isValid">
|
||||||
<BAlert variant="danger" class="mb-0">
|
<BAlert :modelValue="true" variant="danger" class="mb-0">
|
||||||
{{ error }}
|
{{ error }}
|
||||||
</BAlert>
|
</BAlert>
|
||||||
</BFormInvalidFeedback>
|
</BFormInvalidFeedback>
|
||||||
|
@ -134,150 +267,3 @@
|
||||||
</template>
|
</template>
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { readableDate } from '@/helpers/filters/date'
|
|
||||||
import { humanSize } from '@/helpers/filters/human'
|
|
||||||
import { isEmptyValue } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'BackupInfo',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, required: true },
|
|
||||||
name: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', `backups/${this.name}?with_details`]],
|
|
||||||
selected: [],
|
|
||||||
error: '',
|
|
||||||
isValid: null,
|
|
||||||
// api data
|
|
||||||
infos: undefined,
|
|
||||||
apps: undefined,
|
|
||||||
system: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
hasBackupData() {
|
|
||||||
return !isEmptyValue(this.system) || !isEmptyValue(this.apps)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
formatHooks(hooks) {
|
|
||||||
const data = {}
|
|
||||||
Object.entries(hooks).forEach(([hook, { size }]) => {
|
|
||||||
const groupId = hook.startsWith('conf_')
|
|
||||||
? 'adminjs_group_configuration'
|
|
||||||
: hook
|
|
||||||
if (groupId in data) {
|
|
||||||
data[groupId].value.push(hook)
|
|
||||||
data[groupId].description += ', ' + this.$t('hook_' + hook)
|
|
||||||
data[groupId].size += size
|
|
||||||
} else {
|
|
||||||
data[groupId] = {
|
|
||||||
name: this.$t('hook_' + groupId),
|
|
||||||
value: [hook],
|
|
||||||
description: this.$t(
|
|
||||||
groupId === hook ? `hook_${hook}_desc` : 'hook_' + hook,
|
|
||||||
),
|
|
||||||
size,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
return data
|
|
||||||
},
|
|
||||||
|
|
||||||
onQueriesResponse(data) {
|
|
||||||
this.infos = {
|
|
||||||
name: this.name,
|
|
||||||
created_at: data.created_at,
|
|
||||||
size: data.size,
|
|
||||||
path: data.path,
|
|
||||||
}
|
|
||||||
this.system = this.formatHooks(data.system)
|
|
||||||
this.apps = data.apps
|
|
||||||
|
|
||||||
this.toggleSelected()
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleSelected(select = true) {
|
|
||||||
if (select) {
|
|
||||||
this.selected = [...Object.keys(this.apps), ...Object.keys(this.system)]
|
|
||||||
} else {
|
|
||||||
this.selected = []
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async restoreBackup() {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_restore', { name: this.name }),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
const data = { apps: [], system: [], force: '' }
|
|
||||||
for (const item of this.selected) {
|
|
||||||
if (item in this.system) {
|
|
||||||
data.system = [...data.system, ...this.system[item].value]
|
|
||||||
} else {
|
|
||||||
data.apps.push(item)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(`backups/${this.name}/restore`, data, {
|
|
||||||
key: 'backups.restore',
|
|
||||||
name: this.name,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.isValid = null
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
this.error = err.message
|
|
||||||
this.isValid = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteBackup() {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_delete', { name: this.name }),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
api
|
|
||||||
.delete(
|
|
||||||
'backups/' + this.name,
|
|
||||||
{},
|
|
||||||
{ key: 'backups.delete', name: this.name },
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
this.$router.push({ name: 'backup-list', params: { id: this.id } })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
downloadBackup() {
|
|
||||||
const host = this.$store.getters.host
|
|
||||||
window.open(
|
|
||||||
`https://${host}/yunohost/api/backups/${this.name}/download`,
|
|
||||||
'_blank',
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
readableDate,
|
|
||||||
humanSize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,32 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import { distanceToNow, readableDate } from '@/helpers/filters/date'
|
||||||
|
import { humanSize } from '@/helpers/filters/human'
|
||||||
|
import type { Obj } from '@/types/commons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
id: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const queries = [['GET', 'backups?with_info']]
|
||||||
|
const archives = ref<Obj[] | null>(null)
|
||||||
|
|
||||||
|
function onQueriesResponse(data) {
|
||||||
|
const archives_ = Object.entries(data.archives)
|
||||||
|
if (archives_.length) {
|
||||||
|
archives.value = archives_
|
||||||
|
.map(([name, infos]) => {
|
||||||
|
infos.name = name
|
||||||
|
return infos
|
||||||
|
})
|
||||||
|
.reverse()
|
||||||
|
} else {
|
||||||
|
archives.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
|
@ -43,43 +72,3 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { distanceToNow, readableDate } from '@/helpers/filters/date'
|
|
||||||
import { humanSize } from '@/helpers/filters/human'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'BackupList',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
id: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', 'backups?with_info']],
|
|
||||||
archives: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(data) {
|
|
||||||
const archives = Object.entries(data.archives)
|
|
||||||
if (archives.length) {
|
|
||||||
this.archives = archives
|
|
||||||
.map(([name, infos]) => {
|
|
||||||
infos.name = name
|
|
||||||
return infos
|
|
||||||
})
|
|
||||||
.reverse()
|
|
||||||
} else {
|
|
||||||
this.archives = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceToNow,
|
|
||||||
readableDate,
|
|
||||||
humanSize,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -2,40 +2,18 @@
|
||||||
<div>
|
<div>
|
||||||
<BListGroup>
|
<BListGroup>
|
||||||
<BListGroupItem
|
<BListGroupItem
|
||||||
v-for="{ id, name, uri } in storages"
|
:to="{ name: 'backup-list', params: { id: 'local' } }"
|
||||||
:key="id"
|
|
||||||
:to="{ name: 'backup-list', params: { id } }"
|
|
||||||
class="d-flex justify-content-between align-items-center pe-0"
|
class="d-flex justify-content-between align-items-center pe-0"
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
<h5 class="fw-bold">
|
<h5 class="fw-bold">
|
||||||
{{ name }}
|
{{ $t('local_archives') }}
|
||||||
<small class="text-secondary">{{ id }}</small>
|
<small class="text-secondary">local</small>
|
||||||
</h5>
|
</h5>
|
||||||
<p class="m-0">
|
<p class="m-0">/home/yunohost.backup/</p>
|
||||||
{{ uri }}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
<YIcon iname="chevron-right" class="lg fs-sm ms-auto" />
|
<YIcon iname="chevron-right" class="lg fs-sm ms-auto" />
|
||||||
</BListGroupItem>
|
</BListGroupItem>
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'BackupView',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
storages: [
|
|
||||||
{
|
|
||||||
id: 'local',
|
|
||||||
name: this.$t('local_archives'),
|
|
||||||
uri: '/home/yunohost.backup/',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,9 +1,109 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { distanceToNow } from '@/helpers/filters/date'
|
||||||
|
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['PUT', 'diagnosis/run?except_if_never_ran_yet', {}, 'diagnosis.run'],
|
||||||
|
['GET', 'diagnosis?full'],
|
||||||
|
]
|
||||||
|
const { dark } = useStoreGetters()
|
||||||
|
|
||||||
|
const reports = ref()
|
||||||
|
|
||||||
|
function onQueriesResponse(_, reportsData) {
|
||||||
|
if (reportsData === null) {
|
||||||
|
reports.value = null
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const reports_ = reportsData.reports
|
||||||
|
for (const report of reports_) {
|
||||||
|
report.warnings = 0
|
||||||
|
report.errors = 0
|
||||||
|
report.ignoreds = 0
|
||||||
|
|
||||||
|
for (const item of report.items) {
|
||||||
|
const status = (item.variant = item.status.toLowerCase())
|
||||||
|
item.icon = DEFAULT_STATUS_ICON[status]
|
||||||
|
item.issue = false
|
||||||
|
|
||||||
|
if (item.ignored) {
|
||||||
|
report.ignoreds++
|
||||||
|
}
|
||||||
|
if (status === 'warning') {
|
||||||
|
item.issue = true
|
||||||
|
if (!item.ignored) {
|
||||||
|
report.warnings++
|
||||||
|
}
|
||||||
|
} else if (status === 'error') {
|
||||||
|
item.variant = 'danger'
|
||||||
|
item.issue = true
|
||||||
|
if (!item.ignored) {
|
||||||
|
report.errors++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report.noIssues = report.warnings + report.errors === 0
|
||||||
|
}
|
||||||
|
reports.value = reports_
|
||||||
|
}
|
||||||
|
|
||||||
|
function runDiagnosis({ id = null, description } = {}) {
|
||||||
|
const param = id !== null ? '?force' : ''
|
||||||
|
const data = id !== null ? { categories: [id] } : {}
|
||||||
|
|
||||||
|
api
|
||||||
|
.put('diagnosis/run' + param, data, {
|
||||||
|
key: 'diagnosis.run' + (id !== null ? '_specific' : ''),
|
||||||
|
description,
|
||||||
|
})
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleIgnoreIssue(action, report, item) {
|
||||||
|
const filterArgs = [report.id].concat(
|
||||||
|
Object.entries(item.meta).map((entries) => entries.join('=')),
|
||||||
|
)
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
'diagnosis/' + action,
|
||||||
|
{ filter: filterArgs },
|
||||||
|
`diagnosis.${action}.${item.status.toLowerCase()}`,
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
item.ignored = action === 'ignore'
|
||||||
|
if (item.ignored) {
|
||||||
|
report[item.status.toLowerCase() + 's']--
|
||||||
|
report.ignoreds++
|
||||||
|
} else {
|
||||||
|
report[item.status.toLowerCase() + 's']++
|
||||||
|
report.ignoreds--
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLogs() {
|
||||||
|
api.get('diagnosis?share').then(({ url }) => {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
@queries-response="onQueriesResponse"
|
@queries-response="onQueriesResponse"
|
||||||
queries-wait
|
queries-wait
|
||||||
ref="view"
|
ref="viewElem"
|
||||||
>
|
>
|
||||||
<template #top-bar-group-right>
|
<template #top-bar-group-right>
|
||||||
<BButton @click="shareLogs" variant="success">
|
<BButton @click="shareLogs" variant="success">
|
||||||
|
@ -145,116 +245,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
import { distanceToNow } from '@/helpers/filters/date'
|
|
||||||
import { DEFAULT_STATUS_ICON } from '@/helpers/yunohostArguments'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DiagnosisView',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['PUT', 'diagnosis/run?except_if_never_ran_yet', {}, 'diagnosis.run'],
|
|
||||||
['GET', 'diagnosis?full'],
|
|
||||||
],
|
|
||||||
reports: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['dark']),
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(_, reportsData) {
|
|
||||||
if (reportsData === null) {
|
|
||||||
this.reports = null
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const reports = reportsData.reports
|
|
||||||
for (const report of reports) {
|
|
||||||
report.warnings = 0
|
|
||||||
report.errors = 0
|
|
||||||
report.ignoreds = 0
|
|
||||||
|
|
||||||
for (const item of report.items) {
|
|
||||||
const status = (item.variant = item.status.toLowerCase())
|
|
||||||
item.icon = DEFAULT_STATUS_ICON[status]
|
|
||||||
item.issue = false
|
|
||||||
|
|
||||||
if (item.ignored) {
|
|
||||||
report.ignoreds++
|
|
||||||
}
|
|
||||||
if (status === 'warning') {
|
|
||||||
item.issue = true
|
|
||||||
if (!item.ignored) {
|
|
||||||
report.warnings++
|
|
||||||
}
|
|
||||||
} else if (status === 'error') {
|
|
||||||
item.variant = 'danger'
|
|
||||||
item.issue = true
|
|
||||||
if (!item.ignored) {
|
|
||||||
report.errors++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
report.noIssues = report.warnings + report.errors === 0
|
|
||||||
}
|
|
||||||
this.reports = reports
|
|
||||||
},
|
|
||||||
|
|
||||||
runDiagnosis({ id = null, description } = {}) {
|
|
||||||
const param = id !== null ? '?force' : ''
|
|
||||||
const data = id !== null ? { categories: [id] } : {}
|
|
||||||
|
|
||||||
api
|
|
||||||
.put('diagnosis/run' + param, data, {
|
|
||||||
key: 'diagnosis.run' + (id !== null ? '_specific' : ''),
|
|
||||||
description,
|
|
||||||
})
|
|
||||||
.then(this.$refs.view.fetchQueries)
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleIgnoreIssue(action, report, item) {
|
|
||||||
const filterArgs = [report.id].concat(
|
|
||||||
Object.entries(item.meta).map((entries) => entries.join('=')),
|
|
||||||
)
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
'diagnosis/' + action,
|
|
||||||
{ filter: filterArgs },
|
|
||||||
`diagnosis.${action}.${item.status.toLowerCase()}`,
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
item.ignored = action === 'ignore'
|
|
||||||
if (item.ignored) {
|
|
||||||
report[item.status.toLowerCase() + 's']--
|
|
||||||
report.ignoreds++
|
|
||||||
} else {
|
|
||||||
report[item.status.toLowerCase() + 's']++
|
|
||||||
report.ignoreds--
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
shareLogs() {
|
|
||||||
api.get('diagnosis?share').then(({ url }) => {
|
|
||||||
window.open(url, '_blank')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceToNow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.badge + .badge {
|
.badge + .badge {
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
|
|
|
@ -1,3 +1,32 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
|
import { DomainForm } from '@/views/_partials'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const queries = [['GET', { uri: 'domains' }]]
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function onSubmit(data) {
|
||||||
|
api
|
||||||
|
.post('domains', data, { key: 'domains.add', name: data.domain })
|
||||||
|
.then(() => {
|
||||||
|
store.dispatch('RESET_CACHE_DATA', ['domains'])
|
||||||
|
router.push({ name: 'domain-list' })
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
|
serverError.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase :queries="queries" skeleton="CardFormSkeleton">
|
<ViewBase :queries="queries" skeleton="CardFormSkeleton">
|
||||||
<DomainForm
|
<DomainForm
|
||||||
|
@ -8,36 +37,3 @@
|
||||||
/>
|
/>
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { DomainForm } from '@/views/_partials'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DomainAdd',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', { uri: 'domains' }]],
|
|
||||||
serverError: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onSubmit(data) {
|
|
||||||
api
|
|
||||||
.post('domains', data, { key: 'domains.add', name: data.domain })
|
|
||||||
.then(() => {
|
|
||||||
this.$store.dispatch('RESET_CACHE_DATA', ['domains'])
|
|
||||||
this.$router.push({ name: 'domain-list' })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
this.serverError = err.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
components: { DomainForm },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,133 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { isEmptyValue } from '@/helpers/commons'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const queries = [['GET', `domains/${props.name}/dns/suggest`]]
|
||||||
|
const loading = ref(true)
|
||||||
|
const showAutoConfigCard = ref(true)
|
||||||
|
const showManualConfigCard = ref(false)
|
||||||
|
const dnsConfig = ref('')
|
||||||
|
const dnsChanges = ref(undefined)
|
||||||
|
const dnsErrors = ref(undefined)
|
||||||
|
const dnsZone = ref(undefined)
|
||||||
|
const force = ref(null)
|
||||||
|
|
||||||
|
getDnsChanges()
|
||||||
|
|
||||||
|
function onQueriesResponse(suggestedConfig) {
|
||||||
|
dnsConfig.value = suggestedConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDnsChanges() {
|
||||||
|
loading.value = true
|
||||||
|
|
||||||
|
return api
|
||||||
|
.post(`domains/${props.name}/dns/push?dry_run`, {}, null, {
|
||||||
|
wait: false,
|
||||||
|
websocket: false,
|
||||||
|
})
|
||||||
|
.then((dnsChanges) => {
|
||||||
|
function getLongest(arr, key) {
|
||||||
|
return arr.reduce((acc, obj) => {
|
||||||
|
if (obj[key].length > acc) return obj[key].length
|
||||||
|
return acc
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const changes = []
|
||||||
|
let canForce = false
|
||||||
|
const categories = [
|
||||||
|
{ action: 'create', icon: 'plus', variant: 'success' },
|
||||||
|
{ action: 'update', icon: 'exchange', variant: 'warning' },
|
||||||
|
{ action: 'delete', icon: 'minus', variant: 'danger' },
|
||||||
|
]
|
||||||
|
categories.forEach((category) => {
|
||||||
|
const records = dnsChanges[category.action]
|
||||||
|
if (records && records.length > 0) {
|
||||||
|
const longestName = getLongest(records, 'name')
|
||||||
|
const longestType = getLongest(records, 'type')
|
||||||
|
records.forEach((record) => {
|
||||||
|
record.name =
|
||||||
|
record.name + ' '.repeat(longestName - record.name.length + 1)
|
||||||
|
record.spaces = ' '.repeat(longestType - record.type.length + 1)
|
||||||
|
if (record.managed_by_yunohost === false) canForce = true
|
||||||
|
})
|
||||||
|
changes.push({ ...category, records })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const unchanged = dnsChanges.unchanged
|
||||||
|
if (unchanged) {
|
||||||
|
const longestName = getLongest(unchanged, 'name')
|
||||||
|
const longestType = getLongest(unchanged, 'type')
|
||||||
|
unchanged.forEach((record) => {
|
||||||
|
record.name =
|
||||||
|
record.name + ' '.repeat(longestName - record.name.length + 1)
|
||||||
|
record.spaces = ' '.repeat(longestType - record.type.length + 1)
|
||||||
|
})
|
||||||
|
dnsZone.value = unchanged
|
||||||
|
}
|
||||||
|
|
||||||
|
dnsChanges.value = changes.length > 0 ? changes : null
|
||||||
|
force.value = canForce ? false : null
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name !== 'APIBadRequestError') throw err
|
||||||
|
const key = err.data.error_key
|
||||||
|
if (key === 'domain_dns_push_managed_in_parent_domain') {
|
||||||
|
const message = t(key, err.data)
|
||||||
|
dnsErrors.value = [{ icon: 'info', variant: 'info', message }]
|
||||||
|
} else if (key === 'domain_dns_push_failed_to_authenticate') {
|
||||||
|
const message = t(key, err.data)
|
||||||
|
dnsErrors.value = [{ icon: 'ban', variant: 'danger', message }]
|
||||||
|
} else {
|
||||||
|
showManualConfigCard.value = true
|
||||||
|
showAutoConfigCard.value = false
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function pushDnsChanges() {
|
||||||
|
if (force.value) {
|
||||||
|
const confirmed = await modalConfirm(t('domain.dns.push_force_confirm'))
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.post(
|
||||||
|
`domains/${props.name}/dns/push${force.value ? '?force' : ''}`,
|
||||||
|
{},
|
||||||
|
{ key: 'domains.push_dns_changes', name: props.name },
|
||||||
|
)
|
||||||
|
.then(async (responseData) => {
|
||||||
|
await getDnsChanges()
|
||||||
|
if (!isEmptyValue(responseData)) {
|
||||||
|
dnsErrors.value = Object.keys(responseData).reduce((acc, key) => {
|
||||||
|
const args =
|
||||||
|
key === 'warnings'
|
||||||
|
? { icon: 'warning', variant: 'warning' }
|
||||||
|
: { icon: 'ban', variant: 'danger' }
|
||||||
|
responseData[key].forEach((message) => acc.push({ ...args, message }))
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
|
@ -143,152 +273,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { isEmptyValue } from '@/helpers/commons'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DomainDns',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
name: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', `domains/${this.name}/dns/suggest`]],
|
|
||||||
loading: true,
|
|
||||||
showAutoConfigCard: true,
|
|
||||||
showManualConfigCard: false,
|
|
||||||
dnsConfig: '',
|
|
||||||
dnsChanges: undefined,
|
|
||||||
dnsErrors: undefined,
|
|
||||||
dnsZone: undefined,
|
|
||||||
force: null,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(suggestedConfig) {
|
|
||||||
this.dnsConfig = suggestedConfig
|
|
||||||
},
|
|
||||||
|
|
||||||
getDnsChanges() {
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
return api
|
|
||||||
.post(`domains/${this.name}/dns/push?dry_run`, {}, null, {
|
|
||||||
wait: false,
|
|
||||||
websocket: false,
|
|
||||||
})
|
|
||||||
.then((dnsChanges) => {
|
|
||||||
function getLongest(arr, key) {
|
|
||||||
return arr.reduce((acc, obj) => {
|
|
||||||
if (obj[key].length > acc) return obj[key].length
|
|
||||||
return acc
|
|
||||||
}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
const changes = []
|
|
||||||
let canForce = false
|
|
||||||
const categories = [
|
|
||||||
{ action: 'create', icon: 'plus', variant: 'success' },
|
|
||||||
{ action: 'update', icon: 'exchange', variant: 'warning' },
|
|
||||||
{ action: 'delete', icon: 'minus', variant: 'danger' },
|
|
||||||
]
|
|
||||||
categories.forEach((category) => {
|
|
||||||
const records = dnsChanges[category.action]
|
|
||||||
if (records && records.length > 0) {
|
|
||||||
const longestName = getLongest(records, 'name')
|
|
||||||
const longestType = getLongest(records, 'type')
|
|
||||||
records.forEach((record) => {
|
|
||||||
record.name =
|
|
||||||
record.name + ' '.repeat(longestName - record.name.length + 1)
|
|
||||||
record.spaces = ' '.repeat(longestType - record.type.length + 1)
|
|
||||||
if (record.managed_by_yunohost === false) canForce = true
|
|
||||||
})
|
|
||||||
changes.push({ ...category, records })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
const unchanged = dnsChanges.unchanged
|
|
||||||
if (unchanged) {
|
|
||||||
const longestName = getLongest(unchanged, 'name')
|
|
||||||
const longestType = getLongest(unchanged, 'type')
|
|
||||||
unchanged.forEach((record) => {
|
|
||||||
record.name =
|
|
||||||
record.name + ' '.repeat(longestName - record.name.length + 1)
|
|
||||||
record.spaces = ' '.repeat(longestType - record.type.length + 1)
|
|
||||||
})
|
|
||||||
this.dnsZone = unchanged
|
|
||||||
}
|
|
||||||
|
|
||||||
this.dnsChanges = changes.length > 0 ? changes : null
|
|
||||||
this.force = canForce ? false : null
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
const key = err.data.error_key
|
|
||||||
if (key === 'domain_dns_push_managed_in_parent_domain') {
|
|
||||||
const message = this.$t(key, err.data)
|
|
||||||
this.dnsErrors = [{ icon: 'info', variant: 'info', message }]
|
|
||||||
} else if (key === 'domain_dns_push_failed_to_authenticate') {
|
|
||||||
const message = this.$t(key, err.data)
|
|
||||||
this.dnsErrors = [{ icon: 'ban', variant: 'danger', message }]
|
|
||||||
} else {
|
|
||||||
this.showManualConfigCard = true
|
|
||||||
this.showAutoConfigCard = false
|
|
||||||
}
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async pushDnsChanges() {
|
|
||||||
if (this.force) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('domain.dns.push_force_confirm'),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
|
|
||||||
api
|
|
||||||
.post(
|
|
||||||
`domains/${this.name}/dns/push${this.force ? '?force' : ''}`,
|
|
||||||
{},
|
|
||||||
{ key: 'domains.push_dns_changes', name: this.name },
|
|
||||||
)
|
|
||||||
.then(async (responseData) => {
|
|
||||||
await this.getDnsChanges()
|
|
||||||
if (!isEmptyValue(responseData)) {
|
|
||||||
this.dnsErrors = Object.keys(responseData).reduce((acc, key) => {
|
|
||||||
const args =
|
|
||||||
key === 'warnings'
|
|
||||||
? { icon: 'warning', variant: 'warning' }
|
|
||||||
: { icon: 'ban', variant: 'danger' }
|
|
||||||
responseData[key].forEach((message) =>
|
|
||||||
acc.push({ ...args, message }),
|
|
||||||
)
|
|
||||||
return acc
|
|
||||||
}, [])
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
created() {
|
|
||||||
this.getDnsChanges()
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.records {
|
.records {
|
||||||
white-space: pre;
|
white-space: pre;
|
||||||
|
|
|
@ -1,8 +1,159 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import api, { objectToParams } from '@/api'
|
||||||
|
import ConfigPanels from '@/components/ConfigPanels.vue'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import {
|
||||||
|
formatFormData,
|
||||||
|
formatYunoHostConfigPanels,
|
||||||
|
} from '@/helpers/yunohostArguments'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
import DomainDns from '@/views/domain/DomainDns.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const store = useStore()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
const { mainDomain } = useStoreGetters()
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', { uri: 'domains', storeKey: 'domains' }],
|
||||||
|
['GET', { uri: 'domains', storeKey: 'domains_details', param: props.name }],
|
||||||
|
['GET', `domains/${props.name}/config?full`],
|
||||||
|
]
|
||||||
|
const config = ref({})
|
||||||
|
const externalResults = reactive({})
|
||||||
|
const unsubscribeDomainFromDyndns = ref(false)
|
||||||
|
|
||||||
|
const currentTab = computed(() => {
|
||||||
|
return route.params.tabId
|
||||||
|
})
|
||||||
|
|
||||||
|
const domain = computed(() => {
|
||||||
|
return store.getters.domain(props.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const parentName = computed(() => {
|
||||||
|
return store.getters.highestDomainParentName(props.name)
|
||||||
|
})
|
||||||
|
|
||||||
|
const cert = computed(() => {
|
||||||
|
const { CA_type: authority, validity } = domain.value.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 }
|
||||||
|
})
|
||||||
|
|
||||||
|
const dns = computed(() => {
|
||||||
|
return domain.value.dns
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMainDomain = computed(() => {
|
||||||
|
if (!mainDomain.value) return
|
||||||
|
return props.name === mainDomain.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const isMainDynDomain = computed(() => {
|
||||||
|
return (
|
||||||
|
domain.value.registrar === 'yunohost' && props.name.split('.').length === 3
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function onQueriesResponse(domains, domain, config_) {
|
||||||
|
config.value = formatYunoHostConfigPanels(config_)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onConfigSubmit({ id, form, action, name }) {
|
||||||
|
const args = await formatFormData(form, {
|
||||||
|
removeEmpty: false,
|
||||||
|
removeNull: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
action
|
||||||
|
? `domain/${props.name}/actions/${action}`
|
||||||
|
: `domains/${props.name}/config/${id}`,
|
||||||
|
{ args: objectToParams(args) },
|
||||||
|
{
|
||||||
|
key: `domains.${action ? 'action' : 'update'}_config`,
|
||||||
|
id,
|
||||||
|
name: props.name,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.then(() => viewElem.value!.fetchQueries({ triggerLoading: true }))
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name !== 'APIBadRequestError') throw err
|
||||||
|
const panel = config.value.panels.find((panel) => panel.id === id)
|
||||||
|
if (err.data.name) {
|
||||||
|
Object.assign(externalResults, {
|
||||||
|
forms: { [panel.id]: { [err.data.name]: [err.data.error] } },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
panel.serverError = err.message
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteDomain() {
|
||||||
|
const data =
|
||||||
|
isMainDynDomain.value && !unsubscribeDomainFromDyndns.value
|
||||||
|
? { ignore_dyndns: 1 }
|
||||||
|
: {}
|
||||||
|
|
||||||
|
api
|
||||||
|
.delete({ uri: 'domains', param: props.name }, data, {
|
||||||
|
key: 'domains.delete',
|
||||||
|
name: props.name,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push({ name: 'domain-list' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setAsDefaultDomain() {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_change_maindomain'))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
{ uri: `domains/${props.name}/main`, storeKey: 'main_domain' },
|
||||||
|
{},
|
||||||
|
{ key: 'domains.set_default', name: props.name },
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
// FIXME Have to commit by hand here since the response is empty (should return the given name)
|
||||||
|
store.commit('UPDATE_MAIN_DOMAIN', props.name)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
@queries-response="onQueriesResponse"
|
@queries-response="onQueriesResponse"
|
||||||
ref="view"
|
ref="viewElem"
|
||||||
skeleton="CardListSkeleton"
|
skeleton="CardListSkeleton"
|
||||||
>
|
>
|
||||||
<!-- INFO CARD -->
|
<!-- INFO CARD -->
|
||||||
|
@ -122,7 +273,7 @@
|
||||||
<BModal
|
<BModal
|
||||||
v-if="domain"
|
v-if="domain"
|
||||||
id="delete-modal"
|
id="delete-modal"
|
||||||
:title="$t('confirm_delete', { name: this.name })"
|
:title="$t('confirm_delete', { name: props.name })"
|
||||||
@ok="deleteDomain"
|
@ok="deleteDomain"
|
||||||
header-bg-variant="warning"
|
header-bg-variant="warning"
|
||||||
header-class="text-black"
|
header-class="text-black"
|
||||||
|
@ -137,175 +288,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import api, { objectToParams } from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import {
|
|
||||||
formatFormData,
|
|
||||||
formatYunoHostConfigPanels,
|
|
||||||
} from '@/helpers/yunohostArguments'
|
|
||||||
import ConfigPanels from '@/components/ConfigPanels.vue'
|
|
||||||
import DomainDns from './DomainDns.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DomainInfo',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
ConfigPanels,
|
|
||||||
DomainDns,
|
|
||||||
},
|
|
||||||
|
|
||||||
props: {
|
|
||||||
name: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', { uri: 'domains', storeKey: 'domains' }],
|
|
||||||
[
|
|
||||||
'GET',
|
|
||||||
{ uri: 'domains', storeKey: 'domains_details', param: this.name },
|
|
||||||
],
|
|
||||||
['GET', `domains/${this.name}/config?full`],
|
|
||||||
],
|
|
||||||
config: {},
|
|
||||||
externalResults: {},
|
|
||||||
unsubscribeDomainFromDyndns: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
|
|
||||||
isMainDynDomain() {
|
|
||||||
return (
|
|
||||||
this.domain.registrar === 'yunohost' &&
|
|
||||||
this.name.split('.').length === 3
|
|
||||||
)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(domains, 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) {
|
|
||||||
Object.assign(this.externalResults, {
|
|
||||||
forms: { [panel.id]: { [err.data.name]: [err.data.error] } },
|
|
||||||
})
|
|
||||||
} else {
|
|
||||||
panel.serverError = err.message
|
|
||||||
}
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteDomain() {
|
|
||||||
const data =
|
|
||||||
this.isMainDynDomain && !this.unsubscribeDomainFromDyndns
|
|
||||||
? { ignore_dyndns: 1 }
|
|
||||||
: {}
|
|
||||||
|
|
||||||
api
|
|
||||||
.delete({ uri: 'domains', param: this.name }, data, {
|
|
||||||
key: 'domains.delete',
|
|
||||||
name: this.name,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.$router.push({ name: 'domain-list' })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async setAsDefaultDomain() {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_change_maindomain'),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
{ uri: `domains/${this.name}/main`, storeKey: 'main_domain' },
|
|
||||||
{},
|
|
||||||
{ key: 'domains.set_default', name: this.name },
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
// FIXME Have to commit by hand here since the response is empty (should return the given name)
|
|
||||||
this.$store.commit('UPDATE_MAIN_DOMAIN', this.name)
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.main-domain-badge {
|
.main-domain-badge {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
|
|
|
@ -1,3 +1,30 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import RecursiveListGroup from '@/components/RecursiveListGroup.vue'
|
||||||
|
|
||||||
|
const { domains, mainDomain, domainsTree } = useStoreGetters()
|
||||||
|
|
||||||
|
const queries = [['GET', { uri: 'domains', storeKey: 'domains' }]]
|
||||||
|
const search = ref('')
|
||||||
|
|
||||||
|
const tree = computed(() => {
|
||||||
|
// FIXME rm ts type when moved to pinia or else
|
||||||
|
if (!domainsTree.value) return
|
||||||
|
const search_ = search.value.toLowerCase()
|
||||||
|
if (search_) {
|
||||||
|
return domainsTree.value.filter((node) => node.id.includes(search_))
|
||||||
|
}
|
||||||
|
return domainsTree.value
|
||||||
|
})
|
||||||
|
|
||||||
|
const hasFilteredItems = computed(() => {
|
||||||
|
if (!tree.value) return null
|
||||||
|
return tree.value.children.length ? tree.value.children : null
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch
|
||||||
id="domain-list"
|
id="domain-list"
|
||||||
|
@ -19,14 +46,17 @@
|
||||||
:toggle-text="$t('domain.toggle_subdomains')"
|
:toggle-text="$t('domain.toggle_subdomains')"
|
||||||
class="mb-5"
|
class="mb-5"
|
||||||
>
|
>
|
||||||
|
<!-- FIXME slot typing not appearing? -->
|
||||||
<template #default="{ data, parent }">
|
<template #default="{ data, parent }">
|
||||||
<div class="w-100 d-flex justify-content-between align-items-center">
|
<div class="w-100 d-flex justify-content-between align-items-center">
|
||||||
<h5 class="me-3">
|
<h5 class="me-3">
|
||||||
<BLink :to="data.to" class="text-body text-decoration-none">
|
<BLink :to="data.to" class="text-body text-decoration-none">
|
||||||
<span class="fw-bold">
|
<span class="fw-bold">
|
||||||
{{ data.name.replace(parent ? parent.data.name : null, '') }}
|
{{
|
||||||
|
data.name.replace(parent?.data ? parent.data.name : null, '')
|
||||||
|
}}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="parent" class="text-secondary">
|
<span v-if="parent?.data" class="text-secondary">
|
||||||
{{ parent.data.name }}
|
{{ parent.data.name }}
|
||||||
</span>
|
</span>
|
||||||
</BLink>
|
</BLink>
|
||||||
|
@ -45,44 +75,3 @@
|
||||||
</RecursiveListGroup>
|
</RecursiveListGroup>
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import RecursiveListGroup from '@/components/RecursiveListGroup.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'DomainList',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
RecursiveListGroup,
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', { uri: 'domains', storeKey: 'domains' }]],
|
|
||||||
search: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['domains', 'mainDomain', 'domainsTree']),
|
|
||||||
|
|
||||||
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
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,43 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { APIBadRequestError, APIError } from '@/api/errors'
|
||||||
|
import { alphalownumdot_, required } from '@/helpers/validators'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const form = reactive({ groupname: '' })
|
||||||
|
const v$ = useVuelidate({ groupname: { required, alphalownumdot_ } }, form)
|
||||||
|
const serverError = ref('')
|
||||||
|
const groupnameField = {
|
||||||
|
label: t('group_name'),
|
||||||
|
description: t('group_format_name_help'),
|
||||||
|
props: {
|
||||||
|
id: 'groupname',
|
||||||
|
placeholder: t('placeholder.groupname'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
function onSubmit() {
|
||||||
|
api
|
||||||
|
.post({ uri: 'users/groups', storeKey: 'groups' }, form, {
|
||||||
|
key: 'groups.create',
|
||||||
|
name: form.groupname,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push({ name: 'group-list' })
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
|
serverError.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardForm
|
<CardForm
|
||||||
:title="$t('group_new')"
|
:title="$t('group_new')"
|
||||||
|
@ -8,66 +48,9 @@
|
||||||
>
|
>
|
||||||
<!-- GROUP NAME -->
|
<!-- GROUP NAME -->
|
||||||
<FormField
|
<FormField
|
||||||
v-bind="groupname"
|
v-bind="groupnameField"
|
||||||
v-model="form.groupname"
|
v-model="form.groupname"
|
||||||
:validation="v$.form.groupname"
|
:validation="v$.form.groupname"
|
||||||
/>
|
/>
|
||||||
</CardForm>
|
</CardForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
import { required, alphalownumdot_ } from '@/helpers/validators'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'GroupCreate',
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
groupname: '',
|
|
||||||
},
|
|
||||||
serverError: '',
|
|
||||||
groupname: {
|
|
||||||
label: this.$t('group_name'),
|
|
||||||
description: this.$t('group_format_name_help'),
|
|
||||||
props: {
|
|
||||||
id: 'groupname',
|
|
||||||
placeholder: this.$t('placeholder.groupname'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
validations: {
|
|
||||||
form: {
|
|
||||||
groupname: { required, alphalownumdot_ },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onSubmit() {
|
|
||||||
api
|
|
||||||
.post({ uri: 'users/groups', storeKey: 'groups' }, this.form, {
|
|
||||||
key: 'groups.create',
|
|
||||||
name: this.form.groupname,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.$router.push({ name: 'group-list' })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
this.serverError = err.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,185 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import TagsSelectizeItem from '@/components/globals/formItems/TagsSelectizeItem.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { isEmptyValue } from '@/helpers/commons'
|
||||||
|
|
||||||
|
// TODO add global search with type (search by: group, user, permission)
|
||||||
|
// TODO add vuex store update on inputs ?
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', { uri: 'users' }],
|
||||||
|
[
|
||||||
|
'GET',
|
||||||
|
{
|
||||||
|
uri: 'users/groups?full&include_primary_groups',
|
||||||
|
storeKey: 'groups',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
|
||||||
|
]
|
||||||
|
const search = ref('')
|
||||||
|
const permissions = ref()
|
||||||
|
const permissionsOptions = ref()
|
||||||
|
const primaryGroups = ref()
|
||||||
|
const userGroups = ref()
|
||||||
|
const usersOptions = ref()
|
||||||
|
const activeUserGroups = ref()
|
||||||
|
|
||||||
|
const filteredGroups = computed(() => {
|
||||||
|
const groups = primaryGroups.value
|
||||||
|
if (!groups) return
|
||||||
|
const search_ = search.value.toLowerCase()
|
||||||
|
const filtered = {}
|
||||||
|
for (const groupName in groups) {
|
||||||
|
if (groupName.toLowerCase().includes(search_)) {
|
||||||
|
filtered[groupName] = groups[groupName]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return isEmptyValue(filtered) ? null : filtered
|
||||||
|
})
|
||||||
|
|
||||||
|
function onQueriesResponse(users, allGroups, permsDict) {
|
||||||
|
// Do not use computed properties to get values from the store here to avoid auto
|
||||||
|
// updates while modifying values.
|
||||||
|
const permissions_ = Object.entries(permsDict).map(([id, value]) => ({
|
||||||
|
id,
|
||||||
|
...value,
|
||||||
|
}))
|
||||||
|
const userNames = users ? Object.keys(users) : []
|
||||||
|
const primaryGroups_ = {}
|
||||||
|
const userGroups_ = {}
|
||||||
|
|
||||||
|
for (const groupName in allGroups) {
|
||||||
|
// copy the group to unlink it from the store
|
||||||
|
const group_ = { ...allGroups[groupName] }
|
||||||
|
group_.permissions = group_.permissions.map((perm) => {
|
||||||
|
return permsDict[perm].label
|
||||||
|
})
|
||||||
|
|
||||||
|
if (userNames.includes(groupName)) {
|
||||||
|
userGroups_[groupName] = group_
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
group_.isSpecial = ['visitors', 'all_users', 'admins'].includes(groupName)
|
||||||
|
|
||||||
|
if (groupName === 'visitors') {
|
||||||
|
// Forbid to add or remove a protected permission on group `visitors`
|
||||||
|
group_.disabledItems = permissions_
|
||||||
|
.filter(({ id }) => {
|
||||||
|
return (
|
||||||
|
['mail.main', 'xmpp.main'].includes(id) || permsDict[id].protected
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.map(({ id }) => permsDict[id].label)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (groupName === 'all_users') {
|
||||||
|
// Forbid to add ssh and sftp permission on group `all_users`
|
||||||
|
group_.disabledItems = permissions_
|
||||||
|
.filter(({ id }) => {
|
||||||
|
return ['ssh.main', 'sftp.main'].includes(id)
|
||||||
|
})
|
||||||
|
.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_
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeUserGroups_ = Object.entries(userGroups_)
|
||||||
|
.filter(([_, group]) => {
|
||||||
|
return group.permissions.length > 0
|
||||||
|
})
|
||||||
|
.map(([name]) => name)
|
||||||
|
|
||||||
|
permissions.value = permissions_
|
||||||
|
permissionsOptions.value = permissions_.map((perm) => perm.label)
|
||||||
|
primaryGroups.value = primaryGroups_
|
||||||
|
userGroups.value = isEmptyValue(userGroups_) ? null : userGroups_
|
||||||
|
usersOptions.value = userNames
|
||||||
|
activeUserGroups.value = activeUserGroups_
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPermissionChanged({ option, groupName, action, applyMethod }) {
|
||||||
|
const permId = permissions.value.find((perm) => perm.label === option).id
|
||||||
|
if (action === 'add' && ['sftp.main', 'ssh.main'].includes(permId)) {
|
||||||
|
const confirmed = await modalConfirm(
|
||||||
|
t('confirm_group_add_access_permission', {
|
||||||
|
name: groupName,
|
||||||
|
perm: option,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
// FIXME hacky way to update the store
|
||||||
|
{
|
||||||
|
uri: `users/permissions/${permId}/${action}/${groupName}`,
|
||||||
|
storeKey: 'permissions',
|
||||||
|
groupName,
|
||||||
|
action,
|
||||||
|
permId,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ key: 'permissions.' + action, perm: option, name: groupName },
|
||||||
|
)
|
||||||
|
.then(() => applyMethod(option))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onUserChanged({ option, groupName, action, applyMethod }) {
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
{
|
||||||
|
uri: `users/groups/${groupName}/${action}/${option}`,
|
||||||
|
storeKey: 'groups',
|
||||||
|
groupName,
|
||||||
|
},
|
||||||
|
{},
|
||||||
|
{ key: 'groups.' + action, user: option, name: groupName },
|
||||||
|
)
|
||||||
|
.then(() => applyMethod(option))
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSpecificUserAdded({ option: userName, action, applyMethod }) {
|
||||||
|
if (action === 'add') {
|
||||||
|
userGroups.value[userName].permissions = []
|
||||||
|
applyMethod(userName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deleteGroup(groupName) {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_delete', { name: groupName }))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api
|
||||||
|
.delete(
|
||||||
|
{ uri: 'users/groups', param: groupName, storeKey: 'groups' },
|
||||||
|
{},
|
||||||
|
{ key: 'groups.delete', name: groupName },
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
delete primaryGroups.value[groupName]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch
|
||||||
items-name="groups"
|
items-name="groups"
|
||||||
|
@ -42,7 +224,6 @@
|
||||||
<BCol md="3" lg="2">
|
<BCol md="3" lg="2">
|
||||||
<strong>{{ $t('users') }}</strong>
|
<strong>{{ $t('users') }}</strong>
|
||||||
</BCol>
|
</BCol>
|
||||||
|
|
||||||
<BCol>
|
<BCol>
|
||||||
<template v-if="group.isSpecial">
|
<template v-if="group.isSpecial">
|
||||||
<p class="text-primary-emphasis">
|
<p class="text-primary-emphasis">
|
||||||
|
@ -132,210 +313,6 @@
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { isEmptyValue } from '@/helpers/commons'
|
|
||||||
import TagsSelectizeItem from '@/components/globals/formItems/TagsSelectizeItem.vue'
|
|
||||||
|
|
||||||
// TODO add global search with type (search by: group, user, permission)
|
|
||||||
// TODO add vuex store update on inputs ?
|
|
||||||
export default {
|
|
||||||
name: 'GroupList',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
TagsSelectizeItem,
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', { uri: 'users' }],
|
|
||||||
[
|
|
||||||
'GET',
|
|
||||||
{
|
|
||||||
uri: 'users/groups?full&include_primary_groups',
|
|
||||||
storeKey: 'groups',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
['GET', { uri: 'users/permissions?full', storeKey: 'permissions' }],
|
|
||||||
],
|
|
||||||
search: '',
|
|
||||||
permissions: undefined,
|
|
||||||
permissionsOptions: undefined,
|
|
||||||
primaryGroups: undefined,
|
|
||||||
userGroups: undefined,
|
|
||||||
usersOptions: undefined,
|
|
||||||
activeUserGroups: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
filteredGroups() {
|
|
||||||
const groups = this.primaryGroups
|
|
||||||
if (!groups) return
|
|
||||||
const search = this.search.toLowerCase()
|
|
||||||
const filtered = {}
|
|
||||||
for (const groupName in groups) {
|
|
||||||
if (groupName.toLowerCase().includes(search)) {
|
|
||||||
filtered[groupName] = groups[groupName]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return isEmptyValue(filtered) ? null : filtered
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(users, allGroups, permsDict) {
|
|
||||||
// Do not use computed properties to get values from the store here to avoid auto
|
|
||||||
// updates while modifying values.
|
|
||||||
const permissions = Object.entries(permsDict).map(([id, value]) => ({
|
|
||||||
id,
|
|
||||||
...value,
|
|
||||||
}))
|
|
||||||
const userNames = users ? Object.keys(users) : []
|
|
||||||
const primaryGroups = {}
|
|
||||||
const userGroups = {}
|
|
||||||
|
|
||||||
for (const groupName in allGroups) {
|
|
||||||
// copy the group to unlink it from the store
|
|
||||||
const group = { ...allGroups[groupName] }
|
|
||||||
group.permissions = group.permissions.map((perm) => {
|
|
||||||
return permsDict[perm].label
|
|
||||||
})
|
|
||||||
|
|
||||||
if (userNames.includes(groupName)) {
|
|
||||||
userGroups[groupName] = group
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
group.isSpecial = ['visitors', 'all_users', 'admins'].includes(
|
|
||||||
groupName,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (groupName === 'visitors') {
|
|
||||||
// Forbid to add or remove a protected permission on group `visitors`
|
|
||||||
group.disabledItems = permissions
|
|
||||||
.filter(({ id }) => {
|
|
||||||
return (
|
|
||||||
['mail.main', 'xmpp.main'].includes(id) ||
|
|
||||||
permsDict[id].protected
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.map(({ id }) => permsDict[id].label)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (groupName === 'all_users') {
|
|
||||||
// Forbid to add ssh and sftp permission on group `all_users`
|
|
||||||
group.disabledItems = permissions
|
|
||||||
.filter(({ id }) => {
|
|
||||||
return ['ssh.main', 'sftp.main'].includes(id)
|
|
||||||
})
|
|
||||||
.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
|
|
||||||
}
|
|
||||||
|
|
||||||
const activeUserGroups = Object.entries(userGroups)
|
|
||||||
.filter(([_, group]) => {
|
|
||||||
return group.permissions.length > 0
|
|
||||||
})
|
|
||||||
.map(([name]) => name)
|
|
||||||
|
|
||||||
Object.assign(this, {
|
|
||||||
permissions,
|
|
||||||
permissionsOptions: permissions.map((perm) => perm.label),
|
|
||||||
primaryGroups,
|
|
||||||
userGroups: isEmptyValue(userGroups) ? null : userGroups,
|
|
||||||
usersOptions: userNames,
|
|
||||||
activeUserGroups,
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
async onPermissionChanged({ option, groupName, action, applyMethod }) {
|
|
||||||
const permId = this.permissions.find((perm) => perm.label === option).id
|
|
||||||
if (action === 'add' && ['sftp.main', 'ssh.main'].includes(permId)) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_group_add_access_permission', {
|
|
||||||
name: groupName,
|
|
||||||
perm: option,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
// FIXME hacky way to update the store
|
|
||||||
{
|
|
||||||
uri: `users/permissions/${permId}/${action}/${groupName}`,
|
|
||||||
storeKey: 'permissions',
|
|
||||||
groupName,
|
|
||||||
action,
|
|
||||||
permId,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
{ key: 'permissions.' + action, perm: option, name: groupName },
|
|
||||||
)
|
|
||||||
.then(() => applyMethod(option))
|
|
||||||
},
|
|
||||||
|
|
||||||
onUserChanged({ option, groupName, action, applyMethod }) {
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
{
|
|
||||||
uri: `users/groups/${groupName}/${action}/${option}`,
|
|
||||||
storeKey: 'groups',
|
|
||||||
groupName,
|
|
||||||
},
|
|
||||||
{},
|
|
||||||
{ key: 'groups.' + action, user: option, name: groupName },
|
|
||||||
)
|
|
||||||
.then(() => applyMethod(option))
|
|
||||||
},
|
|
||||||
|
|
||||||
onSpecificUserAdded({ option: userName, action, applyMethod }) {
|
|
||||||
if (action === 'add') {
|
|
||||||
this.userGroups[userName].permissions = []
|
|
||||||
applyMethod(userName)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteGroup(groupName) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_delete', { name: groupName }),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
api
|
|
||||||
.delete(
|
|
||||||
{ uri: 'users/groups', param: groupName, storeKey: 'groups' },
|
|
||||||
{},
|
|
||||||
{ key: 'groups.delete', name: groupName },
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
delete this.primaryGroups[groupName]
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.row > div:first-child {
|
.row > div:first-child {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
|
@ -1,8 +1,94 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { distanceToNow } from '@/helpers/filters/date'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', 'services/' + props.name],
|
||||||
|
['GET', `services/${props.name}/log?number=50`],
|
||||||
|
]
|
||||||
|
const infos = ref()
|
||||||
|
const uptime = ref()
|
||||||
|
const isCritical = ref()
|
||||||
|
const logs = ref()
|
||||||
|
const action = ref()
|
||||||
|
|
||||||
|
function onQueriesResponse(
|
||||||
|
// eslint-disable-next-line
|
||||||
|
{ status, description, start_on_boot, last_state_change, configuration },
|
||||||
|
logs,
|
||||||
|
) {
|
||||||
|
isCritical.value = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(
|
||||||
|
props.name,
|
||||||
|
)
|
||||||
|
// eslint-disable-next-line
|
||||||
|
uptime.value = last_state_change === 'unknown' ? 0 : last_state_change
|
||||||
|
infos.value = { description, status, start_on_boot, configuration }
|
||||||
|
|
||||||
|
logs.value = Object.keys(logs)
|
||||||
|
.sort((prev, curr) => {
|
||||||
|
if (prev === 'journalctl') return -1
|
||||||
|
else if (curr === 'journalctl') return 1
|
||||||
|
else if (prev < curr) return -1
|
||||||
|
else return 1
|
||||||
|
})
|
||||||
|
.map((filename) => ({ content: logs[filename].join('\n'), filename }))
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateService(action) {
|
||||||
|
const confirmed = await modalConfirm(
|
||||||
|
t('confirm_service_' + action, { name: props.name }),
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
`services/${props.name}/${action}`,
|
||||||
|
{},
|
||||||
|
{ key: 'services.' + action, name: props.name },
|
||||||
|
)
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLogs() {
|
||||||
|
const logs = logs.value
|
||||||
|
.map(({ filename, content }) => {
|
||||||
|
return `LOGFILE: ${filename}\n${content}`
|
||||||
|
})
|
||||||
|
.join('\n\n')
|
||||||
|
|
||||||
|
fetch('https://paste.yunohost.org/documents', {
|
||||||
|
method: 'POST',
|
||||||
|
body: logs,
|
||||||
|
})
|
||||||
|
.then((response) => {
|
||||||
|
if (response.ok) return response.json()
|
||||||
|
// FIXME flash error
|
||||||
|
/* eslint-disable-next-line */ else console.log('error', response)
|
||||||
|
})
|
||||||
|
.then(({ key }) => {
|
||||||
|
window.open('https://paste.yunohost.org/' + key, '_blank')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
@queries-response="onQueriesResponse"
|
@queries-response="onQueriesResponse"
|
||||||
ref="view"
|
ref="viewElem"
|
||||||
skeleton="CardInfoSkeleton"
|
skeleton="CardInfoSkeleton"
|
||||||
>
|
>
|
||||||
<!-- INFO CARD -->
|
<!-- INFO CARD -->
|
||||||
|
@ -81,104 +167,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { distanceToNow } from '@/helpers/filters/date'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ServiceInfo',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
name: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', 'services/' + this.name],
|
|
||||||
['GET', `services/${this.name}/log?number=50`],
|
|
||||||
],
|
|
||||||
// Service data
|
|
||||||
infos: undefined,
|
|
||||||
uptime: undefined,
|
|
||||||
isCritical: undefined,
|
|
||||||
logs: undefined,
|
|
||||||
// Modal action
|
|
||||||
action: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(
|
|
||||||
// eslint-disable-next-line
|
|
||||||
{ status, description, start_on_boot, last_state_change, configuration },
|
|
||||||
logs,
|
|
||||||
) {
|
|
||||||
this.isCritical = ['nginx', 'ssh', 'slapd', 'yunohost-api'].includes(
|
|
||||||
this.name,
|
|
||||||
)
|
|
||||||
// eslint-disable-next-line
|
|
||||||
this.uptime = last_state_change === 'unknown' ? 0 : last_state_change
|
|
||||||
this.infos = { description, status, start_on_boot, configuration }
|
|
||||||
|
|
||||||
this.logs = Object.keys(logs)
|
|
||||||
.sort((prev, curr) => {
|
|
||||||
if (prev === 'journalctl') return -1
|
|
||||||
else if (curr === 'journalctl') return 1
|
|
||||||
else if (prev < curr) return -1
|
|
||||||
else return 1
|
|
||||||
})
|
|
||||||
.map((filename) => ({ content: logs[filename].join('\n'), filename }))
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateService(action) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_service_' + action, { name: this.name }),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
`services/${this.name}/${action}`,
|
|
||||||
{},
|
|
||||||
{ key: 'services.' + action, name: this.name },
|
|
||||||
)
|
|
||||||
.then(this.$refs.view.fetchQueries)
|
|
||||||
},
|
|
||||||
|
|
||||||
shareLogs() {
|
|
||||||
const logs = this.logs
|
|
||||||
.map(({ filename, content }) => {
|
|
||||||
return `LOGFILE: ${filename}\n${content}`
|
|
||||||
})
|
|
||||||
.join('\n\n')
|
|
||||||
|
|
||||||
fetch('https://paste.yunohost.org/documents', {
|
|
||||||
method: 'POST',
|
|
||||||
body: logs,
|
|
||||||
})
|
|
||||||
.then((response) => {
|
|
||||||
if (response.ok) return response.json()
|
|
||||||
// FIXME flash error
|
|
||||||
/* eslint-disable-next-line */ else console.log('error', response)
|
|
||||||
})
|
|
||||||
.then(({ key }) => {
|
|
||||||
window.open('https://paste.yunohost.org/' + key, '_blank')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceToNow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
h3 {
|
h3 {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
|
|
|
@ -1,3 +1,33 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { distanceToNow } from '@/helpers/filters/date'
|
||||||
|
|
||||||
|
const queries = [['GET', 'services']]
|
||||||
|
const search = ref('')
|
||||||
|
const services = ref()
|
||||||
|
|
||||||
|
const filteredServices = computed(() => {
|
||||||
|
if (!services.value) return
|
||||||
|
const services_ = services.value.filter(({ name }) => {
|
||||||
|
return name.toLowerCase().includes(search.value.toLowerCase())
|
||||||
|
})
|
||||||
|
return services_.length ? services_ : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function onQueriesResponse(services) {
|
||||||
|
services.value = Object.keys(services)
|
||||||
|
.sort()
|
||||||
|
.map((name) => {
|
||||||
|
const service = services[name]
|
||||||
|
if (service.last_state_change === 'unknown') {
|
||||||
|
service.last_state_change = 0
|
||||||
|
}
|
||||||
|
return { ...service, name }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch
|
||||||
id="service-list"
|
id="service-list"
|
||||||
|
@ -42,49 +72,6 @@
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { distanceToNow } from '@/helpers/filters/date'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ServiceList',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', 'services']],
|
|
||||||
search: '',
|
|
||||||
services: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
filteredServices() {
|
|
||||||
if (!this.services) return
|
|
||||||
const search = this.search.toLowerCase()
|
|
||||||
const services = this.services.filter(({ name }) => {
|
|
||||||
return name.toLowerCase().includes(search)
|
|
||||||
})
|
|
||||||
return services.length ? services : null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(services) {
|
|
||||||
this.services = Object.keys(services)
|
|
||||||
.sort()
|
|
||||||
.map((name) => {
|
|
||||||
const service = services[name]
|
|
||||||
if (service.last_state_change === 'unknown') {
|
|
||||||
service.last_state_change = 0
|
|
||||||
}
|
|
||||||
return { ...service, name }
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceToNow,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@include media-breakpoint-down(lg) {
|
@include media-breakpoint-down(lg) {
|
||||||
h5 small {
|
h5 small {
|
||||||
|
|
|
@ -1,8 +1,168 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { between, integer, required } from '@/helpers/validators'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
const queries = [['GET', '/firewall?raw']]
|
||||||
|
|
||||||
|
const fields = [
|
||||||
|
{ key: 'port', label: t('port') },
|
||||||
|
{ key: 'ipv4', label: t('ipv4') },
|
||||||
|
{ key: 'ipv6', label: t('ipv6') },
|
||||||
|
{ key: 'uPnP', label: t('upnp') },
|
||||||
|
]
|
||||||
|
const form = reactive({
|
||||||
|
action: 'allow',
|
||||||
|
port: undefined,
|
||||||
|
connection: 'ipv4',
|
||||||
|
protocol: 'TCP',
|
||||||
|
})
|
||||||
|
const v$ = useVuelidate(
|
||||||
|
{
|
||||||
|
port: { number: required, integer, between: between(0, 65535) },
|
||||||
|
},
|
||||||
|
form,
|
||||||
|
)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
// Ports tables data
|
||||||
|
const protocols = ref()
|
||||||
|
|
||||||
|
// Ports form data
|
||||||
|
const actionChoices = [
|
||||||
|
{ value: 'allow', text: t('open') },
|
||||||
|
{ value: 'disallow', text: t('close') },
|
||||||
|
]
|
||||||
|
const connectionChoices = [
|
||||||
|
{ value: 'ipv4', text: t('ipv4') },
|
||||||
|
{ value: 'ipv6', text: t('ipv6') },
|
||||||
|
]
|
||||||
|
const protocolChoices = [
|
||||||
|
{ value: 'TCP', text: t('tcp') },
|
||||||
|
{ value: 'UDP', text: t('udp') },
|
||||||
|
{ value: 'Both', text: t('both') },
|
||||||
|
]
|
||||||
|
|
||||||
|
// uPnP
|
||||||
|
const upnpEnabled = ref()
|
||||||
|
const upnpError = ref('')
|
||||||
|
|
||||||
|
function onQueriesResponse(data) {
|
||||||
|
const ports = Object.values(data).reduce(
|
||||||
|
(ports_, protocols_) => {
|
||||||
|
for (const type of ['TCP', 'UDP']) {
|
||||||
|
for (const port of protocols_[type]) {
|
||||||
|
ports_[type].add(port)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ports
|
||||||
|
},
|
||||||
|
{ TCP: new Set(), UDP: new Set() },
|
||||||
|
)
|
||||||
|
|
||||||
|
const tables = {
|
||||||
|
TCP: [],
|
||||||
|
UDP: [],
|
||||||
|
}
|
||||||
|
for (const protocol of ['TCP', 'UDP']) {
|
||||||
|
for (const port of ports[protocol]) {
|
||||||
|
const row = { port }
|
||||||
|
for (const connection of ['ipv4', 'ipv6', 'uPnP']) {
|
||||||
|
row[connection] = data[connection][protocol].includes(port)
|
||||||
|
}
|
||||||
|
tables[protocol].push(row)
|
||||||
|
}
|
||||||
|
tables[protocol].sort((a, b) => (a.port < b.port ? -1 : 1))
|
||||||
|
}
|
||||||
|
|
||||||
|
protocols.value = tables
|
||||||
|
upnpEnabled.value = data.uPnP.enabled
|
||||||
|
}
|
||||||
|
|
||||||
|
async function togglePort({ action, port, protocol, connection }) {
|
||||||
|
const confirmed = await modalConfirm(
|
||||||
|
t('confirm_firewall_' + action, {
|
||||||
|
port,
|
||||||
|
protocol,
|
||||||
|
connection,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
if (!confirmed) {
|
||||||
|
return Promise.resolve(confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
const actionTrad = t({ allow: 'open', disallow: 'close' }[action])
|
||||||
|
return api
|
||||||
|
.put(
|
||||||
|
`firewall/${protocol}/${action}/${port}?${connection}_only`,
|
||||||
|
{},
|
||||||
|
{
|
||||||
|
key: 'firewall.ports',
|
||||||
|
protocol,
|
||||||
|
action: actionTrad,
|
||||||
|
port,
|
||||||
|
connection,
|
||||||
|
},
|
||||||
|
{ wait: false },
|
||||||
|
)
|
||||||
|
.then(() => confirmed)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function toggleUpnp(value) {
|
||||||
|
const action = upnpEnabled.value ? 'disable' : 'enable'
|
||||||
|
const confirmed = await modalConfirm(t('confirm_upnp_' + action))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api
|
||||||
|
.put(
|
||||||
|
'firewall/upnp/' + action,
|
||||||
|
{},
|
||||||
|
{ key: 'firewall.upnp', action: t(action) },
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
// FIXME Couldn't test when it works.
|
||||||
|
viewElem.value!.fetchQueries()
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
|
upnpError.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onTablePortToggling(port, protocol, connection, index, value) {
|
||||||
|
protocols.value[protocol][index][connection] = value
|
||||||
|
const action = value ? 'allow' : 'disallow'
|
||||||
|
togglePort({ action, port, protocol, connection }).then((toggled) => {
|
||||||
|
// Revert change on cancel
|
||||||
|
if (!toggled) {
|
||||||
|
protocols.value[protocol][index][connection] = !value
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function onFormPortToggling() {
|
||||||
|
togglePort(form).then((toggled) => {
|
||||||
|
if (toggled) viewElem.value!.fetchQueries()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
@queries-response="onQueriesResponse"
|
@queries-response="onQueriesResponse"
|
||||||
ref="view"
|
ref="viewElem"
|
||||||
skeleton="CardFormSkeleton"
|
skeleton="CardFormSkeleton"
|
||||||
>
|
>
|
||||||
<!-- PORTS -->
|
<!-- PORTS -->
|
||||||
|
@ -115,178 +275,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { required, integer, between } from '@/helpers/validators'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ToolFirewall',
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', '/firewall?raw']],
|
|
||||||
serverError: '',
|
|
||||||
|
|
||||||
// Ports tables data
|
|
||||||
fields: [
|
|
||||||
{ key: 'port', label: this.$t('port') },
|
|
||||||
{ key: 'ipv4', label: this.$t('ipv4') },
|
|
||||||
{ key: 'ipv6', label: this.$t('ipv6') },
|
|
||||||
{ key: 'uPnP', label: this.$t('upnp') },
|
|
||||||
],
|
|
||||||
protocols: undefined,
|
|
||||||
portToToggle: undefined,
|
|
||||||
|
|
||||||
// Ports form data
|
|
||||||
actionChoices: [
|
|
||||||
{ value: 'allow', text: this.$t('open') },
|
|
||||||
{ value: 'disallow', text: this.$t('close') },
|
|
||||||
],
|
|
||||||
connectionChoices: [
|
|
||||||
{ value: 'ipv4', text: this.$t('ipv4') },
|
|
||||||
{ value: 'ipv6', text: this.$t('ipv6') },
|
|
||||||
],
|
|
||||||
protocolChoices: [
|
|
||||||
{ value: 'TCP', text: this.$t('tcp') },
|
|
||||||
{ value: 'UDP', text: this.$t('udp') },
|
|
||||||
{ value: 'Both', text: this.$t('both') },
|
|
||||||
],
|
|
||||||
form: {
|
|
||||||
action: 'allow',
|
|
||||||
port: undefined,
|
|
||||||
connection: 'ipv4',
|
|
||||||
protocol: 'TCP',
|
|
||||||
},
|
|
||||||
|
|
||||||
// uPnP
|
|
||||||
upnpEnabled: undefined,
|
|
||||||
upnpError: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
validations: {
|
|
||||||
form: {
|
|
||||||
port: { number: required, integer, between: between(0, 65535) },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(data) {
|
|
||||||
const ports = Object.values(data).reduce(
|
|
||||||
(ports, protocols) => {
|
|
||||||
for (const type of ['TCP', 'UDP']) {
|
|
||||||
for (const port of protocols[type]) {
|
|
||||||
ports[type].add(port)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ports
|
|
||||||
},
|
|
||||||
{ TCP: new Set(), UDP: new Set() },
|
|
||||||
)
|
|
||||||
|
|
||||||
const tables = {
|
|
||||||
TCP: [],
|
|
||||||
UDP: [],
|
|
||||||
}
|
|
||||||
for (const protocol of ['TCP', 'UDP']) {
|
|
||||||
for (const port of ports[protocol]) {
|
|
||||||
const row = { port }
|
|
||||||
for (const connection of ['ipv4', 'ipv6', 'uPnP']) {
|
|
||||||
row[connection] = data[connection][protocol].includes(port)
|
|
||||||
}
|
|
||||||
tables[protocol].push(row)
|
|
||||||
}
|
|
||||||
tables[protocol].sort((a, b) => (a.port < b.port ? -1 : 1))
|
|
||||||
}
|
|
||||||
|
|
||||||
this.protocols = tables
|
|
||||||
this.upnpEnabled = data.uPnP.enabled
|
|
||||||
},
|
|
||||||
|
|
||||||
async togglePort({ action, port, protocol, connection }) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_firewall_' + action, {
|
|
||||||
port,
|
|
||||||
protocol,
|
|
||||||
connection,
|
|
||||||
}),
|
|
||||||
)
|
|
||||||
if (!confirmed) {
|
|
||||||
return Promise.resolve(confirmed)
|
|
||||||
}
|
|
||||||
|
|
||||||
const actionTrad = this.$t({ allow: 'open', disallow: 'close' }[action])
|
|
||||||
return api
|
|
||||||
.put(
|
|
||||||
`firewall/${protocol}/${action}/${port}?${connection}_only`,
|
|
||||||
{},
|
|
||||||
{
|
|
||||||
key: 'firewall.ports',
|
|
||||||
protocol,
|
|
||||||
action: actionTrad,
|
|
||||||
port,
|
|
||||||
connection,
|
|
||||||
},
|
|
||||||
{ wait: false },
|
|
||||||
)
|
|
||||||
.then(() => confirmed)
|
|
||||||
},
|
|
||||||
|
|
||||||
async toggleUpnp(value) {
|
|
||||||
const action = this.upnpEnabled ? 'disable' : 'enable'
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_upnp_' + action),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
'firewall/upnp/' + action,
|
|
||||||
{},
|
|
||||||
{ key: 'firewall.upnp', action: this.$t(action) },
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
// FIXME Couldn't test when it works.
|
|
||||||
this.$refs.view.fetchQueries()
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
this.upnpError = err.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
onTablePortToggling(port, protocol, connection, index, value) {
|
|
||||||
this.protocols[protocol][index][connection] = value
|
|
||||||
const action = value ? 'allow' : 'disallow'
|
|
||||||
this.togglePort({ action, port, protocol, connection }).then(
|
|
||||||
(toggled) => {
|
|
||||||
// Revert change on cancel
|
|
||||||
if (!toggled) {
|
|
||||||
this.protocols[protocol][index][connection] = !value
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
|
|
||||||
onFormPortToggling(e) {
|
|
||||||
this.togglePort(this.form).then((toggled) => {
|
|
||||||
if (toggled) this.$refs.view.fetchQueries()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
:deep() {
|
:deep() {
|
||||||
.form-switch {
|
.form-switch {
|
||||||
|
|
|
@ -1,25 +1,5 @@
|
||||||
<!-- FIXME make a component shared with HomeView.vue ? -->
|
<script setup lang="ts">
|
||||||
<template>
|
const menu = [
|
||||||
<BListGroup class="menu-list">
|
|
||||||
<BListGroupItem
|
|
||||||
v-for="item in menu"
|
|
||||||
:key="item.routeName"
|
|
||||||
:to="{ name: item.routeName }"
|
|
||||||
>
|
|
||||||
<YIcon :iname="item.icon" class="lg ms-1" />
|
|
||||||
<h4>{{ $t(item.translation) }}</h4>
|
|
||||||
<YIcon iname="chevron-right" class="lg fs-sm ms-auto" />
|
|
||||||
</BListGroupItem>
|
|
||||||
</BListGroup>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
export default {
|
|
||||||
name: 'ToolList',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
menu: [
|
|
||||||
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
|
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
|
||||||
{
|
{
|
||||||
routeName: 'tool-migrations',
|
routeName: 'tool-migrations',
|
||||||
|
@ -43,8 +23,20 @@ export default {
|
||||||
icon: 'power-off',
|
icon: 'power-off',
|
||||||
translation: 'tools_shutdown_reboot',
|
translation: 'tools_shutdown_reboot',
|
||||||
},
|
},
|
||||||
],
|
]
|
||||||
}
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<!-- FIXME make a component shared with HomeView.vue ? -->
|
||||||
|
<template>
|
||||||
|
<BListGroup class="menu-list">
|
||||||
|
<BListGroupItem
|
||||||
|
v-for="item in menu"
|
||||||
|
:key="item.routeName"
|
||||||
|
:to="{ name: item.routeName }"
|
||||||
|
>
|
||||||
|
<YIcon :iname="item.icon" class="lg ms-1" />
|
||||||
|
<h4>{{ $t(item.translation) }}</h4>
|
||||||
|
<YIcon iname="chevron-right" class="lg fs-sm ms-auto" />
|
||||||
|
</BListGroupItem>
|
||||||
|
</BListGroup>
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,8 +1,86 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import api, { objectToParams } from '@/api'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { escapeHtml } from '@/helpers/commons'
|
||||||
|
import { readableDate } from '@/helpers/filters/date'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
const numberOfLines = ref(25)
|
||||||
|
const queries = computed(() => {
|
||||||
|
const queryString = objectToParams({
|
||||||
|
filter_irrelevant: '',
|
||||||
|
with_suboperations: '',
|
||||||
|
number: numberOfLines.value,
|
||||||
|
})
|
||||||
|
return [['GET', `logs/${props.name}?${queryString}`]]
|
||||||
|
})
|
||||||
|
|
||||||
|
// Log data
|
||||||
|
const description = ref()
|
||||||
|
const info = ref({})
|
||||||
|
const logs = ref()
|
||||||
|
// Logs line display
|
||||||
|
const moreLogsAvailable = ref(false)
|
||||||
|
|
||||||
|
function onQueriesResponse(log) {
|
||||||
|
if (log.logs.length === numberOfLines.value) {
|
||||||
|
moreLogsAvailable.value = true
|
||||||
|
numberOfLines.value *= 10
|
||||||
|
} else {
|
||||||
|
moreLogsAvailable.value = false
|
||||||
|
}
|
||||||
|
description.value = log.description
|
||||||
|
|
||||||
|
const levels = ['ERROR', 'WARNING', 'SUCCESS', 'INFO']
|
||||||
|
logs.value = log.logs
|
||||||
|
.map((line) => {
|
||||||
|
const escaped = escapeHtml(line)
|
||||||
|
for (const level of levels) {
|
||||||
|
if (line.includes(level + ' -')) {
|
||||||
|
return `<span class="alert-${
|
||||||
|
level === 'ERROR' ? 'danger' : level.toLowerCase()
|
||||||
|
}">${escaped}</span>`
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return escaped
|
||||||
|
})
|
||||||
|
.join('\n')
|
||||||
|
// eslint-disable-next-line
|
||||||
|
const { started_at, ended_at, error, success, suboperations } = log.metadata
|
||||||
|
const info_ = { path: log.log_path, started_at, ended_at }
|
||||||
|
if (!success) info_.error = error
|
||||||
|
if (suboperations && suboperations.length) info_.suboperations = suboperations
|
||||||
|
// eslint-disable-next-line
|
||||||
|
if (!ended_at) delete info_.ended_at
|
||||||
|
info.value = info
|
||||||
|
}
|
||||||
|
|
||||||
|
function shareLogs() {
|
||||||
|
api
|
||||||
|
.get(
|
||||||
|
`logs/${props.name}/share`,
|
||||||
|
null,
|
||||||
|
{ key: 'share_logs', name: props.name },
|
||||||
|
{ websocket: true },
|
||||||
|
)
|
||||||
|
.then(({ url }) => {
|
||||||
|
window.open(url, '_blank')
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
@queries-response="onQueriesResponse"
|
@queries-response="onQueriesResponse"
|
||||||
ref="view"
|
ref="viewElem"
|
||||||
skeleton="CardInfoSkeleton"
|
skeleton="CardInfoSkeleton"
|
||||||
>
|
>
|
||||||
<!-- INFO CARD -->
|
<!-- INFO CARD -->
|
||||||
|
@ -57,7 +135,7 @@
|
||||||
v-if="moreLogsAvailable"
|
v-if="moreLogsAvailable"
|
||||||
variant="white"
|
variant="white"
|
||||||
class="w-100 rounded-0"
|
class="w-100 rounded-0"
|
||||||
@click="$refs.view.fetchQueries()"
|
@click="viewElem!.fetchQueries()"
|
||||||
>
|
>
|
||||||
<YIcon iname="plus" /> {{ $t('logs_more') }}
|
<YIcon iname="plus" /> {{ $t('logs_more') }}
|
||||||
</BButton>
|
</BButton>
|
||||||
|
@ -71,92 +149,3 @@
|
||||||
<p class="w-100 px-5 py-2 mb-0" v-html="$t('text_selection_is_disabled')" />
|
<p class="w-100 px-5 py-2 mb-0" v-html="$t('text_selection_is_disabled')" />
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api, { objectToParams } from '@/api'
|
|
||||||
import { escapeHtml } from '@/helpers/commons'
|
|
||||||
import { readableDate } from '@/helpers/filters/date'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ToolLog',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
name: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
// Log data
|
|
||||||
description: undefined,
|
|
||||||
info: {},
|
|
||||||
logs: undefined,
|
|
||||||
// Logs line display
|
|
||||||
numberOfLines: 25,
|
|
||||||
moreLogsAvailable: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
queries() {
|
|
||||||
const queryString = objectToParams({
|
|
||||||
filter_irrelevant: '',
|
|
||||||
with_suboperations: '',
|
|
||||||
number: this.numberOfLines,
|
|
||||||
})
|
|
||||||
return [['GET', `logs/${this.name}?${queryString}`]]
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(log) {
|
|
||||||
if (log.logs.length === this.numberOfLines) {
|
|
||||||
this.moreLogsAvailable = true
|
|
||||||
this.numberOfLines *= 10
|
|
||||||
} else {
|
|
||||||
this.moreLogsAvailable = false
|
|
||||||
}
|
|
||||||
this.description = log.description
|
|
||||||
|
|
||||||
const levels = ['ERROR', 'WARNING', 'SUCCESS', 'INFO']
|
|
||||||
this.logs = log.logs
|
|
||||||
.map((line) => {
|
|
||||||
const escaped = escapeHtml(line)
|
|
||||||
for (const level of levels) {
|
|
||||||
if (line.includes(level + ' -')) {
|
|
||||||
return `<span class="alert-${
|
|
||||||
level === 'ERROR' ? 'danger' : level.toLowerCase()
|
|
||||||
}">${escaped}</span>`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return escaped
|
|
||||||
})
|
|
||||||
.join('\n')
|
|
||||||
// eslint-disable-next-line
|
|
||||||
const { started_at, ended_at, error, success, suboperations } =
|
|
||||||
log.metadata
|
|
||||||
const info = { path: log.log_path, started_at, ended_at }
|
|
||||||
if (!success) info.error = error
|
|
||||||
if (suboperations && suboperations.length)
|
|
||||||
info.suboperations = suboperations
|
|
||||||
// eslint-disable-next-line
|
|
||||||
if (!ended_at) delete info.ended_at
|
|
||||||
this.info = info
|
|
||||||
},
|
|
||||||
|
|
||||||
shareLogs() {
|
|
||||||
api
|
|
||||||
.get(
|
|
||||||
`logs/${this.name}/share`,
|
|
||||||
null,
|
|
||||||
{ key: 'share_logs', name: this.name },
|
|
||||||
{ websocket: true },
|
|
||||||
)
|
|
||||||
.then(({ url }) => {
|
|
||||||
window.open(url, '_blank')
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
readableDate,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,38 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
import { distanceToNow, readableDate } from '@/helpers/filters/date'
|
||||||
|
|
||||||
|
const queries = [['GET', `logs?limit=${25}&with_details`]]
|
||||||
|
const search = ref('')
|
||||||
|
const operations = ref()
|
||||||
|
|
||||||
|
const filteredOperations = computed(() => {
|
||||||
|
if (!operations.value) return
|
||||||
|
const search_ = search.value.toLowerCase()
|
||||||
|
const operations_ = operations.value.filter(({ description }) => {
|
||||||
|
return description.toLowerCase().includes(search_)
|
||||||
|
})
|
||||||
|
return operations_.length ? operations_ : null
|
||||||
|
})
|
||||||
|
|
||||||
|
function onQueriesResponse({ operation }) {
|
||||||
|
operation.forEach((log, index) => {
|
||||||
|
if (log.success === '?') {
|
||||||
|
operation[index].icon = 'question'
|
||||||
|
operation[index].class = 'warning'
|
||||||
|
} else if (log.success) {
|
||||||
|
operation[index].icon = 'check'
|
||||||
|
operation[index].class = 'success'
|
||||||
|
} else {
|
||||||
|
operation[index].icon = 'close'
|
||||||
|
operation[index].class = 'danger'
|
||||||
|
}
|
||||||
|
})
|
||||||
|
operations.value = operation
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch
|
||||||
v-model:search="search"
|
v-model:search="search"
|
||||||
|
@ -24,51 +59,3 @@
|
||||||
</YCard>
|
</YCard>
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { distanceToNow, readableDate } from '@/helpers/filters/date'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ToolLogs',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', `logs?limit=${25}&with_details`]],
|
|
||||||
search: '',
|
|
||||||
operations: undefined,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
filteredOperations() {
|
|
||||||
if (!this.operations) return
|
|
||||||
const search = this.search.toLowerCase()
|
|
||||||
const operations = this.operations.filter(({ description }) => {
|
|
||||||
return description.toLowerCase().includes(search)
|
|
||||||
})
|
|
||||||
return operations.length ? operations : null
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse({ operation }) {
|
|
||||||
operation.forEach((log, index) => {
|
|
||||||
if (log.success === '?') {
|
|
||||||
operation[index].icon = 'question'
|
|
||||||
operation[index].class = 'warning'
|
|
||||||
} else if (log.success) {
|
|
||||||
operation[index].icon = 'check'
|
|
||||||
operation[index].class = 'success'
|
|
||||||
} else {
|
|
||||||
operation[index].icon = 'close'
|
|
||||||
operation[index].class = 'danger'
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.operations = operation
|
|
||||||
},
|
|
||||||
|
|
||||||
distanceToNow,
|
|
||||||
readableDate,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,5 +1,67 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
|
||||||
|
// FIXME not tested with pending migrations (disclaimer and stuff)
|
||||||
|
const { t } = useI18n()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', 'migrations?pending'],
|
||||||
|
['GET', 'migrations?done'],
|
||||||
|
]
|
||||||
|
const pending = ref()
|
||||||
|
const done = ref()
|
||||||
|
const checked = reactive({})
|
||||||
|
|
||||||
|
function onQueriesResponse({ migrations: pending_ }, { migrations: done_ }) {
|
||||||
|
done.value = done_.length ? done_.reverse() : null
|
||||||
|
pending_.forEach((migration) => {
|
||||||
|
if (migration.disclaimer) {
|
||||||
|
migration.disclaimer = migration.disclaimer.replaceAll('\n', '<br>')
|
||||||
|
checked[migration.id] = null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
// FIXME change to pending
|
||||||
|
pending.value = pending_.length ? pending_.reverse() : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function runMigrations() {
|
||||||
|
// Display an error on migration's disclaimer that aren't checked.
|
||||||
|
for (const [id, value] of Object.entries(checked)) {
|
||||||
|
if (value !== true) {
|
||||||
|
checked[id] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Check that every migration's disclaimer has been checked.
|
||||||
|
if (Object.values(checked).every((value) => value === true)) {
|
||||||
|
api
|
||||||
|
.put('migrations?accept_disclaimer', {}, 'migrations.run')
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function skipMigration(id) {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_migrations_skip'))
|
||||||
|
if (!confirmed) return
|
||||||
|
api
|
||||||
|
.put('/migrations/' + id, { skip: '', targets: id }, 'migration.skip')
|
||||||
|
.then(() => viewElem.value!.fetchQueries())
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase :queries="queries" @queries-response="onQueriesResponse" ref="view">
|
<ViewBase
|
||||||
|
:queries="queries"
|
||||||
|
@queries-response="onQueriesResponse"
|
||||||
|
ref="viewElem"
|
||||||
|
>
|
||||||
<!-- PENDING MIGRATIONS -->
|
<!-- PENDING MIGRATIONS -->
|
||||||
<YCard :title="$t('migrations_pending')" icon="cogs" no-body>
|
<YCard :title="$t('migrations_pending')" icon="cogs" no-body>
|
||||||
<template #header-buttons v-if="pending">
|
<template #header-buttons v-if="pending">
|
||||||
|
@ -85,74 +147,3 @@
|
||||||
</template>
|
</template>
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
|
|
||||||
// FIXME not tested with pending migrations (disclaimer and stuff)
|
|
||||||
export default {
|
|
||||||
name: 'ToolMigrations',
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', 'migrations?pending'],
|
|
||||||
['GET', 'migrations?done'],
|
|
||||||
],
|
|
||||||
pending: undefined,
|
|
||||||
done: undefined,
|
|
||||||
checked: {},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse({ migrations: pending }, { migrations: done }) {
|
|
||||||
this.done = done.length ? done.reverse() : null
|
|
||||||
pending.forEach((migration) => {
|
|
||||||
if (migration.disclaimer) {
|
|
||||||
migration.disclaimer = migration.disclaimer.replaceAll('\n', '<br>')
|
|
||||||
this.checked[migration.id] = null
|
|
||||||
}
|
|
||||||
})
|
|
||||||
// FIXME change to pending
|
|
||||||
this.pending = pending.length ? pending.reverse() : null
|
|
||||||
},
|
|
||||||
|
|
||||||
runMigrations() {
|
|
||||||
// Display an error on migration's disclaimer that aren't checked.
|
|
||||||
for (const [id, value] of Object.entries(this.checked)) {
|
|
||||||
if (value !== true) {
|
|
||||||
this.checked[id] = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Check that every migration's disclaimer has been checked.
|
|
||||||
if (Object.values(this.checked).every((value) => value === true)) {
|
|
||||||
api
|
|
||||||
.put('migrations?accept_disclaimer', {}, 'migrations.run')
|
|
||||||
.then(() => {
|
|
||||||
this.$refs.view.fetchQueries()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async skipMigration(id) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_migrations_skip'),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
api
|
|
||||||
.put('/migrations/' + id, { skip: '', targets: id }, 'migration.skip')
|
|
||||||
.then(() => {
|
|
||||||
this.$refs.view.fetchQueries()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,29 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
async function triggerAction(action) {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_reboot_action_' + action))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api.put(action + '?force', {}, action).then(() => {
|
||||||
|
const delay = action === 'reboot' ? 4000 : 10000
|
||||||
|
store.dispatch('TRY_TO_RECONNECT', {
|
||||||
|
attemps: Infinity,
|
||||||
|
origin: action,
|
||||||
|
delay,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<YCard :title="$t('operations')" icon="wrench">
|
<YCard :title="$t('operations')" icon="wrench">
|
||||||
<!-- REBOOT -->
|
<!-- REBOOT -->
|
||||||
|
@ -32,37 +58,3 @@
|
||||||
</BFormGroup>
|
</BFormGroup>
|
||||||
</YCard>
|
</YCard>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'ToolPower',
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async triggerAction(action) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_reboot_action_' + action),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
this.action = action
|
|
||||||
api.put(action + '?force', {}, action).then(() => {
|
|
||||||
const delay = action === 'reboot' ? 4000 : 10000
|
|
||||||
this.$store.dispatch('TRY_TO_RECONNECT', {
|
|
||||||
attemps: Infinity,
|
|
||||||
origin: action,
|
|
||||||
delay,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,50 +1,27 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<ViewBase
|
import { reactive, ref } from 'vue'
|
||||||
:queries="queries"
|
|
||||||
@queries-response="onQueriesResponse"
|
|
||||||
ref="view"
|
|
||||||
skeleton="CardFormSkeleton"
|
|
||||||
>
|
|
||||||
<ConfigPanels
|
|
||||||
v-if="config.panels"
|
|
||||||
v-bind="config"
|
|
||||||
:external-results="externalResults"
|
|
||||||
@apply="onConfigSubmit"
|
|
||||||
/>
|
|
||||||
</ViewBase>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
|
||||||
import api, { objectToParams } from '@/api'
|
import api, { objectToParams } from '@/api'
|
||||||
|
import { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
|
import ConfigPanels from '@/components/ConfigPanels.vue'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
import {
|
import {
|
||||||
formatFormData,
|
formatFormData,
|
||||||
formatYunoHostConfigPanels,
|
formatYunoHostConfigPanels,
|
||||||
} from '@/helpers/yunohostArguments'
|
} from '@/helpers/yunohostArguments'
|
||||||
import ConfigPanels from '@/components/ConfigPanels.vue'
|
|
||||||
|
|
||||||
export default {
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
name: 'ToolSettingsConfig',
|
|
||||||
|
|
||||||
components: {
|
const queries = [['GET', 'settings?full']]
|
||||||
ConfigPanels,
|
const config = ref({})
|
||||||
},
|
// FIXME user proper useValidate stuff
|
||||||
|
const externalResults = reactive({})
|
||||||
|
|
||||||
props: {},
|
function onQueriesResponse(config_) {
|
||||||
|
config.value = formatYunoHostConfigPanels(config_)
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['GET', 'settings?full']],
|
|
||||||
config: {},
|
|
||||||
externalResults: {},
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
async function onConfigSubmit({ id, form }) {
|
||||||
onQueriesResponse(config) {
|
|
||||||
this.config = formatYunoHostConfigPanels(config)
|
|
||||||
},
|
|
||||||
|
|
||||||
async onConfigSubmit({ id, form }) {
|
|
||||||
const args = await formatFormData(form, {
|
const args = await formatFormData(form, {
|
||||||
removeEmpty: false,
|
removeEmpty: false,
|
||||||
removeNull: true,
|
removeNull: true,
|
||||||
|
@ -57,21 +34,33 @@ export default {
|
||||||
{ args: objectToParams(args) },
|
{ args: objectToParams(args) },
|
||||||
{ key: 'settings.update', panel: id },
|
{ key: 'settings.update', panel: id },
|
||||||
)
|
)
|
||||||
.then(() => {
|
.then(() => viewElem.value!.fetchQueries({ triggerLoading: true }))
|
||||||
this.$refs.view.fetchQueries({ triggerLoading: true })
|
.catch((err: APIError) => {
|
||||||
})
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
.catch((err) => {
|
const panel = config.value.panels.find((panel) => panel.id === id)
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
const panel = this.config.panels.find((panel) => panel.id === id)
|
|
||||||
if (err.data.name) {
|
if (err.data.name) {
|
||||||
Object.assign(this.externalResults, {
|
Object.assign(externalResults, {
|
||||||
forms: { [panel.id]: { [err.data.name]: [err.data.error] } },
|
forms: { [panel.id]: { [err.data.name]: [err.data.error] } },
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
panel.serverError = err.message
|
panel.serverError = err.message
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<ViewBase
|
||||||
|
:queries="queries"
|
||||||
|
@queries-response="onQueriesResponse"
|
||||||
|
ref="viewElem"
|
||||||
|
skeleton="CardFormSkeleton"
|
||||||
|
>
|
||||||
|
<ConfigPanels
|
||||||
|
v-if="config.panels"
|
||||||
|
v-bind="config"
|
||||||
|
:external-results="externalResults"
|
||||||
|
@apply="onConfigSubmit"
|
||||||
|
/>
|
||||||
|
</ViewBase>
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,99 +1,93 @@
|
||||||
<template>
|
<script setup lang="ts">
|
||||||
<CardForm :title="$t('tools_webadmin_settings')" icon="cog" no-footer>
|
import { computed } from 'vue'
|
||||||
<template v-for="(field, fname) in fields" :key="fname">
|
import { useI18n } from 'vue-i18n'
|
||||||
<FormField v-bind="field" v-model="self[fname]" />
|
import { useStore } from 'vuex'
|
||||||
<hr />
|
|
||||||
</template>
|
|
||||||
</CardForm>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script>
|
const { t } = useI18n()
|
||||||
// FIXME move into helpers ?
|
const store = useStore()
|
||||||
// Dynamicly generate computed properties from store with get/set and automatic commit/dispatch
|
|
||||||
function mapStoreGetSet(props = [], action = 'commit') {
|
|
||||||
return props.reduce((obj, prop) => {
|
|
||||||
obj[prop] = {
|
|
||||||
get() {
|
|
||||||
return this.$store.getters[prop]
|
|
||||||
},
|
|
||||||
set(value) {
|
|
||||||
const key =
|
|
||||||
(action === 'commit' ? 'SET_' : 'UPDATE_') + prop.toUpperCase()
|
|
||||||
this.$store[action](key, value)
|
|
||||||
},
|
|
||||||
}
|
|
||||||
return obj
|
|
||||||
}, {})
|
|
||||||
}
|
|
||||||
|
|
||||||
export default {
|
const fields = {
|
||||||
name: 'ToolWebadmin',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
// Hacky way to be able to dynamicly point to a computed property `self['computedProp']`
|
|
||||||
self: this,
|
|
||||||
|
|
||||||
fields: {
|
|
||||||
locale: {
|
locale: {
|
||||||
label: this.$t('tools_webadmin.language'),
|
label: t('tools_webadmin.language'),
|
||||||
component: 'SelectItem',
|
component: 'SelectItem',
|
||||||
props: { id: 'locale', choices: [] },
|
props: { id: 'locale', choices: [] },
|
||||||
},
|
},
|
||||||
|
|
||||||
fallbackLocale: {
|
fallbackLocale: {
|
||||||
label: this.$t('tools_webadmin.fallback_language'),
|
label: t('tools_webadmin.fallback_language'),
|
||||||
description: this.$t('tools_webadmin.fallback_language_description'),
|
description: t('tools_webadmin.fallback_language_description'),
|
||||||
component: 'SelectItem',
|
component: 'SelectItem',
|
||||||
props: { id: 'fallback-locale', choices: [] },
|
props: { id: 'fallback-locale', choices: [] },
|
||||||
},
|
},
|
||||||
|
|
||||||
cache: {
|
cache: {
|
||||||
id: 'cache',
|
id: 'cache',
|
||||||
label: this.$t('tools_webadmin.cache'),
|
label: t('tools_webadmin.cache'),
|
||||||
description: this.$t('tools_webadmin.cache_description'),
|
description: t('tools_webadmin.cache_description'),
|
||||||
component: 'CheckboxItem',
|
component: 'CheckboxItem',
|
||||||
props: { labels: { true: 'enabled', false: 'disabled' } },
|
props: { labels: { true: 'enabled', false: 'disabled' } },
|
||||||
},
|
},
|
||||||
|
|
||||||
transitions: {
|
transitions: {
|
||||||
id: 'transitions',
|
id: 'transitions',
|
||||||
label: this.$t('tools_webadmin.transitions'),
|
label: t('tools_webadmin.transitions'),
|
||||||
component: 'CheckboxItem',
|
component: 'CheckboxItem',
|
||||||
props: { labels: { true: 'enabled', false: 'disabled' } },
|
props: { labels: { true: 'enabled', false: 'disabled' } },
|
||||||
},
|
},
|
||||||
|
|
||||||
dark: {
|
dark: {
|
||||||
id: 'theme',
|
id: 'theme',
|
||||||
label: this.$t('tools_webadmin.theme'),
|
label: t('tools_webadmin.theme'),
|
||||||
component: 'CheckboxItem',
|
component: 'CheckboxItem',
|
||||||
props: { labels: { true: '🌙', false: '☀️' } },
|
props: { labels: { true: '🌙', false: '☀️' } },
|
||||||
},
|
},
|
||||||
|
|
||||||
// experimental: added in `created()`
|
// experimental: added in `created()`
|
||||||
},
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
const form = {
|
||||||
// Those are set/get computed properties
|
|
||||||
...mapStoreGetSet(['locale', 'fallbackLocale', 'dark'], 'dispatch'),
|
...mapStoreGetSet(['locale', 'fallbackLocale', 'dark'], 'dispatch'),
|
||||||
...mapStoreGetSet(['cache', 'transitions', 'experimental']),
|
...mapStoreGetSet(['cache', 'transitions', 'experimental']),
|
||||||
},
|
}
|
||||||
|
|
||||||
|
const availableLocales = store.getters.availableLocales
|
||||||
|
fields.locale.props.choices = availableLocales
|
||||||
|
fields.fallbackLocale.props.choices = availableLocales
|
||||||
|
|
||||||
created() {
|
|
||||||
const availableLocales = this.$store.getters.availableLocales
|
|
||||||
this.fields.locale.props.choices = availableLocales
|
|
||||||
this.fields.fallbackLocale.props.choices = availableLocales
|
|
||||||
if (import.meta.env.DEV) {
|
if (import.meta.env.DEV) {
|
||||||
this.fields.experimental = {
|
fields.experimental = {
|
||||||
id: 'experimental',
|
id: 'experimental',
|
||||||
label: this.$t('tools_webadmin.experimental'),
|
label: t('tools_webadmin.experimental'),
|
||||||
description: this.$t('tools_webadmin.experimental_description'),
|
description: t('tools_webadmin.experimental_description'),
|
||||||
component: 'CheckboxItem',
|
component: 'CheckboxItem',
|
||||||
props: { labels: { true: 'enabled', false: 'disabled' } },
|
props: { labels: { true: 'enabled', false: 'disabled' } },
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// FIXME move into helpers ?
|
||||||
|
// Dynamicly generate computed properties from store with get/set and automatic commit/dispatch
|
||||||
|
function mapStoreGetSet(props = [], action = 'commit') {
|
||||||
|
return props.reduce((obj, prop) => {
|
||||||
|
obj[prop] = computed({
|
||||||
|
get() {
|
||||||
|
return store.getters[prop]
|
||||||
},
|
},
|
||||||
|
set(value) {
|
||||||
|
const key =
|
||||||
|
(action === 'commit' ? 'SET_' : 'UPDATE_') + prop.toUpperCase()
|
||||||
|
store[action](key, value)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
return obj
|
||||||
|
}, {})
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<CardForm :title="$t('tools_webadmin_settings')" icon="cog" no-footer>
|
||||||
|
<template v-for="(field, fname) in fields" :key="fname">
|
||||||
|
<FormField v-bind="field" v-model="form[fname]" />
|
||||||
|
<hr />
|
||||||
|
</template>
|
||||||
|
</CardForm>
|
||||||
|
</template>
|
||||||
|
|
|
@ -1,3 +1,117 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import CardCollapse from '@/components/CardCollapse.vue'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const store = useStore()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const queries = [['PUT', 'update/all', {}, 'update']]
|
||||||
|
|
||||||
|
const { dark } = useStoreGetters()
|
||||||
|
const system = ref()
|
||||||
|
const apps = ref()
|
||||||
|
const importantYunohostUpgrade = ref()
|
||||||
|
const pendingMigrations = ref()
|
||||||
|
const showPreUpgradeModal = ref(false)
|
||||||
|
const preUpgrade = ref({
|
||||||
|
apps: [],
|
||||||
|
notifs: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
function onQueriesResponse({
|
||||||
|
apps,
|
||||||
|
system,
|
||||||
|
important_yunohost_upgrade,
|
||||||
|
pending_migrations,
|
||||||
|
}) {
|
||||||
|
apps.value = apps.length ? apps : null
|
||||||
|
system.value = system.length ? system : null
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
importantYunohostUpgrade.value = important_yunohost_upgrade
|
||||||
|
pendingMigrations.value = pending_migrations.length !== 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAppNotifs(notifs) {
|
||||||
|
return Object.keys(notifs).reduce((acc, key) => {
|
||||||
|
return acc + '\n\n' + notifs[key]
|
||||||
|
}, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
async function confirmAppsUpgrade(id = null) {
|
||||||
|
const appList = id ? [apps.value.find((app) => app.id === id)] : apps.value
|
||||||
|
const apps_ = appList.map((app) => ({
|
||||||
|
id: app.id,
|
||||||
|
name: app.name,
|
||||||
|
notif: app.notifications.PRE_UPGRADE
|
||||||
|
? formatAppNotifs(app.notifications.PRE_UPGRADE)
|
||||||
|
: '',
|
||||||
|
}))
|
||||||
|
preUpgrade.value = { apps: apps_, hasNotifs: apps_.some((app) => app.notif) }
|
||||||
|
showPreUpgradeModal.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performAppsUpgrade(ids) {
|
||||||
|
const apps_ = ids.map((id) => apps.value.find((app) => app.id === id))
|
||||||
|
const lastAppId = apps_[apps_.length - 1].id
|
||||||
|
|
||||||
|
for (const app of apps_) {
|
||||||
|
const continue_ = await api
|
||||||
|
.put(`apps/${app.id}/upgrade`, {}, { key: 'upgrade.app', app: app.name })
|
||||||
|
.then((response) => {
|
||||||
|
const postMessage = formatAppNotifs(response.notifications.POST_UPGRADE)
|
||||||
|
const isLast = app.id === lastAppId
|
||||||
|
apps.value = apps.value.filter((a) => app.id !== a.id)
|
||||||
|
|
||||||
|
if (postMessage) {
|
||||||
|
const message =
|
||||||
|
t('app.upgrade.notifs.post.alert') + '\n\n' + postMessage
|
||||||
|
return modalConfirm(
|
||||||
|
message,
|
||||||
|
{
|
||||||
|
title: t('app.upgrade.notifs.post.title', {
|
||||||
|
name: app.name,
|
||||||
|
}),
|
||||||
|
okTitle: t(isLast ? 'ok' : 'app.upgrade.continue'),
|
||||||
|
cancelTitle: t('app.upgrade.stop'),
|
||||||
|
},
|
||||||
|
{ markdown: true, cancelable: !isLast },
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
return Promise.resolve(true)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if (!continue_) break
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!apps.value.length) {
|
||||||
|
apps.value = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function performSystemUpgrade() {
|
||||||
|
const confirmed = await modalConfirm(t('confirm_update_system'))
|
||||||
|
if (!confirmed) return
|
||||||
|
|
||||||
|
api.put('upgrade/system', {}, { key: 'upgrade.system' }).then(() => {
|
||||||
|
if (system.value.some(({ name }) => name.includes('yunohost'))) {
|
||||||
|
store.dispatch('TRY_TO_RECONNECT', {
|
||||||
|
attemps: 1,
|
||||||
|
origin: 'upgrade_system',
|
||||||
|
initialDelay: 2000,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
system.value = null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
|
@ -138,145 +252,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
import CardCollapse from '@/components/CardCollapse.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'SystemUpdate',
|
|
||||||
|
|
||||||
components: {
|
|
||||||
CardCollapse,
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [['PUT', 'update/all', {}, 'update']],
|
|
||||||
// API data
|
|
||||||
system: undefined,
|
|
||||||
apps: undefined,
|
|
||||||
importantYunohostUpgrade: undefined,
|
|
||||||
pendingMigrations: undefined,
|
|
||||||
showPreUpgradeModal: false,
|
|
||||||
preUpgrade: {
|
|
||||||
apps: [],
|
|
||||||
notifs: [],
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['dark']),
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
onQueriesResponse({
|
|
||||||
apps,
|
|
||||||
system,
|
|
||||||
important_yunohost_upgrade,
|
|
||||||
pending_migrations,
|
|
||||||
}) {
|
|
||||||
this.apps = apps.length ? apps : null
|
|
||||||
this.system = system.length ? system : null
|
|
||||||
// eslint-disable-next-line camelcase
|
|
||||||
this.importantYunohostUpgrade = important_yunohost_upgrade
|
|
||||||
this.pendingMigrations = pending_migrations.length !== 0
|
|
||||||
},
|
|
||||||
|
|
||||||
formatAppNotifs(notifs) {
|
|
||||||
return Object.keys(notifs).reduce((acc, key) => {
|
|
||||||
return acc + '\n\n' + notifs[key]
|
|
||||||
}, '')
|
|
||||||
},
|
|
||||||
|
|
||||||
async confirmAppsUpgrade(id = null) {
|
|
||||||
const appList = id ? [this.apps.find((app) => app.id === id)] : this.apps
|
|
||||||
const apps = appList.map((app) => ({
|
|
||||||
id: app.id,
|
|
||||||
name: app.name,
|
|
||||||
notif: app.notifications.PRE_UPGRADE
|
|
||||||
? this.formatAppNotifs(app.notifications.PRE_UPGRADE)
|
|
||||||
: '',
|
|
||||||
}))
|
|
||||||
this.preUpgrade = { apps, hasNotifs: apps.some((app) => app.notif) }
|
|
||||||
this.showPreUpgradeModal = true
|
|
||||||
},
|
|
||||||
|
|
||||||
async performAppsUpgrade(ids) {
|
|
||||||
const apps = ids.map((id) => this.apps.find((app) => app.id === id))
|
|
||||||
const lastAppId = apps[apps.length - 1].id
|
|
||||||
|
|
||||||
for (const app of apps) {
|
|
||||||
const continue_ = await api
|
|
||||||
.put(
|
|
||||||
`apps/${app.id}/upgrade`,
|
|
||||||
{},
|
|
||||||
{ key: 'upgrade.app', app: app.name },
|
|
||||||
)
|
|
||||||
.then((response) => {
|
|
||||||
const postMessage = this.formatAppNotifs(
|
|
||||||
response.notifications.POST_UPGRADE,
|
|
||||||
)
|
|
||||||
const isLast = app.id === lastAppId
|
|
||||||
this.apps = this.apps.filter((a) => app.id !== a.id)
|
|
||||||
|
|
||||||
if (postMessage) {
|
|
||||||
const message =
|
|
||||||
this.$t('app.upgrade.notifs.post.alert') + '\n\n' + postMessage
|
|
||||||
return this.modalConfirm(
|
|
||||||
message,
|
|
||||||
{
|
|
||||||
title: this.$t('app.upgrade.notifs.post.title', {
|
|
||||||
name: app.name,
|
|
||||||
}),
|
|
||||||
okTitle: this.$t(isLast ? 'ok' : 'app.upgrade.continue'),
|
|
||||||
cancelTitle: this.$t('app.upgrade.stop'),
|
|
||||||
},
|
|
||||||
{ markdown: true, cancelable: !isLast },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
return Promise.resolve(true)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
if (!continue_) break
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!this.apps.length) {
|
|
||||||
this.apps = null
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async performSystemUpgrade() {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('confirm_update_system'),
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
|
|
||||||
api.put('upgrade/system', {}, { key: 'upgrade.system' }).then(() => {
|
|
||||||
if (this.system.some(({ name }) => name.includes('yunohost'))) {
|
|
||||||
this.$store.dispatch('TRY_TO_RECONNECT', {
|
|
||||||
attemps: 1,
|
|
||||||
origin: 'upgrade_system',
|
|
||||||
initialDelay: 2000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
this.system = null
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.card-collapse-wrapper {
|
.card-collapse-wrapper {
|
||||||
border: $card-border-width solid $card-border-color;
|
border: $card-border-width solid $card-border-color;
|
||||||
|
|
|
@ -1,3 +1,118 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { APIBadRequestError, type APIError } from '@/api/errors'
|
||||||
|
import {
|
||||||
|
alphalownumdot_,
|
||||||
|
minLength,
|
||||||
|
name,
|
||||||
|
required,
|
||||||
|
sameAs,
|
||||||
|
unique,
|
||||||
|
} from '@/helpers/validators'
|
||||||
|
import { formatFormData } from '@/helpers/yunohostArguments'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', { uri: 'users' }],
|
||||||
|
['GET', { uri: 'domains' }],
|
||||||
|
]
|
||||||
|
const { userNames, domainsAsChoices, mainDomain } = useStoreGetters()
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
username: {
|
||||||
|
label: t('user_username'),
|
||||||
|
props: {
|
||||||
|
id: 'username',
|
||||||
|
placeholder: t('placeholder.username'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
fullname: {
|
||||||
|
label: t('user_fullname'),
|
||||||
|
props: {
|
||||||
|
id: 'fullname',
|
||||||
|
placeholder: t('placeholder.fullname'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
domain: {
|
||||||
|
id: 'mail',
|
||||||
|
label: t('user_email'),
|
||||||
|
description: t('tip_about_user_email'),
|
||||||
|
descriptionVariant: 'info',
|
||||||
|
props: { choices: domainsAsChoices },
|
||||||
|
},
|
||||||
|
|
||||||
|
password: {
|
||||||
|
label: t('password'),
|
||||||
|
description: t('good_practices_about_user_password'),
|
||||||
|
descriptionVariant: 'warning',
|
||||||
|
props: {
|
||||||
|
id: 'password',
|
||||||
|
placeholder: '••••••••',
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmation: {
|
||||||
|
label: t('password_confirmation'),
|
||||||
|
props: {
|
||||||
|
id: 'confirmation',
|
||||||
|
placeholder: '••••••••',
|
||||||
|
type: 'password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const form = reactive({
|
||||||
|
username: '',
|
||||||
|
fullname: '',
|
||||||
|
domain: '',
|
||||||
|
password: '',
|
||||||
|
confirmation: '',
|
||||||
|
})
|
||||||
|
const rules = computed(() => ({
|
||||||
|
username: {
|
||||||
|
required,
|
||||||
|
alphalownumdot_,
|
||||||
|
notInUsers: unique(userNames.value),
|
||||||
|
},
|
||||||
|
fullname: { required, name },
|
||||||
|
domain: { required },
|
||||||
|
password: { required, passwordLenght: minLength(8) },
|
||||||
|
confirmation: { required, passwordMatch: sameAs(form.password) },
|
||||||
|
}))
|
||||||
|
const v$ = useVuelidate(rules, form)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function onQueriesResponse() {
|
||||||
|
form.domain = mainDomain.value
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const data = await formatFormData(form, { flatten: true })
|
||||||
|
api
|
||||||
|
.post({ uri: 'users' }, data, {
|
||||||
|
key: 'users.create',
|
||||||
|
name: form.username,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push({ name: 'user-list' })
|
||||||
|
})
|
||||||
|
.catch((err: APIError) => {
|
||||||
|
if (!(err instanceof APIBadRequestError)) throw err
|
||||||
|
serverError.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
|
@ -61,135 +176,3 @@
|
||||||
</CardForm>
|
</CardForm>
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import { formatFormData } from '@/helpers/yunohostArguments'
|
|
||||||
import {
|
|
||||||
alphalownumdot_,
|
|
||||||
unique,
|
|
||||||
required,
|
|
||||||
minLength,
|
|
||||||
name,
|
|
||||||
sameAs,
|
|
||||||
} from '@/helpers/validators'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'UserCreate',
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', { uri: 'users' }],
|
|
||||||
['GET', { uri: 'domains' }],
|
|
||||||
],
|
|
||||||
|
|
||||||
form: {
|
|
||||||
username: '',
|
|
||||||
fullname: '',
|
|
||||||
domain: '',
|
|
||||||
password: '',
|
|
||||||
confirmation: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
serverError: '',
|
|
||||||
|
|
||||||
fields: {
|
|
||||||
username: {
|
|
||||||
label: this.$t('user_username'),
|
|
||||||
props: {
|
|
||||||
id: 'username',
|
|
||||||
placeholder: this.$t('placeholder.username'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
fullname: {
|
|
||||||
label: this.$t('user_fullname'),
|
|
||||||
props: {
|
|
||||||
id: 'fullname',
|
|
||||||
placeholder: this.$t('placeholder.fullname'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
domain: {
|
|
||||||
id: 'mail',
|
|
||||||
label: this.$t('user_email'),
|
|
||||||
description: this.$t('tip_about_user_email'),
|
|
||||||
descriptionVariant: 'info',
|
|
||||||
props: { choices: [] },
|
|
||||||
},
|
|
||||||
|
|
||||||
password: {
|
|
||||||
label: this.$t('password'),
|
|
||||||
description: this.$t('good_practices_about_user_password'),
|
|
||||||
descriptionVariant: 'warning',
|
|
||||||
props: {
|
|
||||||
id: 'password',
|
|
||||||
placeholder: '••••••••',
|
|
||||||
type: 'password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmation: {
|
|
||||||
label: this.$t('password_confirmation'),
|
|
||||||
props: {
|
|
||||||
id: 'confirmation',
|
|
||||||
placeholder: '••••••••',
|
|
||||||
type: 'password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: mapGetters(['userNames', 'domainsAsChoices', 'mainDomain']),
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
username: {
|
|
||||||
required,
|
|
||||||
alphalownumdot_,
|
|
||||||
notInUsers: unique(this.userNames),
|
|
||||||
},
|
|
||||||
fullname: { required, name },
|
|
||||||
domain: { required },
|
|
||||||
password: { required, passwordLenght: minLength(8) },
|
|
||||||
confirmation: { required, passwordMatch: sameAs(this.form.password) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse() {
|
|
||||||
this.fields.domain.props.choices = this.domainsAsChoices
|
|
||||||
this.form.domain = this.mainDomain
|
|
||||||
},
|
|
||||||
|
|
||||||
async onSubmit() {
|
|
||||||
const data = await formatFormData(this.form, { flatten: true })
|
|
||||||
api
|
|
||||||
.post({ uri: 'users' }, data, {
|
|
||||||
key: 'users.create',
|
|
||||||
name: this.form.username,
|
|
||||||
})
|
|
||||||
.then(() => {
|
|
||||||
this.$router.push({ name: 'user-list' })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
this.serverError = err.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,8 +1,237 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
import { computed, nextTick, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import AdressInputSelect from '@/components/AdressInputSelect.vue'
|
||||||
|
import type ViewBase from '@/components/globals/ViewBase.vue'
|
||||||
|
import { arrayDiff } from '@/helpers/commons'
|
||||||
|
import {
|
||||||
|
emailForward,
|
||||||
|
emailLocalPart,
|
||||||
|
helpers,
|
||||||
|
integer,
|
||||||
|
minLength,
|
||||||
|
minValue,
|
||||||
|
name as nameValidator,
|
||||||
|
required,
|
||||||
|
sameAs,
|
||||||
|
} from '@/helpers/validators'
|
||||||
|
import {
|
||||||
|
adressToFormValue,
|
||||||
|
formatFormData,
|
||||||
|
sizeToM,
|
||||||
|
} from '@/helpers/yunohostArguments'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
name: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', { uri: 'users', param: props.name, storeKey: 'users_details' }],
|
||||||
|
['GET', { uri: 'domains' }],
|
||||||
|
]
|
||||||
|
const { user, domainsAsChoices, mainDomain } = useStoreGetters()
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
username: {
|
||||||
|
label: t('user_username'),
|
||||||
|
modelValue: props.name,
|
||||||
|
props: { id: 'username', disabled: true },
|
||||||
|
},
|
||||||
|
|
||||||
|
fullname: {
|
||||||
|
label: t('user_fullname'),
|
||||||
|
props: {
|
||||||
|
id: 'fullname',
|
||||||
|
placeholder: t('placeholder.fullname'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mail: {
|
||||||
|
label: t('user_email'),
|
||||||
|
props: { id: 'mail', choices: domainsAsChoices },
|
||||||
|
},
|
||||||
|
|
||||||
|
mailbox_quota: {
|
||||||
|
label: t('user_mailbox_quota'),
|
||||||
|
description: t('mailbox_quota_description'),
|
||||||
|
example: t('mailbox_quota_example'),
|
||||||
|
props: {
|
||||||
|
id: 'mailbox-quota',
|
||||||
|
placeholder: t('mailbox_quota_placeholder'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mail_aliases: {
|
||||||
|
props: {
|
||||||
|
placeholder: t('placeholder.username'),
|
||||||
|
choices: domainsAsChoices,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
mail_forward: {
|
||||||
|
props: {
|
||||||
|
placeholder: t('user_new_forward'),
|
||||||
|
type: 'email',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
change_password: {
|
||||||
|
label: t('password'),
|
||||||
|
description: t('good_practices_about_user_password'),
|
||||||
|
descriptionVariant: 'warning',
|
||||||
|
props: {
|
||||||
|
id: 'change_password',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: '••••••••',
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
confirmation: {
|
||||||
|
label: t('password_confirmation'),
|
||||||
|
props: {
|
||||||
|
id: 'confirmation',
|
||||||
|
type: 'password',
|
||||||
|
placeholder: '••••••••',
|
||||||
|
autocomplete: 'new-password',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const form = reactive({
|
||||||
|
fullname: '',
|
||||||
|
mail: { localPart: '', separator: '@', domain: '' },
|
||||||
|
mailbox_quota: '',
|
||||||
|
mail_aliases: [],
|
||||||
|
mail_forward: [],
|
||||||
|
change_password: '',
|
||||||
|
confirmation: '',
|
||||||
|
})
|
||||||
|
const rules = computed(() => ({
|
||||||
|
fullname: { required, nameValidator },
|
||||||
|
mail: {
|
||||||
|
localPart: { required, email: emailLocalPart },
|
||||||
|
},
|
||||||
|
mailbox_quota: { integer, minValue: minValue(0) },
|
||||||
|
mail_aliases: {
|
||||||
|
$each: helpers.forEach({
|
||||||
|
localPart: { required, email: emailLocalPart },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
mail_forward: {
|
||||||
|
$each: helpers.forEach({
|
||||||
|
mail: { required, emailForward },
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
change_password: { passwordLenght: minLength(8) },
|
||||||
|
confirmation: { passwordMatch: sameAs(form.change_password) },
|
||||||
|
}))
|
||||||
|
const v$ = useVuelidate(rules, form)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
function onQueriesResponse(user_) {
|
||||||
|
form.fullname = user_.fullname
|
||||||
|
form.mail = adressToFormValue(user_.mail)
|
||||||
|
if (user_['mail-aliases']) {
|
||||||
|
form.mail_aliases = user_['mail-aliases'].map((mail) =>
|
||||||
|
adressToFormValue(mail),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (user_['mail-forward']) {
|
||||||
|
form.mail_forward = user_['mail-forward'].map((mail) => ({ mail })) // Copy value
|
||||||
|
}
|
||||||
|
// mailbox-quota could be 'No quota' or 'Pas de quota'...
|
||||||
|
if (parseInt(user_['mailbox-quota'].limit) > 0) {
|
||||||
|
form.mailbox_quota = sizeToM(user_['mailbox-quota'].limit)
|
||||||
|
} else {
|
||||||
|
form.mailbox_quota = ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
const formData = await formatFormData(form, { flatten: true })
|
||||||
|
// FIXME not sure computed can be executed?
|
||||||
|
const user_ = user.value(props.name)
|
||||||
|
const data = {}
|
||||||
|
if (!Object.prototype.hasOwnProperty.call(formData, 'mailbox_quota')) {
|
||||||
|
formData.mailbox_quota = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
formData.mail_forward = formData.mail_forward?.map((v) => v.mail)
|
||||||
|
|
||||||
|
for (const key of ['mail_aliases', 'mail_forward']) {
|
||||||
|
const dashedKey = key.replace('_', '-')
|
||||||
|
const newKey = key.replace('_', '').replace('es', '')
|
||||||
|
const addDiff = arrayDiff(formData[key], user_[dashedKey])
|
||||||
|
const rmDiff = arrayDiff(user_[dashedKey], formData[key])
|
||||||
|
if (addDiff.length) data['add_' + newKey] = addDiff
|
||||||
|
if (rmDiff.length) data['remove_' + newKey] = rmDiff
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const key in formData) {
|
||||||
|
if (key === 'mailbox_quota') {
|
||||||
|
const quota =
|
||||||
|
parseInt(formData[key]) > 0 ? formData[key] + 'M' : 'No quota'
|
||||||
|
if (parseInt(quota) !== parseInt(user_['mailbox-quota'].limit)) {
|
||||||
|
data[key] = quota === 'No quota' ? '0' : quota
|
||||||
|
}
|
||||||
|
} else if (!key.includes('mail_') && formData[key] !== user_[key]) {
|
||||||
|
data[key] = formData[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(data).length === 0) {
|
||||||
|
serverError.value = t('error_modify_something')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
api
|
||||||
|
.put({ uri: 'users', param: props.name, storeKey: 'users_details' }, data, {
|
||||||
|
key: 'users.update',
|
||||||
|
name: props.name,
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
router.push({ name: 'user-info', param: { name: props.name } })
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (err.name !== 'APIBadRequestError') throw err
|
||||||
|
serverError.value = err.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function addEmailField(type: 'aliases' | 'forward') {
|
||||||
|
form['mail_' + type].push(
|
||||||
|
type === 'aliases'
|
||||||
|
? { localPart: '', separator: '@', domain: mainDomain.value }
|
||||||
|
: { mail: '' },
|
||||||
|
)
|
||||||
|
// Focus last input after rendering update
|
||||||
|
nextTick(() => {
|
||||||
|
const inputs = viewElem.value!.$el.querySelectorAll(`#mail-${type} input`)
|
||||||
|
inputs[inputs.length - 1].focus()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeEmailField(type: 'aliases' | 'forward', index: number) {
|
||||||
|
form['mail_' + type].splice(index, 1)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase
|
<ViewBase
|
||||||
:queries="queries"
|
:queries="queries"
|
||||||
@queries-response="onQueriesResponse"
|
@queries-response="onQueriesResponse"
|
||||||
skeleton="CardFormSkeleton"
|
skeleton="CardFormSkeleton"
|
||||||
|
ref="viewElem"
|
||||||
>
|
>
|
||||||
<CardForm
|
<CardForm
|
||||||
:title="$t('user_username_edit', { name })"
|
:title="$t('user_username_edit', { name })"
|
||||||
|
@ -108,254 +337,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import api from '@/api'
|
|
||||||
import { arrayDiff } from '@/helpers/commons'
|
|
||||||
import {
|
|
||||||
sizeToM,
|
|
||||||
adressToFormValue,
|
|
||||||
formatFormData,
|
|
||||||
} from '@/helpers/yunohostArguments'
|
|
||||||
import {
|
|
||||||
helpers,
|
|
||||||
name,
|
|
||||||
required,
|
|
||||||
minLength,
|
|
||||||
emailLocalPart,
|
|
||||||
sameAs,
|
|
||||||
integer,
|
|
||||||
minValue,
|
|
||||||
emailForward,
|
|
||||||
} from '@/helpers/validators'
|
|
||||||
|
|
||||||
import AdressInputSelect from '@/components/AdressInputSelect.vue'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'UserEdit',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
name: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', { uri: 'users', param: this.name, storeKey: 'users_details' }],
|
|
||||||
['GET', { uri: 'domains' }],
|
|
||||||
],
|
|
||||||
|
|
||||||
form: {
|
|
||||||
fullname: '',
|
|
||||||
mail: { localPart: '', separator: '@', domain: '' },
|
|
||||||
mailbox_quota: '',
|
|
||||||
mail_aliases: [],
|
|
||||||
mail_forward: [],
|
|
||||||
change_password: '',
|
|
||||||
confirmation: '',
|
|
||||||
},
|
|
||||||
|
|
||||||
serverError: '',
|
|
||||||
|
|
||||||
fields: {
|
|
||||||
username: {
|
|
||||||
label: this.$t('user_username'),
|
|
||||||
modelValue: this.name,
|
|
||||||
props: { id: 'username', disabled: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
fullname: {
|
|
||||||
label: this.$t('user_fullname'),
|
|
||||||
props: {
|
|
||||||
id: 'fullname',
|
|
||||||
placeholder: this.$t('placeholder.fullname'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mail: {
|
|
||||||
label: this.$t('user_email'),
|
|
||||||
props: { id: 'mail', choices: [] },
|
|
||||||
},
|
|
||||||
|
|
||||||
mailbox_quota: {
|
|
||||||
label: this.$t('user_mailbox_quota'),
|
|
||||||
description: this.$t('mailbox_quota_description'),
|
|
||||||
example: this.$t('mailbox_quota_example'),
|
|
||||||
props: {
|
|
||||||
id: 'mailbox-quota',
|
|
||||||
placeholder: this.$t('mailbox_quota_placeholder'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mail_aliases: {
|
|
||||||
props: {
|
|
||||||
placeholder: this.$t('placeholder.username'),
|
|
||||||
choices: [],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
mail_forward: {
|
|
||||||
props: {
|
|
||||||
placeholder: this.$t('user_new_forward'),
|
|
||||||
type: 'email',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
change_password: {
|
|
||||||
label: this.$t('password'),
|
|
||||||
description: this.$t('good_practices_about_user_password'),
|
|
||||||
descriptionVariant: 'warning',
|
|
||||||
props: {
|
|
||||||
id: 'change_password',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: '••••••••',
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
confirmation: {
|
|
||||||
label: this.$t('password_confirmation'),
|
|
||||||
props: {
|
|
||||||
id: 'confirmation',
|
|
||||||
type: 'password',
|
|
||||||
placeholder: '••••••••',
|
|
||||||
autocomplete: 'new-password',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: mapGetters(['user', 'domainsAsChoices', 'mainDomain']),
|
|
||||||
|
|
||||||
validations() {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
fullname: { required, name },
|
|
||||||
mail: {
|
|
||||||
localPart: { required, email: emailLocalPart },
|
|
||||||
},
|
|
||||||
mailbox_quota: { integer, minValue: minValue(0) },
|
|
||||||
mail_aliases: {
|
|
||||||
$each: helpers.forEach({
|
|
||||||
localPart: { required, email: emailLocalPart },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
mail_forward: {
|
|
||||||
$each: helpers.forEach({
|
|
||||||
mail: { required, emailForward },
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
change_password: { passwordLenght: minLength(8) },
|
|
||||||
confirmation: { passwordMatch: sameAs(this.form.change_password) },
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
onQueriesResponse(user) {
|
|
||||||
this.fields.mail.props.choices = this.domainsAsChoices
|
|
||||||
this.fields.mail_aliases.props.choices = this.domainsAsChoices
|
|
||||||
|
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (user['mail-forward']) {
|
|
||||||
this.form.mail_forward = user['mail-forward'].map((mail) => ({ mail })) // Copy value
|
|
||||||
}
|
|
||||||
// mailbox-quota could be 'No quota' or 'Pas de quota'...
|
|
||||||
if (parseInt(user['mailbox-quota'].limit) > 0) {
|
|
||||||
this.form.mailbox_quota = sizeToM(user['mailbox-quota'].limit)
|
|
||||||
} else {
|
|
||||||
this.form.mailbox_quota = ''
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async onSubmit() {
|
|
||||||
const formData = await formatFormData(this.form, { flatten: true })
|
|
||||||
const user = this.user(this.name)
|
|
||||||
const data = {}
|
|
||||||
if (!Object.prototype.hasOwnProperty.call(formData, 'mailbox_quota')) {
|
|
||||||
formData.mailbox_quota = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
formData.mail_forward = formData.mail_forward?.map((v) => v.mail)
|
|
||||||
|
|
||||||
for (const key of ['mail_aliases', 'mail_forward']) {
|
|
||||||
const dashedKey = key.replace('_', '-')
|
|
||||||
const newKey = key.replace('_', '').replace('es', '')
|
|
||||||
const addDiff = arrayDiff(formData[key], user[dashedKey])
|
|
||||||
const rmDiff = arrayDiff(user[dashedKey], formData[key])
|
|
||||||
if (addDiff.length) data['add_' + newKey] = addDiff
|
|
||||||
if (rmDiff.length) data['remove_' + newKey] = rmDiff
|
|
||||||
}
|
|
||||||
|
|
||||||
for (const key in formData) {
|
|
||||||
if (key === 'mailbox_quota') {
|
|
||||||
const quota =
|
|
||||||
parseInt(formData[key]) > 0 ? formData[key] + 'M' : 'No quota'
|
|
||||||
if (parseInt(quota) !== parseInt(user['mailbox-quota'].limit)) {
|
|
||||||
data[key] = quota === 'No quota' ? '0' : quota
|
|
||||||
}
|
|
||||||
} else if (!key.includes('mail_') && formData[key] !== user[key]) {
|
|
||||||
data[key] = formData[key]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(data).length === 0) {
|
|
||||||
this.serverError = this.$t('error_modify_something')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
api
|
|
||||||
.put(
|
|
||||||
{ uri: 'users', param: this.name, storeKey: 'users_details' },
|
|
||||||
data,
|
|
||||||
{ key: 'users.update', name: this.name },
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
this.$router.push({ name: 'user-info', param: { name: this.name } })
|
|
||||||
})
|
|
||||||
.catch((err) => {
|
|
||||||
if (err.name !== 'APIBadRequestError') throw err
|
|
||||||
this.serverError = err.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
addEmailField(type) {
|
|
||||||
this.form['mail_' + type].push(
|
|
||||||
type === 'aliases'
|
|
||||||
? { localPart: '', separator: '@', domain: this.mainDomain }
|
|
||||||
: { mail: '' },
|
|
||||||
)
|
|
||||||
// Focus last input after rendering update
|
|
||||||
this.$nextTick(() => {
|
|
||||||
const inputs = this.$el.querySelectorAll(`#mail-${type} input`)
|
|
||||||
inputs[inputs.length - 1].focus()
|
|
||||||
})
|
|
||||||
},
|
|
||||||
|
|
||||||
removeEmailField(type, index) {
|
|
||||||
this.form['mail_' + type].splice(index, 1)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
components: { AdressInputSelect },
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.mail-list {
|
.mail-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
|
@ -1,3 +1,89 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { useAutoModal } from '@/composables/useAutoModal'
|
||||||
|
import { formatFormData } from '@/helpers/yunohostArguments'
|
||||||
|
import { useVuelidate } from '@vuelidate/core'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useStore()
|
||||||
|
const modalConfirm = useAutoModal()
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
csvfile: {
|
||||||
|
label: t('users_import_csv_file'),
|
||||||
|
description: t('users_import_csv_file_desc'),
|
||||||
|
component: 'FileItem',
|
||||||
|
props: {
|
||||||
|
id: 'csvfile',
|
||||||
|
accept: 'text/csv',
|
||||||
|
placeholder: t('placeholder.file'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
update: {
|
||||||
|
label: t('users_import_update'),
|
||||||
|
description: t('users_import_update_desc'),
|
||||||
|
component: 'CheckboxItem',
|
||||||
|
props: {
|
||||||
|
id: 'update',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: {
|
||||||
|
label: t('users_import_delete'),
|
||||||
|
description: t('users_import_delete_desc'),
|
||||||
|
component: 'CheckboxItem',
|
||||||
|
props: {
|
||||||
|
id: 'delete',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const form = reactive({
|
||||||
|
csvfile: { file: null },
|
||||||
|
update: false,
|
||||||
|
delete: false,
|
||||||
|
})
|
||||||
|
const rules = computed(() => ({}))
|
||||||
|
const v$ = useVuelidate(rules, form)
|
||||||
|
const serverError = ref('')
|
||||||
|
|
||||||
|
async function onSubmit() {
|
||||||
|
if (form.delete) {
|
||||||
|
const confirmed = await modalConfirm(
|
||||||
|
t('users_import_confirm_destructive'),
|
||||||
|
{ okTitle: t('users_import_delete_others') },
|
||||||
|
)
|
||||||
|
if (!confirmed) return
|
||||||
|
}
|
||||||
|
|
||||||
|
const requestArgs = { ...form } as Partial<typeof form>
|
||||||
|
if (!requestArgs.delete) delete requestArgs.delete
|
||||||
|
if (!requestArgs.update) delete requestArgs.update
|
||||||
|
const data = await formatFormData(requestArgs)
|
||||||
|
api
|
||||||
|
.post('users/import', data, null, { asFormData: true })
|
||||||
|
.then(() => {
|
||||||
|
// Reset all cached data related to users.
|
||||||
|
store.dispatch('RESET_CACHE_DATA', [
|
||||||
|
'users',
|
||||||
|
'users_details',
|
||||||
|
'groups',
|
||||||
|
'permissions',
|
||||||
|
])
|
||||||
|
router.push({ name: 'user-list' })
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
serverError.value = error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<CardForm
|
<CardForm
|
||||||
:title="$t('users_import')"
|
:title="$t('users_import')"
|
||||||
|
@ -20,105 +106,3 @@
|
||||||
<FormField v-bind="fields.delete" v-model="form.delete" />
|
<FormField v-bind="fields.delete" v-model="form.delete" />
|
||||||
</CardForm>
|
</CardForm>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
import { useAutoModal } from '@/composables/useAutoModal'
|
|
||||||
import { useVuelidate } from '@vuelidate/core'
|
|
||||||
|
|
||||||
import { formatFormData } from '@/helpers/yunohostArguments'
|
|
||||||
import { required } from '@/helpers/validators'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'UserImport',
|
|
||||||
|
|
||||||
setup() {
|
|
||||||
return {
|
|
||||||
v$: useVuelidate(),
|
|
||||||
modalConfirm: useAutoModal(),
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
form: {
|
|
||||||
csvfile: { file: null },
|
|
||||||
update: false,
|
|
||||||
delete: false,
|
|
||||||
},
|
|
||||||
|
|
||||||
serverError: '',
|
|
||||||
|
|
||||||
fields: {
|
|
||||||
csvfile: {
|
|
||||||
label: this.$t('users_import_csv_file'),
|
|
||||||
description: this.$t('users_import_csv_file_desc'),
|
|
||||||
component: 'FileItem',
|
|
||||||
props: {
|
|
||||||
id: 'csvfile',
|
|
||||||
accept: 'text/csv',
|
|
||||||
placeholder: this.$t('placeholder.file'),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
update: {
|
|
||||||
label: this.$t('users_import_update'),
|
|
||||||
description: this.$t('users_import_update_desc'),
|
|
||||||
component: 'CheckboxItem',
|
|
||||||
props: {
|
|
||||||
id: 'update',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: {
|
|
||||||
label: this.$t('users_import_delete'),
|
|
||||||
description: this.$t('users_import_delete_desc'),
|
|
||||||
component: 'CheckboxItem',
|
|
||||||
props: {
|
|
||||||
id: 'delete',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
validations: {
|
|
||||||
form: {
|
|
||||||
csvfile: { required },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
async onSubmit() {
|
|
||||||
if (this.form.delete) {
|
|
||||||
const confirmed = await this.modalConfirm(
|
|
||||||
this.$t('users_import_confirm_destructive'),
|
|
||||||
{ okTitle: this.$t('users_import_delete_others') },
|
|
||||||
)
|
|
||||||
if (!confirmed) return
|
|
||||||
}
|
|
||||||
|
|
||||||
const requestArgs = {}
|
|
||||||
Object.assign(requestArgs, this.form)
|
|
||||||
if (!requestArgs.delete) delete requestArgs.delete
|
|
||||||
if (!requestArgs.update) delete requestArgs.update
|
|
||||||
const data = await formatFormData(requestArgs)
|
|
||||||
api
|
|
||||||
.post('users/import', data, { asFormData: true })
|
|
||||||
.then(() => {
|
|
||||||
// Reset all cached data related to users.
|
|
||||||
this.$store.dispatch('RESET_CACHE_DATA', [
|
|
||||||
'users',
|
|
||||||
'users_details',
|
|
||||||
'groups',
|
|
||||||
'permissions',
|
|
||||||
])
|
|
||||||
this.$router.push({ name: 'user-list' })
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
this.serverError = error.message
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
|
@ -1,3 +1,36 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
import api from '@/api'
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const props = defineProps<{ name: string }>()
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
['GET', { uri: 'users', param: props.name, storeKey: 'users_details' }],
|
||||||
|
]
|
||||||
|
|
||||||
|
const { user: userGetter } = useStoreGetters()
|
||||||
|
const purge = ref(false)
|
||||||
|
const user = computed(() => userGetter.value(props.name))
|
||||||
|
|
||||||
|
function deleteUser() {
|
||||||
|
const data = purge.value ? { purge: '' } : {}
|
||||||
|
api
|
||||||
|
.delete(
|
||||||
|
{ uri: 'users', param: props.name, storeKey: 'users_details' },
|
||||||
|
data,
|
||||||
|
{ key: 'users.delete', name: props.name },
|
||||||
|
)
|
||||||
|
.then(() => {
|
||||||
|
router.push({ name: 'user-list' })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewBase :queries="queries" skeleton="CardInfoSkeleton">
|
<ViewBase :queries="queries" skeleton="CardInfoSkeleton">
|
||||||
<YCard v-if="user" :title="user.fullname" icon="user">
|
<YCard v-if="user" :title="user.fullname" icon="user">
|
||||||
|
@ -101,48 +134,6 @@
|
||||||
</ViewBase>
|
</ViewBase>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import api from '@/api'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'UserInfo',
|
|
||||||
|
|
||||||
props: {
|
|
||||||
name: { type: String, required: true },
|
|
||||||
},
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
['GET', { uri: 'users', param: this.name, storeKey: 'users_details' }],
|
|
||||||
],
|
|
||||||
purge: false,
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
computed: {
|
|
||||||
user() {
|
|
||||||
return this.$store.getters.user(this.name)
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
methods: {
|
|
||||||
deleteUser() {
|
|
||||||
const data = this.purge ? { purge: '' } : {}
|
|
||||||
api
|
|
||||||
.delete(
|
|
||||||
{ uri: 'users', param: this.name, storeKey: 'users_details' },
|
|
||||||
data,
|
|
||||||
{ key: 'users.delete', name: this.name },
|
|
||||||
)
|
|
||||||
.then(() => {
|
|
||||||
this.$router.push({ name: 'user-list' })
|
|
||||||
})
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.icon.fa-user {
|
.icon.fa-user {
|
||||||
font-size: 10rem;
|
font-size: 10rem;
|
||||||
|
|
|
@ -1,3 +1,42 @@
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import { useStore } from 'vuex'
|
||||||
|
|
||||||
|
import { useStoreGetters } from '@/store/utils'
|
||||||
|
|
||||||
|
const store = useStore()
|
||||||
|
|
||||||
|
const queries = [
|
||||||
|
[
|
||||||
|
'GET',
|
||||||
|
{
|
||||||
|
uri: 'users?fields=username&fields=fullname&fields=mail&fields=mailbox-quota&fields=groups',
|
||||||
|
storeKey: 'users',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
]
|
||||||
|
|
||||||
|
const { users } = useStoreGetters()
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const filteredUsers = computed(() => {
|
||||||
|
if (!users.value) return
|
||||||
|
const search_ = search.value.toLowerCase()
|
||||||
|
const filtered = users.value.filter((user) => {
|
||||||
|
return (
|
||||||
|
user.username.toLowerCase().includes(search_) ||
|
||||||
|
user.groups.includes(search_)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
return filtered.length === 0 ? null : filtered
|
||||||
|
})
|
||||||
|
|
||||||
|
function downloadExport() {
|
||||||
|
const host = store.getters.host
|
||||||
|
window.open(`https://${host}/yunohost/api/users/export`, '_blank')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<ViewSearch
|
<ViewSearch
|
||||||
v-model:search="search"
|
v-model:search="search"
|
||||||
|
@ -51,47 +90,3 @@
|
||||||
</BListGroup>
|
</BListGroup>
|
||||||
</ViewSearch>
|
</ViewSearch>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script>
|
|
||||||
import { mapGetters } from 'vuex'
|
|
||||||
|
|
||||||
export default {
|
|
||||||
name: 'UserList',
|
|
||||||
|
|
||||||
data() {
|
|
||||||
return {
|
|
||||||
queries: [
|
|
||||||
[
|
|
||||||
'GET',
|
|
||||||
{
|
|
||||||
uri: 'users?fields=username&fields=fullname&fields=mail&fields=mailbox-quota&fields=groups',
|
|
||||||
storeKey: 'users',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
],
|
|
||||||
search: '',
|
|
||||||
}
|
|
||||||
},
|
|
||||||
methods: {
|
|
||||||
downloadExport() {
|
|
||||||
const host = this.$store.getters.host
|
|
||||||
window.open(`https://${host}/yunohost/api/users/export`, '_blank')
|
|
||||||
},
|
|
||||||
},
|
|
||||||
computed: {
|
|
||||||
...mapGetters(['users']),
|
|
||||||
|
|
||||||
filteredUsers() {
|
|
||||||
if (!this.users) return
|
|
||||||
const search = this.search.toLowerCase()
|
|
||||||
const filtered = this.users.filter((user) => {
|
|
||||||
return (
|
|
||||||
user.username.toLowerCase().includes(search) ||
|
|
||||||
user.groups.includes(search)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
return filtered.length === 0 ? null : filtered
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
Loading…
Reference in a new issue