refactor: turn infos store to global state

This commit is contained in:
axolotle 2024-08-05 21:39:00 +02:00
parent deae1324e1
commit 60b5134dcf
13 changed files with 227 additions and 264 deletions

View file

@ -1,23 +1,16 @@
<script setup lang="ts">
import { onMounted } from 'vue'
import { useStore } from 'vuex'
import { useInfos } from '@/composables/useInfos'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import { useStoreGetters } from '@/store/utils'
import { HistoryConsole } from '@/views/_partials'
const store = useStore()
const { connected, yunohost, ssoLink } = useStoreGetters()
const { ssoLink, connected, yunohost, logout, onAppCreated } = useInfos()
const { locked } = useRequests()
const { spinner, dark } = useSettings()
async function logout() {
store.dispatch('LOGOUT')
}
store.dispatch('ON_APP_CREATED')
onAppCreated()
onMounted(() => {
const copypastaCode = ['ArrowDown', 'ArrowDown', 'ArrowUp', 'ArrowUp']

View file

@ -1,3 +1,4 @@
import { useInfos } from '@/composables/useInfos'
import {
useRequests,
type APIRequestAction,
@ -242,10 +243,10 @@ export default {
delay = 2000,
initialDelay = 0,
}: ReconnectingArgs = {}) {
const { getYunoHostVersion } = useInfos()
return new Promise((resolve, reject) => {
function reconnect(n: number) {
store
.dispatch('GET_YUNOHOST_INFOS')
getYunoHostVersion()
.then(resolve)
.catch((err: APIError) => {
if (err instanceof APIUnauthorizedError) {

View file

@ -4,6 +4,7 @@
*/
import errors from '@/api/errors'
import { useInfos } from '@/composables/useInfos'
import {
STATUS_VARIANT,
type APIRequest,
@ -39,10 +40,9 @@ export async function getResponseData(response: Response) {
* @returns Promise that resolve on websocket 'open' or 'error' event.
*/
export function openWebSocket(request: APIRequestAction): Promise<Event> {
const { host } = useInfos()
return new Promise((resolve) => {
const ws = new WebSocket(
`wss://${store.getters.host}/yunohost/api/messages`,
)
const ws = new WebSocket(`wss://${host.value}/yunohost/api/messages`)
ws.onmessage = ({ data }) => {
const messages: Record<'info' | 'success' | 'warning' | 'error', string> =
JSON.parse(data)

View file

@ -1,16 +1,9 @@
<script setup lang="ts">
import { useStoreGetters } from '@/store/utils'
import { useInfos } from '@/composables/useInfos'
const { breadcrumb } = useStoreGetters()
const { breadcrumb } = useInfos()
</script>
<style lang="scss" scoped>
.breadcrumb {
border: none;
background-color: transparent;
}
</style>
<template>
<BBreadcrumb v-if="breadcrumb.length">
<BBreadcrumbItem to="/">
@ -19,12 +12,19 @@ const { breadcrumb } = useStoreGetters()
</BBreadcrumbItem>
<BBreadcrumbItem
v-for="({ name, text }, i) in breadcrumb"
:key="name"
:to="{ name }"
v-for="({ to, text }, i) in breadcrumb"
:key="i"
:to="to"
:active="i === breadcrumb.length - 1"
>
{{ text }}
</BBreadcrumbItem>
</BBreadcrumb>
</template>
<style lang="scss" scoped>
.breadcrumb {
border: none;
background-color: transparent;
}
</style>

View file

@ -10,13 +10,13 @@ import {
ModalWaiting,
ModalWarning,
} from '@/components/modals'
import { useInfos } from '@/composables/useInfos'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import { useStoreGetters } from '@/store/utils'
import type { VueClass } from '@/types/commons'
const router = useRouter()
const { routerKey } = useStoreGetters()
const { routerKey } = useInfos()
const { reconnecting, currentRequest, dismissModal } = useRequests()
const { transitions, transitionName, dark } = useSettings()

View file

@ -1,212 +1,189 @@
import { createGlobalState, useLocalStorage } from '@vueuse/core'
import { computed, ref } from 'vue'
import type {
RouteLocationNormalized,
RouteLocationNormalizedLoaded,
RouteRecordNormalized,
} from 'vue-router'
import { useRouter } from 'vue-router'
import api from '@/api'
import { useRequests, type ReconnectingArgs } from '@/composables/useRequests'
import { isEmptyValue, timeout } from '@/helpers/commons'
import i18n from '@/i18n'
import router from '@/router'
import { useStoreGetters } from '@/store/utils'
import type { CustomRoute, RouteFromTo } from '@/types/commons'
import { useRequests, type ReconnectingArgs } from './useRequests'
export default {
state: {
host: window.location.host, // String
installed: null,
connected: localStorage.getItem('connected') === 'true', // Boolean
yunohost: null, // Object { version, repo }
routerKey: undefined, // String if current route has params
breadcrumb: [], // Array of routes
transitionName: null, // String of CSS class if transitions are enabled
},
export const useInfos = createGlobalState(() => {
const router = useRouter()
mutations: {
SET_INSTALLED(state, boolean) {
state.installed = boolean
},
const host = ref(window.location.host)
const installed = ref<boolean | undefined>()
const connected = useLocalStorage('connected', false)
const yunohost = ref<{ version: string; repo: string } | undefined>()
const routerKey = ref<string | undefined>()
const breadcrumb = ref<CustomRoute[]>([])
SET_CONNECTED(state, boolean) {
localStorage.setItem('connected', boolean)
state.connected = boolean
},
const { mainDomain } = useStoreGetters()
const ssoLink = computed(() => {
return `//${mainDomain.value ?? host.value}/yunohost/sso`
})
SET_YUNOHOST_INFOS(state, yunohost) {
state.yunohost = yunohost
},
// INIT
SET_ROUTER_KEY(state, key) {
state.routerKey = key
},
SET_BREADCRUMB(state, breadcrumb) {
state.breadcrumb = breadcrumb
},
SET_TRANSITION_NAME(state, transitionName) {
state.transitionName = transitionName
},
},
actions: {
async ON_APP_CREATED({ dispatch, state }) {
await dispatch('CHECK_INSTALL')
if (!state.installed) {
router.push({ name: 'post-install' })
} else {
dispatch('CONNECT')
}
},
async CHECK_INSTALL({ dispatch, commit }, retry = 2) {
// this action will try to query the `/installed` route 3 times every 5 s with
// a timeout of the same delay.
// FIXME need testing with api not responding
try {
const { installed } = await timeout(api.get('installed'), 5000)
commit('SET_INSTALLED', installed)
return installed
} catch (err) {
if (retry > 0) {
return dispatch('CHECK_INSTALL', --retry)
}
throw err
}
},
async CONNECT({ commit, dispatch }) {
// If the user is not connected, the first action will throw
// and login prompt will be shown automaticly
await dispatch('GET_YUNOHOST_INFOS')
commit('SET_CONNECTED', true)
await api.get({ uri: 'domains', storeKey: 'domains' })
},
RESET_CONNECTED({ commit }) {
commit('SET_CONNECTED', false)
commit('SET_YUNOHOST_INFOS', null)
},
DISCONNECT({ dispatch }, route) {
// FIXME vue3 currentRoute is now a ref (currentRoute.value)
dispatch('RESET_CONNECTED')
if (router.currentRoute.value.name === 'login') return
const previousRoute = route ?? router.currentRoute.value
router.push({
name: 'login',
// Add a redirect query if next route is not unknown (like `logout`) or `login`
query:
previousRoute && !['login', null].includes(previousRoute.name)
? { redirect: previousRoute.path }
: {},
})
},
LOGIN({ dispatch }, credentials) {
return api
.post('login', { credentials }, null, { websocket: false })
.then(() => {
return dispatch('CONNECT')
})
},
LOGOUT({ dispatch }) {
dispatch('DISCONNECT')
return api.get('logout')
},
TRY_TO_RECONNECT({ commit }, args?: ReconnectingArgs) {
// FIXME This is very ugly arguments forwarding, will use proper component way of doing this when switching to Vue 3 (teleport)
useRequests().reconnecting.value = args
},
GET_YUNOHOST_INFOS({ commit }) {
return api.get('versions').then((versions) => {
commit('SET_YUNOHOST_INFOS', versions.yunohost)
})
},
UPDATE_ROUTER_KEY({ commit }, { to, from }) {
if (isEmptyValue(to.params)) {
commit('SET_ROUTER_KEY', undefined)
return
}
// If the next route uses the same component as the previous one, Vue will not
// recreate an instance of that component, so hooks like `created()` will not be
// triggered and data will not be fetched.
// For routes with params, we create a unique key to force the recreation of a view.
// Params can be declared in route `meta` to stricly define which params should be
// taken into account.
const params = to.meta.routerParams
? to.meta.routerParams.map((key) => to.params[key])
: Object.values(to.params)
commit('SET_ROUTER_KEY', `${to.name}-${params.join('-')}`)
},
UPDATE_BREADCRUMB({ commit }, { to, from }) {
function getRouteNames(route) {
if (route.meta.breadcrumb) return route.meta.breadcrumb
const parentRoute = route.matched
.slice()
.reverse()
.find((route) => route.meta.breadcrumb)
if (parentRoute) return parentRoute.meta.breadcrumb
return []
}
function formatRoute(route) {
const { trad, param } = route.meta.args || {}
let text = ''
// if a traduction key string has been given and we also need to pass
// the route param as a variable.
if (trad && param) {
text = i18n.global.t(trad, { [param]: to.params[param] })
} else if (trad) {
text = i18n.global.t(trad)
} else {
text = to.params[param]
}
return { name: route.name, text }
}
const routeNames = getRouteNames(to)
const allRoutes = router.getRoutes()
const breadcrumb = routeNames.map((name) => {
const route = allRoutes.find((route) => route.name === name)
return formatRoute(route)
})
commit('SET_BREADCRUMB', breadcrumb)
function getTitle(breadcrumb) {
if (breadcrumb.length === 0) return formatRoute(to).text
return (breadcrumb.length > 2 ? breadcrumb.slice(-2) : breadcrumb)
.map((route) => route.text)
.reverse()
.join(' / ')
}
// Display a simplified breadcrumb as the document title.
document.title = `${getTitle(breadcrumb)} | ${i18n.global.t('yunohost_admin')}`
},
UPDATE_TRANSITION_NAME({ state, commit }, { to, from }) {
// Use the breadcrumb array length as a direction indicator
const toDepth = (to.meta.breadcrumb || []).length
const fromDepth = (from.meta.breadcrumb || []).length
commit(
'SET_TRANSITION_NAME',
toDepth < fromDepth ? 'slide-right' : 'slide-left',
async function _checkInstall(retry = 2) {
// this action will try to query the `/installed` route 3 times every 5 s with
// a timeout of the same delay.
// FIXME need testing with api not responding
try {
const data = await timeout(
api.get<{ installed: boolean }>('installed'),
5000,
)
},
},
installed.value = data.installed
} catch (err) {
if (retry > 0) {
return _checkInstall(--retry)
}
throw err
}
}
getters: {
host: (state) => state.host,
installed: (state) => state.installed,
connected: (state) => state.connected,
yunohost: (state) => state.yunohost,
routerKey: (state) => state.routerKey,
breadcrumb: (state) => state.breadcrumb,
transitionName: (state) => state.transitionName,
ssoLink: (state, getters) => {
return `//${getters.mainDomain ?? state.host}/yunohost/sso`
},
},
}
async function onAppCreated() {
await _checkInstall()
if (!installed.value) {
router.push({ name: 'post-install' })
} else {
_onLogin()
}
}
function getYunoHostVersion() {
return api.get('versions').then((versions) => {
yunohost.value = versions.yunohost
})
}
// CONNECTION
async function _onLogin() {
// If the user is not connected, the first action will throw
// and login prompt will be shown automaticly
await getYunoHostVersion()
connected.value = true
await api.get({ uri: 'domains', cachePath: 'domainList' })
}
function onLogout(route?: RouteLocationNormalizedLoaded) {
connected.value = false
yunohost.value = undefined
const previousRoute = route ?? router.currentRoute.value
if (previousRoute.name === 'login') return
router.push({
name: 'login',
// Add a redirect query if next route is not unknown (like `logout`) or `login`
query:
previousRoute && !['login', null].includes(previousRoute.name as any)
? { redirect: previousRoute.path }
: {},
})
}
function login(credentials: string) {
return api
.post({ uri: 'login', data: { credentials }, websocket: false })
.then(() => _onLogin())
}
function logout() {
onLogout()
return api.get('logout')
}
function tryToReconnect(args?: ReconnectingArgs) {
useRequests().reconnecting.value = args
}
function updateRouterKey({ to }: RouteFromTo) {
if (isEmptyValue(to.params)) {
routerKey.value = undefined
return
}
// If the next route uses the same component as the previous one, Vue will not
// recreate an instance of that component, so hooks like `created()` will not be
// triggered and data will not be fetched.
// For routes with params, we create a unique key to force the recreation of a view.
// Params can be declared in route `meta` to stricly define which params should be
// taken into account.
const params = to.meta.routerParams
? to.meta.routerParams.map((key) => to.params[key])
: Object.values(to.params)
routerKey.value = `${to.name?.toString()}-${params.join('-')}`
}
function updateBreadcrumb({ to }: RouteFromTo) {
function getRouteNames(route: RouteLocationNormalized): string[] {
if (route.meta.breadcrumb) return route.meta.breadcrumb
const parentRoute = route.matched
.slice()
.reverse()
.find((route) => route.meta.breadcrumb)
return parentRoute?.meta.breadcrumb || []
}
function formatRoute(
route: RouteRecordNormalized | RouteLocationNormalized,
) {
const { trad, param } = route.meta.args || {}
let text = ''
// if a traduction key string has been given and we also need to pass
// the route param as a variable.
if (trad && param) {
text = i18n.global.t(trad, { [param]: to.params[param] })
} else if (trad) {
text = i18n.global.t(trad)
} else if (param) {
text = to.params[param] as string
}
return { to: { name: route.name! }, text }
}
const routeNames = getRouteNames(to)
const allRoutes = router.getRoutes()
breadcrumb.value = routeNames.map((name) => {
const route = allRoutes.find((route) => route.name === name)!
return formatRoute(route)
})
function getTitle(breadcrumb: CustomRoute[]) {
if (breadcrumb.length === 0) return formatRoute(to).text
return (breadcrumb.length > 2 ? breadcrumb.slice(-2) : breadcrumb)
.map((route) => route.text)
.reverse()
.join(' / ')
}
// Display a simplified breadcrumb as the document title.
document.title = `${getTitle(breadcrumb.value)} | ${i18n.global.t('yunohost_admin')}`
}
return {
host,
installed,
connected,
yunohost,
routerKey,
breadcrumb,
ssoLink,
onAppCreated,
getYunoHostVersion,
onLogout,
login,
logout,
tryToReconnect,
updateRouterKey,
updateBreadcrumb,
}
})

View file

@ -7,8 +7,8 @@ import type { APIQuery, RequestMethod } from '@/api/api'
import { APIErrorLog, type APIError } from '@/api/errors'
import { isObjectLiteral } from '@/helpers/commons'
import i18n from '@/i18n'
import store from '@/store'
import type { StateVariant } from '@/types/commons'
import { useInfos } from './useInfos'
export type RequestStatus = 'pending' | 'success' | 'warning' | 'error'
@ -162,7 +162,7 @@ export const useRequests = createGlobalState(() => {
err.log()
if (err.code === 401) {
// Unauthorized
store.dispatch('DISCONNECT')
useInfos().onLogout()
} else if (err instanceof APIErrorLog) {
// Errors that have produced logs
router.push({ name: 'tool-log', params: { name: err.logRef } })

View file

@ -1,8 +1,8 @@
import { createRouter, createWebHashHistory } from 'vue-router'
import { useInfos } from '@/composables/useInfos'
import { useRequests } from '@/composables/useRequests'
import { useSettings } from '@/composables/useSettings'
import store from '@/store'
import routes from './routes'
const router = createRouter({
@ -39,20 +39,22 @@ router.beforeEach((to, from, next) => {
dismissModal(currentRequest.value.id)
}
if (to.name === 'post-install' && store.getters.installed) {
const { installed, connected, onLogout } = useInfos()
if (to.name === 'post-install' && installed.value) {
return next('/')
}
// Allow if connected or route is not protected
if (store.getters.connected || to.meta.noAuth) {
if (connected.value || to.meta.noAuth) {
next()
} else {
store.dispatch('DISCONNECT', to)
onLogout(to)
}
})
router.afterEach((to, from) => {
store.dispatch('UPDATE_ROUTER_KEY', { to, from })
store.dispatch('UPDATE_BREADCRUMB', { to, from })
const { updateRouterKey, updateBreadcrumb } = useInfos()
updateRouterKey({ to, from })
updateBreadcrumb({ to, from })
})
export default router

View file

@ -2,11 +2,10 @@
import { reactive, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter, type LocationQueryValue } from 'vue-router'
import { useStore } from 'vuex'
import { useForm } from '@/composables/form'
import { useInfos } from '@/composables/useInfos'
import { alphalownumdot_, minLength, required } from '@/helpers/validators'
import { useStoreGetters } from '@/store/utils'
import type { FieldProps, FormFieldDict } from '@/types/form'
const props = withDefaults(
@ -19,10 +18,8 @@ const props = withDefaults(
)
const { t } = useI18n()
const store = useStore()
const router = useRouter()
const { installed } = useStoreGetters()
const { login, installed } = useInfos()
type Form = typeof form.value
const form = ref({
@ -57,8 +54,7 @@ const { v, onSubmit } = useForm(form, fields)
const onLogin = onSubmit((onError) => {
const { username, password } = form.value
const credentials = [username, password].join(':')
store
.dispatch('LOGIN', credentials)
login(credentials)
.then(() => {
if (props.forceReload) {
window.location.href = '/yunohost/admin/'

View file

@ -2,11 +2,11 @@
import { computed, ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { useRouter } from 'vue-router'
import { useStore } from 'vuex'
import api from '@/api'
import { APIBadRequestError, type APIError } from '@/api/errors'
import { useAutoModal } from '@/composables/useAutoModal'
import { useInfos } from '@/composables/useInfos'
import { useInitialQueries } from '@/composables/useInitialQueries'
import { isEmptyValue } from '@/helpers/commons'
import { readableDate } from '@/helpers/filters/date'
@ -19,7 +19,6 @@ const props = defineProps<{
const { t } = useI18n()
const router = useRouter()
const store = useStore()
const modalConfirm = useAutoModal()
const { loading } = useInitialQueries(
[{ uri: `backups/${props.name}?with_details` }],
@ -128,9 +127,9 @@ async function deleteBackup() {
}
function downloadBackup() {
const host = store.getters.host
const { host } = useInfos()
window.open(
`https://${host}/yunohost/api/backups/${props.name}/download`,
`https://${host.value}/yunohost/api/backups/${props.name}/download`,
'_blank',
)
}

View file

@ -1,13 +1,13 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n'
import { useStore } from 'vuex'
import api from '@/api'
import { useAutoModal } from '@/composables/useAutoModal'
import { useInfos } from '@/composables/useInfos'
const { t } = useI18n()
const store = useStore()
const modalConfirm = useAutoModal()
const { tryToReconnect } = useInfos()
async function triggerAction(action) {
const confirmed = await modalConfirm(t('confirm_reboot_action_' + action))
@ -15,11 +15,7 @@ async function triggerAction(action) {
api.put({ uri: action + '?force', humanKey: action }).then(() => {
const delay = action === 'reboot' ? 4000 : 10000
store.dispatch('TRY_TO_RECONNECT', {
attemps: Infinity,
origin: action,
delay,
})
tryToReconnect({ attemps: Infinity, origin: action, delay })
})
}
</script>

View file

@ -1,15 +1,15 @@
<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 { useInfos } from '@/composables/useInfos'
import { useInitialQueries } from '@/composables/useInitialQueries'
const { t } = useI18n()
const store = useStore()
const { tryToReconnect } = useInfos()
const modalConfirm = useAutoModal()
const { loading } = useInitialQueries(
[{ method: 'PUT', uri: 'update/all', humanKey: 'update' }],
@ -105,7 +105,7 @@ async function performSystemUpgrade() {
api.put({ uri: 'upgrade/system', humanKey: 'upgrade.system' }).then(() => {
if (system.value.some(({ name }) => name.includes('yunohost'))) {
store.dispatch('TRY_TO_RECONNECT', {
tryToReconnect({
attemps: 1,
origin: 'upgrade_system',
initialDelay: 2000,

View file

@ -1,13 +1,12 @@
<script setup lang="ts">
import { type ComputedRef } from 'vue'
import { useStore } from 'vuex'
import { useInfos } from '@/composables/useInfos'
import { useInitialQueries } from '@/composables/useInitialQueries'
import { useSearch } from '@/composables/useSearch'
import { useStoreGetters } from '@/store/utils'
import type { Obj } from '@/types/commons'
const store = useStore()
const { loading } = useInitialQueries([
{
uri: 'users?fields=username&fields=fullname&fields=mail&fields=mailbox-quota&fields=groups',
@ -23,8 +22,8 @@ const [search, filteredUsers] = useSearch(
)
function downloadExport() {
const host = store.getters.host
window.open(`https://${host}/yunohost/api/users/export`, '_blank')
const { host } = useInfos()
window.open(`https://${host.value}/yunohost/api/users/export`, '_blank')
}
</script>