refactor: quickly turn components to script setup + ts

This commit is contained in:
axolotle 2024-05-30 00:42:53 +02:00
parent 0f673709af
commit 7123ba6cdc
87 changed files with 5278 additions and 5615 deletions

View file

@ -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>
<div id="app" class="container">
<!-- HEADER -->
@ -97,104 +181,6 @@
</div>
</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>
// generic style for <html>, <body> and <#app> is in `scss/main.scss`
header {

View file

@ -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>
<BInputGroup v-bind="$attrs">
<InputItem
@ -30,29 +66,3 @@
/>
</BInputGroup>
</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>

View file

@ -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>
<BCard v-bind="$attrs" no-body :class="_class">
<BCard no-body :class="class_">
<template #header>
<slot name="header">
<h2>
@ -21,33 +52,6 @@
</BCard>
</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>
.card-collapse {
.card-header {

View file

@ -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
// https://www.w3.org/WAI/ARIA/apg/patterns/feed/
import { h } from 'vue'
import { BCardGroup } from 'bootstrap-vue-next'
export default {
name: 'CardDeckFeed',
const props = withDefaults(defineProps<{ stacks?: number }>(), { stacks: 21 })
const slots = defineSlots<{
default: any
}>()
props: {
stacks: { type: Number, default: 21 },
},
const busy = ref(false)
const range = ref(props.stacks)
const childrenCount = ref(slots.default()[0].children.length)
const feedElem = ref<InstanceType<typeof BCardGroup> | null>(null)
data() {
return {
busy: false,
range: this.stacks,
childrenCount: this.$slots.default()[0].children,
}
},
methods: {
getTopParent(prev) {
return prev.parentElement === this.$refs.feed.$el
? prev
: this.getTopParent(prev.parentElement)
},
onScroll() {
const elem = this.$refs.feed.$el
if (
window.innerHeight >
elem.clientHeight + elem.getBoundingClientRect().top - 200
) {
this.busy = true
this.range = Math.min(this.range + this.stacks, this.childrenCount)
this.$nextTick().then(() => {
this.busy = false
})
}
},
onKeydown(e) {
if (['PageUp', 'PageDown'].includes(e.code)) {
e.preventDefault()
const key = e.code === 'PageUp' ? 'previous' : 'next'
const sibling = this.getTopParent(e.target)[`${key}ElementSibling`]
if (sibling) {
sibling.focus()
sibling.scrollIntoView({ block: 'center' })
}
}
// 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() {
return h(
BCardGroup,
{
deck: true,
role: 'feed',
'aria-busy': this.busy.toString(),
ref: 'feed',
},
{
default: () => this.$slots.default()[0].children.slice(0, this.range),
},
)
},
beforeUnmount() {
window.removeEventListener('scroll', this.onScroll)
this.$refs.feed.$el.removeEventListener('keydown', this.onKeydown)
},
function getTopParent(prev: HTMLElement): HTMLElement {
return prev.parentElement === feedElem.value?.$el
? prev
: getTopParent(prev.parentElement!)
}
const i = getCurrentInstance()
function onScroll() {
const elem = feedElem.value?.$el
if (
window.innerHeight >
elem.clientHeight + elem.getBoundingClientRect().top - 200
) {
busy.value = true
range.value = Math.min(range.value + props.stacks, childrenCount.value)
nextTick().then(() => {
busy.value = false
})
}
}
function onKeydown(e: KeyboardEvent) {
if (['PageUp', 'PageDown'].includes(e.code)) {
e.preventDefault()
const key = e.code === 'PageUp' ? 'previous' : 'next'
const sibling = getTopParent(e.target as HTMLElement)[
`${key}ElementSibling`
] as HTMLElement | null
sibling?.focus()
sibling?.scrollIntoView({ block: 'center' })
}
// FIXME Add `Home` and `End` shorcuts
}
onMounted(() => {
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,
{
deck: true,
role: 'feed',
'aria-busy': busy.value,
ref: feedElem,
},
{
default: () => slots.default()[0].children.slice(0, range.value),
},
)
</script>
<template>
{{ busy }}
<root />
</template>

View file

@ -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>
<AbstractForm
v-if="panel"
@ -12,7 +73,7 @@
<slot name="tab-top" />
<template v-if="panel.help" #disclaimer>
<div class="alert alert-info" v-html="help" />
<div class="alert alert-info" v-html="panel.help" />
</template>
<slot name="tab-before" />
@ -50,56 +111,6 @@
</AbstractForm>
</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>
.card-title {
margin-bottom: 1em;

View file

@ -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>
<div class="config-panel">
<!-- FIXME vue3 - weird stuff with event binding, need to propagate by hand for now -->
<RoutableTabs
v-if="routes_.length > 1"
v-if="routes.length > 1"
v-bind="{ panels, forms, v: v$, ...$attrs }"
:routes="routes_"
:routes="routes"
>
<template #tab-top>
<slot name="tab-top" />
@ -17,66 +79,10 @@
</template>
</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-before" />
<slot name="tab-after" />
</YCard>
</div>
</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>

View file

@ -1,76 +1,88 @@
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref } from 'vue'
const props = withDefaults(
defineProps<{
unrender?: boolean
minHeight?: number
renderDelay?: number
unrenderDelay?: number
rootMargin?: string
}>(),
{
unrender: true,
minHeight: 0,
renderDelay: 100,
unrenderDelay: 2000,
rootMargin: '300px',
},
)
defineSlots<{
default: any
}>()
const observer = ref<IntersectionObserver | null>(null)
const render = ref(false)
const fixedMinHeight = ref(props.minHeight)
const rootElem = ref<HTMLDivElement | null>(null)
onMounted(() => {
let unrenderTimer: NodeJS.Timeout
let renderTimer: NodeJS.Timeout
observer.value = new IntersectionObserver(
(entries) => {
let intersecting = entries[0].isIntersecting
// Fix for weird bug when typing fast in app search or on slow client.
// Intersection is triggered but even if the element is indeed in the viewport,
// isIntersecting is `false`, so we have to manually check this
// FIXME Would be great to find out why this is happening
if (!intersecting && rootElem.value!.offsetTop < window.innerHeight) {
intersecting = true
}
if (intersecting) {
clearTimeout(unrenderTimer)
// Show the component after a delay (to avoid rendering while scrolling fast)
renderTimer = setTimeout(
() => {
render.value = true
},
props.unrender ? props.renderDelay : 0,
)
if (!props.unrender) {
// Stop listening to intersections after first appearance if unrendering is not activated
this.observer.disconnect()
}
} else if (props.unrender) {
clearTimeout(renderTimer)
// Hide the component after a delay if it's no longer in the viewport
unrenderTimer = setTimeout(() => {
fixedMinHeight.value = rootElem.value!.clientHeight
render.value = false
}, props.unrenderDelay)
}
},
{ rootMargin: props.rootMargin },
)
observer.value.observe(rootElem.value!)
})
onBeforeUnmount(() => {
observer.value!.disconnect()
})
</script>
<template>
<div class="lazy-renderer" :style="`min-height: ${fixedMinHeight}px`">
<div
ref="rootElem"
class="lazy-renderer"
:style="`min-height: ${fixedMinHeight}px`"
>
<slot v-if="render" />
</div>
</template>
<script>
export default {
name: 'LazyRenderer',
props: {
unrender: { type: Boolean, default: true },
minHeight: { type: Number, default: 0 },
renderDelay: { type: Number, default: 100 },
unrenderDelay: { type: Number, default: 2000 },
rootMargin: { type: String, default: '300px' },
},
data() {
return {
observer: null,
render: false,
fixedMinHeight: this.minHeight,
}
},
mounted() {
let unrenderTimer
let renderTimer
this.observer = new IntersectionObserver(
(entries) => {
let intersecting = entries[0].isIntersecting
// Fix for weird bug when typing fast in app search or on slow client.
// Intersection is triggered but even if the element is indeed in the viewport,
// isIntersecting is `false`, so we have to manually check this
// FIXME Would be great to find out why this is happening
if (!intersecting && this.$el.offsetTop < window.innerHeight) {
intersecting = true
}
if (intersecting) {
clearTimeout(unrenderTimer)
// Show the component after a delay (to avoid rendering while scrolling fast)
renderTimer = setTimeout(
() => {
this.render = true
},
this.unrender ? this.renderDelay : 0,
)
if (!this.unrender) {
// Stop listening to intersections after first appearance if unrendering is not activated
this.observer.disconnect()
}
} else if (this.unrender) {
clearTimeout(renderTimer)
// Hide the component after a delay if it's no longer in the viewport
unrenderTimer = setTimeout(() => {
this.fixedMinHeight = this.$el.clientHeight
this.render = false
}, this.unrenderDelay)
}
},
{ rootMargin: this.rootMargin },
)
this.observer.observe(this.$el)
},
beforeUnmount() {
this.observer.disconnect()
},
}
</script>

View file

@ -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>
<BListGroup
v-bind="$attrs"
ref="rootElem"
flush
:class="{ 'fixed-height': fixedHeight, bordered: bordered }"
@scroll="onScroll"
@ -22,55 +74,6 @@
</BListGroup>
</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>
.fixed-height {
max-height: 20vh;

View file

@ -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>
<div v-bind="$attrs" class="query-header w-100">
<div class="query-header w-100">
<!-- STATUS -->
<span
class="status"
@ -47,51 +96,6 @@
</div>
</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>
div {
display: flex;

View file

@ -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>
<BListGroup :flush="flush" :style="{ '--depth': tree.depth }">
<template v-for="(node, i) in tree.children" :key="node.id">
@ -9,7 +41,7 @@
<slot name="default" v-bind="node" />
<BButton
v-if="node.children"
v-if="node.height > 0"
size="xs"
variant="outline-secondary"
:aria-expanded="node.data.opened ? 'true' : 'false'"
@ -24,7 +56,7 @@
</BListGroupItem>
<BCollapse
v-if="node.children"
v-if="node.height > 0"
v-model="node.data.opened"
:id="'collapse-' + node.id"
>
@ -43,31 +75,6 @@
</BListGroup>
</template>
<script>
export default {
name: 'RecursiveListGroup',
props: {
tree: { type: Object, required: true },
flush: { type: Boolean, default: false },
last: { type: Boolean, default: undefined },
toggleText: { type: String, default: null },
},
methods: {
getClasses(node, i) {
const children = node.height > 0
const opened = children && node.data.opened
const last =
this.last !== false &&
(!children || !opened) &&
i === this.tree.children.length - 1
return { collapsible: children, uncollapsible: !children, opened, last }
},
},
}
</script>
<style lang="scss" scoped>
.list-group {
.collapse {

View file

@ -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>
<BCard no-body>
<BCardHeader header-tag="nav">
@ -32,17 +50,3 @@
</RouterView>
</BCard>
</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>

View file

@ -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>
<div>
<BCardBody>
<slot name="disclaimer" />
<BForm
v-bind="$attrs"
:id="id"
:inline="inline"
:class="formClasses"
novalidate
@submit.prevent.stop="onSubmit"
>
@ -35,44 +87,6 @@
</div>
</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>
.card-footer {
display: flex;

View file

@ -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>
<!-- FIXME inheritAttrs false? probably remove vbind instead -->
<YCard v-bind="$attrs" class="card-form">
<template #default>
<slot name="disclaimer" />
{{ serverError }}
<BForm
:id="id"
:inline="inline"
@ -34,41 +88,3 @@
</template>
</YCard>
</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>

View file

@ -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>
<BRow no-gutters class="description-row">
<BCol v-bind="cols_">
<BCol v-bind="cols">
<slot name="term">
<strong>{{ term }}</strong>
</slot>
@ -14,24 +39,6 @@
</BRow>
</template>
<script>
export default {
name: 'DescriptionRow',
props: {
term: { type: String, default: null },
details: { type: String, default: null },
cols: { type: Object, default: () => ({ md: 4, xl: 3 }) },
},
computed: {
cols_() {
return Object.assign({ md: 4, xl: 3 }, this.cols)
},
},
}
</script>
<style lang="scss" scoped>
.description-row {
@include media-breakpoint-up(md) {

View file

@ -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>
<span class="explain-what">
<slot name="default" />
@ -27,31 +46,6 @@
</span>
</template>
<script>
export default {
name: 'ExplainWhat',
props: {
id: { type: String, required: true },
title: { type: String, required: true },
content: { type: String, required: true },
variant: { type: String, default: 'info' },
},
computed: {
cols_() {
return Object.assign({ md: 4, xl: 3 }, this.cols)
},
},
data() {
return {
open: false,
}
},
}
</script>
<style lang="scss" scoped>
.explain-what {
line-height: 1.2;

View file

@ -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>
<!-- v-bind="$attrs" allow to pass default attrs not specified in this component slots -->
<BFormGroup
v-bind="attrs"
:id="_id"
:label-for="$attrs['label-for'] || props.id"
:id="id"
:label-for="attrs['label-for'] || props.id"
:state="state"
@touch="touch"
>
@ -11,10 +132,10 @@
<slot v-bind="{ self: { ...props, state }, touch }">
<!-- if no component was passed as slot, render a component from the props -->
<Component
v-bind="props"
v-bind="props.props"
:is="component"
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
@update:modelValue="emit('update:modelValue', $event)"
:state="state"
:required="validation ? 'required' in validation : false"
/>
@ -47,107 +168,6 @@
</BFormGroup>
</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>
:deep(.invalid-feedback code) {
background-color: $gray-200;

View file

@ -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>
<BRow no-gutters class="description-row">
<BCol v-bind="cols_" class="fw-bold">
<BCol v-bind="cols" class="fw-bold">
{{ label }}
</BCol>
@ -11,46 +61,6 @@
</BRow>
</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>
.description-row {
@include media-breakpoint-up(md) {

View file

@ -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>
<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" />
</div>
<div id="top-bar-right" class="top-bar-group" v-if="hasRightSlot || button">
<slot v-if="hasRightSlot" name="group-right" />
<div
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 }}
</BButton>
</div>
</BButtonToolbar>
</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>
#top-bar {
margin-bottom: 1rem;

View file

@ -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>
<div>
<TopBar v-if="hasTopBar">
<TopBar v-if="slots['top-bar-group-left'] || slots['top-bar-group-right']">
<template #group-left>
<slot name="top-bar-group-left" />
</template>
@ -28,58 +94,3 @@
<slot name="bot" v-bind="{ loading: isLoading }" />
</div>
</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>

View file

@ -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>
<ViewBase v-bind="$attrs" :skeleton="skeleton">
<template v-if="hasCustomTopBar" #top-bar>
<ViewBase :skeleton="skeleton">
<template v-if="slots['top-bar']" #top-bar>
<slot name="top-bar" />
</template>
<template v-if="!hasCustomTopBar" #top-bar-group-left>
<template v-if="!slots['top-bar']" #top-bar-group-left>
<BInputGroup class="w-100">
<BInputGroupText>
<YIcon iname="search" />
@ -12,7 +46,7 @@
<BFormInput
id="top-bar-search"
:modelValue="search"
@update:modelValue="$emit('update:search', $event)"
@update:modelValue="emit('update:search', $event)"
:placeholder="
$t('search.for', { items: $t('items.' + itemsName, 2) })
"
@ -20,7 +54,7 @@
/>
</BInputGroup>
</template>
<template v-if="!hasCustomTopBar" #top-bar-group-right>
<template v-if="!slots['top-bar']" #top-bar-group-right>
<slot name="top-bar-buttons" />
</template>
@ -60,23 +94,3 @@
</template>
</ViewBase>
</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>

View file

@ -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>
<Component
v-bind="$attrs"
@ -7,31 +30,10 @@
:class="{ ['alert alert-' + variant]: !alert }"
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">
<slot name="default" />
</div>
</Component>
</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>

View file

@ -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>
<BBreadcrumb v-if="breadcrumb.length">
<BBreadcrumbItem to="/">
@ -15,22 +28,3 @@
</BBreadcrumbItem>
</BBreadcrumb>
</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>

View file

@ -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>
<BCard v-bind="$attrs" :no-body="collapsable ? true : $attrs['no-body']">
<BCard :no-body="collapsable ? true : $attrs['no-body']">
<template #header>
<div class="w-100 d-flex align-items-center flex-wrap custom-header">
<slot name="header">
@ -10,7 +46,7 @@
</slot>
<div
v-if="hasButtons"
v-if="slots['header-buttons']"
class="mt-2 w-100 custom-header-buttons"
:class="{
[`ms-${buttonUnbreak}-auto mt-${buttonUnbreak}-0 w-${buttonUnbreak}-auto`]:
@ -48,40 +84,12 @@
<slot name="default" />
</template>
<template #footer v-if="'buttons' in $slots">
<template #footer v-if="slots['buttons']">
<slot name="buttons" />
</template>
</BCard>
</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>
:deep(.card-header) {
display: flex;

View file

@ -1,3 +1,12 @@
<script setup lang="ts">
import type { ColorVariant } from 'bootstrap-vue-next'
defineProps<{
iname: string
variant?: ColorVariant
}>()
</script>
<template>
<span
:class="['icon fa fa-' + iname, variant ? 'variant ' + variant : '']"
@ -5,16 +14,6 @@
/>
</template>
<script>
export default {
name: 'YIcon',
props: {
iname: { type: String, required: true },
variant: { type: String, default: null },
},
}
</script>
<style lang="scss" scoped>
.icon {
font-size: inherit;

View file

@ -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>
<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">
<YIcon v-if="_icon" :iname="_icon" :class="['icon-' + variant]" />
<YIcon v-if="icon" :iname="icon" :class="['icon-' + variant]" />
</div>
<div class="yuno-list-group-item-content">
@ -10,38 +49,6 @@
</BListGroupItem>
</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>
.yuno-list-group-item {
display: flex;

View file

@ -1,19 +1,13 @@
<script setup lang="ts">
import { useStoreGetters } from '@/store/utils'
const { spinner } = useStoreGetters()
</script>
<template>
<div :class="['custom-spinner', spinner]" />
</template>
<script>
import { mapGetters } from 'vuex'
export default {
name: 'YSpinner',
computed: {
...mapGetters(['spinner']),
},
}
</script>
<style lang="scss" scoped>
.custom-spinner {
animation: 8s linear infinite;

View file

@ -1,39 +1,46 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
label?: string
id?: string
type?: 'success' | 'info' | 'warning' | 'danger'
icon?: string
enabled?: string | boolean
}>(),
{
label: undefined,
id: undefined,
type: 'success',
icon: undefined,
enabled: true,
},
)
const emit = defineEmits(['action'])
const icon = computed(() => {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
return props.icon || icons[props.type]
})
</script>
<template>
<BButton
:id="id"
:variant="type"
@click="$emit('action', $event)"
@click="emit('action', $event)"
:disabled="!enabled"
class="d-block mb-3"
>
<YIcon :iname="icon_" class="me-2" />
<YIcon :iname="icon" class="me-2" />
<span v-html="label" />
</BButton>
</template>
<script>
export default {
name: 'ButtonItem',
props: {
label: { type: String, default: null },
id: { type: String, default: null },
type: { type: String, default: 'success' },
icon: { type: String, default: null },
enabled: { type: [Boolean, String], default: true },
},
computed: {
icon_() {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
return this.icon || icons[this.type]
},
},
}
</script>

View file

@ -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>
<BFormCheckbox
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
@update:modelValue="emit('update:modelValue', $event)"
:id="id"
:aria-describedby="$parent.id + '__BV_description_'"
switch
@ -9,16 +29,3 @@
{{ label || $t(labels[modelValue]) }}
</BFormCheckbox>
</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>

View file

@ -1,16 +1,18 @@
<script setup lang="ts">
withDefaults(
defineProps<{
id?: string
label?: string
}>(),
{
id: undefined,
label: undefined,
},
)
</script>
<template>
<div>
<p v-text="label" />
</div>
</template>
<script>
export default {
name: 'DisplayTextItem',
props: {
id: { type: String, default: null },
label: { type: String, default: null },
},
}
</script>

View file

@ -1,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>
<BInputGroup class="w-100">
<template #append>
<BButton
v-if="!this.required && this.modelValue.file !== null"
v-if="!required && modelValue.file !== null"
@click="clearFiles"
variant="danger"
>
@ -13,7 +89,7 @@
<BFormFile
:modelValue="modelValue.file"
ref="input-file"
ref="inputElem"
:id="id"
:required="required"
:placeholder="_placeholder"
@ -28,68 +104,6 @@
</BInputGroup>
</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>
// fix https://getbootstrap.com/docs/5.2/migration/#forms
:deep(.custom-file-label) {

View file

@ -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>
<BFormInput
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
@update:modelValue="emit('update:modelValue', $event)"
:id="id"
:placeholder="placeholder"
:type="type"
@ -11,47 +57,7 @@
:max="max"
:step="step"
:trim="trim"
:autocomplete="autocomplete_"
:autocomplete="autocomplete"
@blur="touch(name)"
/>
</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>

View file

@ -1,14 +1,10 @@
<script setup lang="ts">
defineProps<{
label: string
id?: string
}>()
</script>
<template>
<VueShowdown :markdown="label" />
</template>
<script>
export default {
name: 'MarkdownItem',
props: {
id: { type: String, default: null },
label: { type: String, default: null },
},
}
</script>

View file

@ -1,36 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label: string
id?: string
type?: 'success' | 'info' | 'warning' | 'danger'
icon?: string
}>()
const icon = computed(() => {
const icons = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
return props.icon || icons[props.type]
})
</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" />
<YIcon :iname="icon" class="me-md-3 mb-md-0 mb-2" :variant="type" />
<VueShowdown :markdown="label" tag="span" class="markdown" />
</BAlert>
</template>
<script>
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 = {
success: 'thumbs-up',
info: 'info',
warning: 'exclamation',
danger: 'times',
}
return this.icon || icons[this.type]
},
},
}
</script>

View file

@ -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>
<BFormSelect
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
@update:modelValue="emit('update:modelValue', $event)"
:id="id"
:options="choices"
:required="required"
@blur="touch(name)"
/>
</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>

View file

@ -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>
<BFormTags
:modelValue="modelValue"
@ -13,28 +46,3 @@
@blur="touch(name)"
/>
</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>

View file

@ -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>
<div class="tags-selectize">
<BFormTags
v-bind="$attrs"
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
@update:modelValue="emit('update:modelValue', $event)"
:id="id"
size="lg"
class="p-0 border-0"
@ -20,7 +121,7 @@
class="list-inline-item"
>
<BFormTag
@remove="onRemoveTag({ option: tag, removeTag })"
@remove="onRemoveTag(tag, removeTag)"
:title="tag"
:disabled="disabled || disabledItems.includes(tag)"
class="border border-dark mb-2"
@ -31,7 +132,7 @@
</ul>
<BDropdown
ref="dropdown"
ref="dropdownElem"
variant="outline-dark"
block
menu-class="w-100"
@ -62,7 +163,7 @@
class="mb-0"
>
<BFormInput
ref="search-input"
ref="searchElem"
v-model="search"
:id="id + '-search-input'"
type="search"
@ -77,7 +178,7 @@
<BDropdownItemButton
v-for="option in availableOptions"
:key="option"
@click="onAddTag({ option, addTag })"
@click="onAddTag(option, addTag)"
>
{{ option }}
</BDropdownItemButton>
@ -99,91 +200,6 @@
</div>
</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>
:deep(.dropdown-menu) {
max-height: 300px;

View file

@ -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>
<BFormTextarea
:modelValue="modelValue"
@update:modelValue="$emit('update:modelValue', $event)"
@update:modelValue="emit('update:modelValue', $event)"
:id="id"
:placeholder="placeholder"
:required="required"
@ -10,27 +41,3 @@
@blur="touch(name)"
/>
</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>

View file

@ -1,18 +1,14 @@
<script setup lang="ts">
defineProps<{
height: string
width: string
}>()
</script>
<template>
<div :style="{ height, width }" class="b-skeleton" />
</template>
<script>
export default {
name: 'BSkeleton',
props: {
height: { type: String, required: true },
width: { type: String, required: true },
},
}
</script>
<style scoped lang="scss">
.b-skeleton {
position: relative;

View file

@ -1,3 +1,7 @@
<script setup lang="ts">
withDefaults(defineProps<{ loading?: boolean }>(), { loading: false })
</script>
<template>
<div v-if="loading" class="b-skeleton-wrapper">
<slot name="loading" />
@ -5,16 +9,6 @@
<slot v-else name="default" />
</template>
<script>
export default {
name: 'BSkeletonWrapper',
props: {
loading: { type: Boolean, default: false },
},
}
</script>
<style scoped lang="scss">
.b-skeleton-wrapper {
cursor: wait;

View file

@ -1,3 +1,9 @@
<script setup lang="ts">
import { randint } from '@/helpers/commons'
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 4 })
</script>
<template>
<BCard>
<template #header>
@ -16,17 +22,3 @@
</div>
</BCard>
</template>
<script>
import { randint } from '@/helpers/commons'
export default {
name: 'CardButtonsSkeleton',
props: {
itemCount: { type: Number, default: 5 },
},
methods: { randint },
}
</script>

View file

@ -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>
<BCard>
<template #header>
@ -42,23 +58,3 @@
</template>
</BCard>
</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>

View file

@ -1,3 +1,9 @@
<script setup lang="ts">
import { randint } from '@/helpers/commons'
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
</script>
<template>
<BCard>
<template #header>
@ -14,17 +20,3 @@
</BRow>
</BCard>
</template>
<script>
import { randint } from '@/helpers/commons'
export default {
name: 'CardInfoSkeleton',
props: {
itemCount: { type: Number, default: 5 },
},
methods: { randint },
}
</script>

View file

@ -1,3 +1,9 @@
<script setup lang="ts">
import { randint } from '@/helpers/commons'
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
</script>
<template>
<BCard no-body>
<template #header>
@ -18,17 +24,3 @@
</BListGroup>
</BCard>
</template>
<script>
import { randint } from '@/helpers/commons'
export default {
name: 'CardListSkeleton',
props: {
itemCount: { type: Number, default: 5 },
},
methods: { randint },
}
</script>

View file

@ -1,3 +1,9 @@
<script setup lang="ts">
import { randint } from '@/helpers/commons'
withDefaults(defineProps<{ itemCount: number }>(), { itemCount: 5 })
</script>
<template>
<BListGroup>
<BListGroupItem v-for="count in itemCount" :key="count">
@ -6,17 +12,3 @@
</BListGroupItem>
</BListGroup>
</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
View 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
View 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
}

View file

@ -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>
<div class="home">
<BListGroup class="menu-list">
@ -14,30 +30,6 @@
</div>
</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>
.home {
margin-top: 2rem;

View file

@ -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>
<CardForm
:title="$t('login')"
:title="t('login')"
icon="lock"
:validation="v$"
:server-error="serverError"
@ -10,14 +89,14 @@
<FormField
v-bind="fields.username"
v-model="form.username"
:validation="v$.form.username"
:validation="v$.username"
/>
<!-- ADMIN PASSWORD -->
<FormField
v-bind="fields.password"
v-model="form.password"
:validation="v$.form.password"
:validation="v$.password"
/>
<template #buttons>
@ -27,91 +106,8 @@
:disabled="!installed"
form="ynh-form"
>
{{ $t('login') }}
{{ t('login') }}
</BButton>
</template>
</CardForm>
</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>

View file

@ -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>
<div class="post-install">
<!-- START STEP -->
@ -51,11 +181,11 @@
/>
<FormField
v-for="(field, name) in fields"
:key="name"
v-for="(field, key) in fields"
:key="key"
v-bind="field"
v-model="user[name]"
:validation="v$.user[name]"
v-model="form[key]"
:validation="v$.form[key]"
/>
</CardForm>
@ -87,152 +217,3 @@
</template>
</div>
</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>

View file

@ -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>
<CardForm
:title="title"
@ -32,7 +177,7 @@
<FormField
v-bind="fields.domain"
v-model="form.domain"
:validation="v$.form.domain"
:validation="v$.domain"
class="mt-3"
/>
</BCollapse>
@ -58,7 +203,7 @@
<FormField
v-bind="fields.dynDomain"
:validation="v$.form.dynDomain"
:validation="v$.dynDomain"
class="mt-3"
>
<template #default="{ self }">
@ -68,13 +213,13 @@
<FormField
v-bind="fields.dynDomainPassword"
:validation="v$.form.dynDomainPassword"
:validation="v$.dynDomainPassword"
v-model="form.dynDomainPassword"
/>
<FormField
v-bind="fields.dynDomainPasswordConfirmation"
:validation="v$.form.dynDomainPasswordConfirmation"
:validation="v$.dynDomainPasswordConfirmation"
v-model="form.dynDomainPasswordConfirmation"
/>
</BCollapse>
@ -103,7 +248,7 @@
<FormField
v-bind="fields.localDomain"
:validation="v$.form.localDomain"
:validation="v$.localDomain"
class="mt-3"
>
<template #default="{ self }">
@ -113,165 +258,3 @@
</BCollapse>
</CardForm>
</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>

View file

@ -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>
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
<div>
@ -23,7 +54,7 @@
<p>
<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" />
</BAlert>
</p>
@ -50,40 +81,6 @@
</div>
</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>
code,
pre code {

View file

@ -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>
<BCard no-body id="console">
<BCard id="console" ref="rootElem" no-body>
<!-- HISTORY BAR -->
<BCardHeader
role="button"
@ -40,7 +157,7 @@
</BCardHeader>
<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">
{{ $t('history.is_empty') }}
</p>
@ -62,7 +179,7 @@
<QueryHeader
role="tab"
v-b-toggle="
action.messages.length ? 'messages-collapse-' + i : false
action.messages.length ? 'messages-collapse-' + i : undefined
"
:request="action"
show-time
@ -87,134 +204,6 @@
</BCard>
</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>
// reset default style
.card + .card {

View file

@ -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>
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
<BCardBody>
@ -39,52 +72,3 @@
</template>
</BCardBody>
</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>

View file

@ -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>
<BOverlay
:variant="dark ? 'dark' : 'light'"
@ -13,59 +41,12 @@
<QueryHeader :request="error || currentRequest" status-size="lg" />
</BCardHeader>
<Component :is="component.name" :request="component.request" />
<Component :is="component.is" :request="component.request" />
</BCard>
</template>
</BOverlay>
</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 for `*Display`'s cards
.card-overlay {

View file

@ -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>
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
<BCardBody>
@ -25,34 +49,3 @@
/>
</BCardBody>
</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>

View file

@ -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>
<!-- This card receives style from `ViewLockOverlay` if used inside it -->
<div>
@ -11,29 +32,6 @@
</div>
</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>
.card-body {
padding-bottom: 1.5rem !important;

View file

@ -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>
<ViewSearch
:items="apps"
@ -224,219 +404,6 @@
</ViewSearch>
</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>
#view-top-bar {
margin-bottom: 2rem;

View file

@ -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>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
:loading="loading"
ref="view"
ref="viewElem"
>
<YAlert
v-if="
@ -357,350 +686,6 @@
</ViewBase>
</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>
select {
border-top-right-radius: 0;

View file

@ -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>
<ViewBase :queries="queries" @queries-response="onQueriesResponse">
<template v-if="app">
@ -205,231 +417,6 @@
</ViewBase>
</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>
.antifeatures {
dt::before {

View file

@ -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>
<ViewSearch
v-model:search="search"
@ -36,45 +71,3 @@
</BListGroup>
</ViewSearch>
</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>

View file

@ -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>
<ViewBase
:queries="queries"
@ -122,91 +202,3 @@
</YCard>
</ViewBase>
</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>

View file

@ -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>
<ViewBase :queries="queries" @queries-response="onQueriesResponse">
<!-- BACKUP INFO -->
@ -106,7 +239,7 @@
</BListGroup>
<BFormInvalidFeedback id="backup-restore-feedback" :state="isValid">
<BAlert variant="danger" class="mb-0">
<BAlert :modelValue="true" variant="danger" class="mb-0">
{{ error }}
</BAlert>
</BFormInvalidFeedback>
@ -134,150 +267,3 @@
</template>
</ViewBase>
</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>

View file

@ -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>
<ViewBase
:queries="queries"
@ -43,43 +72,3 @@
</BListGroup>
</ViewBase>
</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>

View file

@ -2,40 +2,18 @@
<div>
<BListGroup>
<BListGroupItem
v-for="{ id, name, uri } in storages"
:key="id"
:to="{ name: 'backup-list', params: { id } }"
:to="{ name: 'backup-list', params: { id: 'local' } }"
class="d-flex justify-content-between align-items-center pe-0"
>
<div>
<h5 class="fw-bold">
{{ name }}
<small class="text-secondary">{{ id }}</small>
{{ $t('local_archives') }}
<small class="text-secondary">local</small>
</h5>
<p class="m-0">
{{ uri }}
</p>
<p class="m-0">/home/yunohost.backup/</p>
</div>
<YIcon iname="chevron-right" class="lg fs-sm ms-auto" />
</BListGroupItem>
</BListGroup>
</div>
</template>
<script>
export default {
name: 'BackupView',
data() {
return {
storages: [
{
id: 'local',
name: this.$t('local_archives'),
uri: '/home/yunohost.backup/',
},
],
}
},
}
</script>

View file

@ -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>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
queries-wait
ref="view"
ref="viewElem"
>
<template #top-bar-group-right>
<BButton @click="shareLogs" variant="success">
@ -145,116 +245,6 @@
</ViewBase>
</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>
.badge + .badge {
margin-left: 0.5rem;

View file

@ -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>
<ViewBase :queries="queries" skeleton="CardFormSkeleton">
<DomainForm
@ -8,36 +37,3 @@
/>
</ViewBase>
</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>

View file

@ -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>
<ViewBase
:queries="queries"
@ -143,152 +273,6 @@
</ViewBase>
</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>
.records {
white-space: pre;

View file

@ -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>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
ref="view"
ref="viewElem"
skeleton="CardListSkeleton"
>
<!-- INFO CARD -->
@ -122,7 +273,7 @@
<BModal
v-if="domain"
id="delete-modal"
:title="$t('confirm_delete', { name: this.name })"
:title="$t('confirm_delete', { name: props.name })"
@ok="deleteDomain"
header-bg-variant="warning"
header-class="text-black"
@ -137,175 +288,6 @@
</ViewBase>
</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>
.main-domain-badge {
font-size: 0.75rem;

View file

@ -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>
<ViewSearch
id="domain-list"
@ -19,14 +46,17 @@
:toggle-text="$t('domain.toggle_subdomains')"
class="mb-5"
>
<!-- FIXME slot typing not appearing? -->
<template #default="{ data, parent }">
<div class="w-100 d-flex justify-content-between align-items-center">
<h5 class="me-3">
<BLink :to="data.to" class="text-body text-decoration-none">
<span class="fw-bold">
{{ data.name.replace(parent ? parent.data.name : null, '') }}
{{
data.name.replace(parent?.data ? parent.data.name : null, '')
}}
</span>
<span v-if="parent" class="text-secondary">
<span v-if="parent?.data" class="text-secondary">
{{ parent.data.name }}
</span>
</BLink>
@ -45,44 +75,3 @@
</RecursiveListGroup>
</ViewSearch>
</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>

View file

@ -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>
<CardForm
:title="$t('group_new')"
@ -8,66 +48,9 @@
>
<!-- GROUP NAME -->
<FormField
v-bind="groupname"
v-bind="groupnameField"
v-model="form.groupname"
:validation="v$.form.groupname"
/>
</CardForm>
</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>

View file

@ -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>
<ViewSearch
items-name="groups"
@ -42,7 +224,6 @@
<BCol md="3" lg="2">
<strong>{{ $t('users') }}</strong>
</BCol>
<BCol>
<template v-if="group.isSpecial">
<p class="text-primary-emphasis">
@ -132,210 +313,6 @@
</ViewSearch>
</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>
.row > div:first-child {
margin-bottom: 1rem;

View file

@ -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>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
ref="view"
ref="viewElem"
skeleton="CardInfoSkeleton"
>
<!-- INFO CARD -->
@ -81,104 +167,6 @@
</ViewBase>
</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>
h3 {
margin-bottom: 1rem;

View file

@ -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>
<ViewSearch
id="service-list"
@ -42,49 +72,6 @@
</ViewSearch>
</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>
@include media-breakpoint-down(lg) {
h5 small {

View file

@ -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>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
ref="view"
ref="viewElem"
skeleton="CardFormSkeleton"
>
<!-- PORTS -->
@ -115,178 +275,6 @@
</ViewBase>
</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>
:deep() {
.form-switch {

View file

@ -1,3 +1,31 @@
<script setup lang="ts">
const menu = [
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
{
routeName: 'tool-migrations',
icon: 'share',
translation: 'migrations',
},
{ routeName: 'service-list', icon: 'gears', translation: 'services' },
{ routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
{
routeName: 'tool-settings',
icon: 'sliders',
translation: 'tools_yunohost_settings',
},
{
routeName: 'tool-webadmin',
icon: 'sliders',
translation: 'tools_webadmin_settings',
},
{
routeName: 'tool-power',
icon: 'power-off',
translation: 'tools_shutdown_reboot',
},
]
</script>
<!-- FIXME make a component shared with HomeView.vue ? -->
<template>
<BListGroup class="menu-list">
@ -12,39 +40,3 @@
</BListGroupItem>
</BListGroup>
</template>
<script>
export default {
name: 'ToolList',
data() {
return {
menu: [
{ routeName: 'tool-logs', icon: 'book', translation: 'logs' },
{
routeName: 'tool-migrations',
icon: 'share',
translation: 'migrations',
},
{ routeName: 'service-list', icon: 'gears', translation: 'services' },
{ routeName: 'tool-firewall', icon: 'shield', translation: 'firewall' },
{
routeName: 'tool-settings',
icon: 'sliders',
translation: 'tools_yunohost_settings',
},
{
routeName: 'tool-webadmin',
icon: 'sliders',
translation: 'tools_webadmin_settings',
},
{
routeName: 'tool-power',
icon: 'power-off',
translation: 'tools_shutdown_reboot',
},
],
}
},
}
</script>

View file

@ -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>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
ref="view"
ref="viewElem"
skeleton="CardInfoSkeleton"
>
<!-- INFO CARD -->
@ -57,7 +135,7 @@
v-if="moreLogsAvailable"
variant="white"
class="w-100 rounded-0"
@click="$refs.view.fetchQueries()"
@click="viewElem!.fetchQueries()"
>
<YIcon iname="plus" /> {{ $t('logs_more') }}
</BButton>
@ -71,92 +149,3 @@
<p class="w-100 px-5 py-2 mb-0" v-html="$t('text_selection_is_disabled')" />
</ViewBase>
</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>

View file

@ -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>
<ViewSearch
v-model:search="search"
@ -24,51 +59,3 @@
</YCard>
</ViewSearch>
</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>

View file

@ -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>
<ViewBase :queries="queries" @queries-response="onQueriesResponse" ref="view">
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
ref="viewElem"
>
<!-- PENDING MIGRATIONS -->
<YCard :title="$t('migrations_pending')" icon="cogs" no-body>
<template #header-buttons v-if="pending">
@ -85,74 +147,3 @@
</template>
</ViewBase>
</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>

View file

@ -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>
<YCard :title="$t('operations')" icon="wrench">
<!-- REBOOT -->
@ -32,37 +58,3 @@
</BFormGroup>
</YCard>
</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>

View file

@ -1,8 +1,59 @@
<script setup lang="ts">
import { reactive, ref } from 'vue'
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 {
formatFormData,
formatYunoHostConfigPanels,
} from '@/helpers/yunohostArguments'
const viewElem = ref<InstanceType<typeof ViewBase> | null>(null)
const queries = [['GET', 'settings?full']]
const config = ref({})
// FIXME user proper useValidate stuff
const externalResults = reactive({})
function onQueriesResponse(config_) {
config.value = formatYunoHostConfigPanels(config_)
}
async function onConfigSubmit({ id, form }) {
const args = await formatFormData(form, {
removeEmpty: false,
removeNull: true,
})
// FIXME no route for potential action
api
.put(
`settings/${id}`,
{ args: objectToParams(args) },
{ key: 'settings.update', panel: id },
)
.then(() => viewElem.value!.fetchQueries({ triggerLoading: true }))
.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
}
})
}
</script>
<template>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
ref="view"
ref="viewElem"
skeleton="CardFormSkeleton"
>
<ConfigPanels
@ -13,65 +64,3 @@
/>
</ViewBase>
</template>
<script>
import api, { objectToParams } from '@/api'
import {
formatFormData,
formatYunoHostConfigPanels,
} from '@/helpers/yunohostArguments'
import ConfigPanels from '@/components/ConfigPanels.vue'
export default {
name: 'ToolSettingsConfig',
components: {
ConfigPanels,
},
props: {},
data() {
return {
queries: [['GET', 'settings?full']],
config: {},
externalResults: {},
}
},
methods: {
onQueriesResponse(config) {
this.config = formatYunoHostConfigPanels(config)
},
async onConfigSubmit({ id, form }) {
const args = await formatFormData(form, {
removeEmpty: false,
removeNull: true,
})
// FIXME no route for potential action
api
.put(
`settings/${id}`,
{ args: objectToParams(args) },
{ key: 'settings.update', panel: id },
)
.then(() => {
this.$refs.view.fetchQueries({ triggerLoading: true })
})
.catch((err) => {
if (err.name !== 'APIBadRequestError') throw err
const panel = this.config.panels.find((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
}
})
},
},
}
</script>

View file

@ -1,99 +1,93 @@
<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="self[fname]" />
<hr />
</template>
</CardForm>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import { useStore } from 'vuex'
const { t } = useI18n()
const store = useStore()
const fields = {
locale: {
label: t('tools_webadmin.language'),
component: 'SelectItem',
props: { id: 'locale', choices: [] },
},
fallbackLocale: {
label: t('tools_webadmin.fallback_language'),
description: t('tools_webadmin.fallback_language_description'),
component: 'SelectItem',
props: { id: 'fallback-locale', choices: [] },
},
cache: {
id: 'cache',
label: t('tools_webadmin.cache'),
description: t('tools_webadmin.cache_description'),
component: 'CheckboxItem',
props: { labels: { true: 'enabled', false: 'disabled' } },
},
transitions: {
id: 'transitions',
label: t('tools_webadmin.transitions'),
component: 'CheckboxItem',
props: { labels: { true: 'enabled', false: 'disabled' } },
},
dark: {
id: 'theme',
label: t('tools_webadmin.theme'),
component: 'CheckboxItem',
props: { labels: { true: '🌙', false: '☀️' } },
},
// experimental: added in `created()`
}
const form = {
...mapStoreGetSet(['locale', 'fallbackLocale', 'dark'], 'dispatch'),
...mapStoreGetSet(['cache', 'transitions', 'experimental']),
}
const availableLocales = store.getters.availableLocales
fields.locale.props.choices = availableLocales
fields.fallbackLocale.props.choices = availableLocales
if (import.meta.env.DEV) {
fields.experimental = {
id: 'experimental',
label: t('tools_webadmin.experimental'),
description: t('tools_webadmin.experimental_description'),
component: 'CheckboxItem',
props: { labels: { true: 'enabled', false: 'disabled' } },
}
}
<script>
// 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] = {
obj[prop] = computed({
get() {
return this.$store.getters[prop]
return store.getters[prop]
},
set(value) {
const key =
(action === 'commit' ? 'SET_' : 'UPDATE_') + prop.toUpperCase()
this.$store[action](key, value)
store[action](key, value)
},
}
})
return obj
}, {})
}
export default {
name: 'ToolWebadmin',
data() {
return {
// Hacky way to be able to dynamicly point to a computed property `self['computedProp']`
self: this,
fields: {
locale: {
label: this.$t('tools_webadmin.language'),
component: 'SelectItem',
props: { id: 'locale', choices: [] },
},
fallbackLocale: {
label: this.$t('tools_webadmin.fallback_language'),
description: this.$t('tools_webadmin.fallback_language_description'),
component: 'SelectItem',
props: { id: 'fallback-locale', choices: [] },
},
cache: {
id: 'cache',
label: this.$t('tools_webadmin.cache'),
description: this.$t('tools_webadmin.cache_description'),
component: 'CheckboxItem',
props: { labels: { true: 'enabled', false: 'disabled' } },
},
transitions: {
id: 'transitions',
label: this.$t('tools_webadmin.transitions'),
component: 'CheckboxItem',
props: { labels: { true: 'enabled', false: 'disabled' } },
},
dark: {
id: 'theme',
label: this.$t('tools_webadmin.theme'),
component: 'CheckboxItem',
props: { labels: { true: '🌙', false: '☀️' } },
},
// experimental: added in `created()`
},
}
},
computed: {
// Those are set/get computed properties
...mapStoreGetSet(['locale', 'fallbackLocale', 'dark'], 'dispatch'),
...mapStoreGetSet(['cache', 'transitions', 'experimental']),
},
created() {
const availableLocales = this.$store.getters.availableLocales
this.fields.locale.props.choices = availableLocales
this.fields.fallbackLocale.props.choices = availableLocales
if (import.meta.env.DEV) {
this.fields.experimental = {
id: 'experimental',
label: this.$t('tools_webadmin.experimental'),
description: this.$t('tools_webadmin.experimental_description'),
component: 'CheckboxItem',
props: { labels: { true: 'enabled', false: 'disabled' } },
}
}
},
}
</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>

View file

@ -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>
<ViewBase
:queries="queries"
@ -138,145 +252,6 @@
</ViewBase>
</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">
.card-collapse-wrapper {
border: $card-border-width solid $card-border-color;

View file

@ -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>
<ViewBase
:queries="queries"
@ -61,135 +176,3 @@
</CardForm>
</ViewBase>
</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>

View file

@ -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>
<ViewBase
:queries="queries"
@queries-response="onQueriesResponse"
skeleton="CardFormSkeleton"
ref="viewElem"
>
<CardForm
:title="$t('user_username_edit', { name })"
@ -108,254 +337,6 @@
</ViewBase>
</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>
.mail-list {
display: flex;

View file

@ -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>
<CardForm
:title="$t('users_import')"
@ -20,105 +106,3 @@
<FormField v-bind="fields.delete" v-model="form.delete" />
</CardForm>
</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>

View file

@ -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>
<ViewBase :queries="queries" skeleton="CardInfoSkeleton">
<YCard v-if="user" :title="user.fullname" icon="user">
@ -101,48 +134,6 @@
</ViewBase>
</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>
.icon.fa-user {
font-size: 10rem;

View file

@ -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>
<ViewSearch
v-model:search="search"
@ -51,47 +90,3 @@
</BListGroup>
</ViewSearch>
</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>