@userfrosting/sprinkle-core 6.0.0-alpha.4 → 6.0.0-alpha.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,2 +1,3 @@
1
- export { useSprunjer } from './sprunjer'
2
- export { usePageMeta } from './usePageMeta'
1
+ export { useSprunjer } from './useSprunjer'
2
+ export { useCsrf } from './useCsrf'
3
+ export { useAxiosInterceptor } from './useAxiosInterceptor'
@@ -0,0 +1,30 @@
1
+ import axios from 'axios'
2
+ import { useAlertsStore } from '../stores/useAlertsStore'
3
+ import { Severity } from '../interfaces'
4
+
5
+ /**
6
+ * Axios Error Handler
7
+ *
8
+ * This composable sets up an Axios interceptor to handle errors globally using
9
+ * the Alerts store. It sends the error to the Alerts store, unless it's a 401
10
+ * error.
11
+ *
12
+ * @see https://axios-http.com/docs/interceptors
13
+ */
14
+ export const useAxiosInterceptor = () => {
15
+ axios.interceptors.response.use(
16
+ (response) => response,
17
+ (error) => {
18
+ if (error.response.status !== 401) {
19
+ const alertStore = useAlertsStore()
20
+ alertStore.push({
21
+ title: error.response.data.title ?? error.response?.statusText,
22
+ description: error.response?.data?.description ?? error.message,
23
+ style: Severity.Danger
24
+ })
25
+ }
26
+
27
+ return Promise.reject(error)
28
+ }
29
+ )
30
+ }
@@ -0,0 +1,129 @@
1
+ import { ref, watchEffect } from 'vue'
2
+ import { useConfigStore } from '../stores'
3
+ import axios from 'axios'
4
+
5
+ /**
6
+ * CSRF Protection Composable
7
+ *
8
+ * Automatically sets the CSRF token in the axios headers for all requests.
9
+ * The CSRF token is read from the meta tags in the HTML document.
10
+ * The CSRF token can updated when the server responds with a new token in the headers.
11
+ *
12
+ * @see https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#axios
13
+ */
14
+ export const useCsrf = () => {
15
+ /**
16
+ * Public constant for the CSRF token name and value, plus respective keys.
17
+ */
18
+ const key_name = ref(getNameKey())
19
+ const key_value = ref(getValueKey())
20
+ const name = ref(readMetaTag(key_name.value))
21
+ const token = ref(readMetaTag(key_value.value))
22
+
23
+ /**
24
+ * Set the axios headers for CSRF protection
25
+ */
26
+ function setAxiosHeader() {
27
+ axios.defaults.headers.post[key_name.value] = name.value
28
+ axios.defaults.headers.post[key_value.value] = token.value
29
+ axios.defaults.headers.put[key_name.value] = name.value
30
+ axios.defaults.headers.put[key_value.value] = token.value
31
+ axios.defaults.headers.delete[key_name.value] = name.value
32
+ axios.defaults.headers.delete[key_value.value] = token.value
33
+ axios.defaults.headers.patch[key_name.value] = name.value
34
+ axios.defaults.headers.patch[key_value.value] = token.value
35
+ }
36
+
37
+ /**
38
+ * Fetch the CSRF token from the server
39
+ */
40
+ async function fetchCsrfToken() {
41
+ const response = await axios.get('/api/csrf')
42
+ updateFromHeaders(response.headers)
43
+ }
44
+
45
+ /**
46
+ * Get the CSRF token name and value keys from config.
47
+ */
48
+ function getNameKey(): string {
49
+ const config = useConfigStore()
50
+ return config.get('csrf.name', 'csrf') + '_name'
51
+ }
52
+ function getValueKey(): string {
53
+ const config = useConfigStore()
54
+ return config.get('csrf.name', 'csrf') + '_value'
55
+ }
56
+
57
+ /**
58
+ * Meta tag reader and writer
59
+ */
60
+ function readMetaTag(name: string): string {
61
+ return document.querySelector("meta[name='" + name + "']")?.getAttribute('content') ?? ''
62
+ }
63
+ function writeMetaTag(name: string, value: string) {
64
+ const metaTag = document.querySelector("meta[name='" + name + "']")
65
+ if (metaTag) {
66
+ metaTag.setAttribute('content', value)
67
+ } else {
68
+ const newMetaTag = document.createElement('meta')
69
+ newMetaTag.setAttribute('name', name)
70
+ newMetaTag.setAttribute('content', value)
71
+ document.head.appendChild(newMetaTag)
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Update the CSRF token with the values from the request headers.
77
+ *
78
+ * N.B.: CSRF keys are hardcoded with '{name}_name' and '{name}_value' in
79
+ * PHP. However, the headers doesn't allows underscores that are replaced
80
+ * with dashes automatically.
81
+ */
82
+ function updateFromHeaders(headers: any) {
83
+ const config = useConfigStore()
84
+ const nameKey = config.get('csrf.name', 'csrf') + '-name'
85
+ const valueKey = config.get('csrf.name', 'csrf') + '-value'
86
+
87
+ // Update both value only if the headers are present
88
+ // This is to avoid overwriting the CSRF token with empty values
89
+ if (nameKey in headers) {
90
+ name.value = headers[nameKey]
91
+ }
92
+ if (valueKey in headers) {
93
+ token.value = headers[valueKey]
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Return if CSRF is enabled
99
+ */
100
+ function isEnabled(): boolean {
101
+ const config = useConfigStore()
102
+ return config.get('csrf.enabled', true)
103
+ }
104
+
105
+ /**
106
+ * Watchers - Watch for changes in the CSRF token and update the axios
107
+ * headers + meta tags
108
+ */
109
+ watchEffect(() => {
110
+ if (isEnabled() && name.value !== '' && token.value !== '') {
111
+ writeMetaTag(key_name.value, name.value)
112
+ writeMetaTag(key_value.value, token.value)
113
+ setAxiosHeader()
114
+ }
115
+ })
116
+
117
+ /**
118
+ * Export functions and managed states
119
+ */
120
+ return {
121
+ key_name,
122
+ key_value,
123
+ name,
124
+ token,
125
+ isEnabled,
126
+ updateFromHeaders,
127
+ fetchCsrfToken
128
+ }
129
+ }
@@ -33,7 +33,13 @@
33
33
  */
34
34
  import { ref, toValue, watchEffect, computed } from 'vue'
35
35
  import axios from 'axios'
36
- import type { AssociativeArray, Sprunjer, SprunjerData, SprunjerResponse } from '../interfaces'
36
+ import type {
37
+ ApiErrorResponse,
38
+ AssociativeArray,
39
+ Sprunjer,
40
+ SprunjerData,
41
+ SprunjerResponse
42
+ } from '../interfaces'
37
43
 
38
44
  export const useSprunjer = (
39
45
  dataUrl: string | (() => string),
@@ -60,6 +66,7 @@ export const useSprunjer = (
60
66
 
61
67
  // State
62
68
  const loading = ref<boolean>(false)
69
+ const error = ref<ApiErrorResponse | null>(null)
63
70
 
64
71
  /**
65
72
  * Api fetch function
@@ -87,8 +94,7 @@ export const useSprunjer = (
87
94
  data.value.filterable = response.data.filterable ?? []
88
95
  })
89
96
  .catch((err) => {
90
- // TODO : User toast alert, or export alert
91
- console.error(err)
97
+ error.value = err.response.data as ApiErrorResponse
92
98
  })
93
99
  .finally(() => {
94
100
  loading.value = false
@@ -169,6 +175,7 @@ export const useSprunjer = (
169
175
  data,
170
176
  fetch,
171
177
  loading,
178
+ error,
172
179
  downloadCsv,
173
180
  totalPages,
174
181
  countFiltered,
@@ -1,15 +1,24 @@
1
1
  import type { App } from 'vue'
2
2
  import { useConfigStore, useTranslator } from './stores'
3
+ import { useCsrf } from './composables/useCsrf'
4
+ import { useAxiosInterceptor } from './composables'
3
5
 
4
6
  /**
5
7
  * Core Sprinkle initialization recipe.
6
8
  *
7
9
  * This recipe is responsible for loading the configuration from the api,
8
- * loading the translations and register the translator as $t and $tdate global
9
- * properties.
10
+ * loading the translations, register the translator as $t and $tdate global
11
+ * properties and setting up the axios CSRF headers.
10
12
  */
11
13
  export default {
12
14
  install: (app: App) => {
15
+ /**
16
+ * Add Axios error handler.
17
+ * Load first to ensure that all axios requests are intercepted.
18
+ * This is important for the config loading and translator loading.
19
+ */
20
+ useAxiosInterceptor()
21
+
13
22
  /**
14
23
  * Load configuration
15
24
  */
@@ -22,5 +31,10 @@ export default {
22
31
  translator.load()
23
32
  app.config.globalProperties.$t = translator.translate
24
33
  app.config.globalProperties.$tdate = translator.translateDate
34
+
35
+ /**
36
+ * Setup CSRF Protection.
37
+ */
38
+ useCsrf()
25
39
  }
26
40
  }
@@ -4,5 +4,12 @@
4
4
  * Generic API Response interface.
5
5
  */
6
6
  export interface ApiResponse {
7
- message: string
7
+ title: string
8
+ description?: string
9
+ }
10
+
11
+ export interface ApiErrorResponse {
12
+ title: string
13
+ description: string
14
+ status: number
8
15
  }
@@ -27,4 +27,4 @@ export type { SprunjerRequest, SprunjerResponse } from './sprunjerApi'
27
27
  export type { DictionaryResponse, DictionaryEntries, DictionaryConfig } from './DictionaryApi'
28
28
 
29
29
  // Misc
30
- export type { ApiResponse } from './ApiResponse'
30
+ export type { ApiResponse, ApiErrorResponse } from './ApiResponse'
@@ -4,7 +4,7 @@
4
4
  * Represents the interface for the Sprunjer composable.
5
5
  */
6
6
  import type { Ref, ComputedRef } from 'vue'
7
- import type { AssociativeArray } from '.'
7
+ import type { ApiErrorResponse, AssociativeArray } from '.'
8
8
 
9
9
  export interface Sprunjer {
10
10
  dataUrl: string | (() => string)
@@ -15,6 +15,7 @@ export interface Sprunjer {
15
15
  data: Ref<SprunjerData>
16
16
  fetch: () => void
17
17
  loading: Ref<boolean>
18
+ error: Ref<ApiErrorResponse | null>
18
19
  totalPages: ComputedRef<number>
19
20
  downloadCsv: () => void
20
21
  countFiltered: ComputedRef<number>
@@ -12,7 +12,7 @@ export default [
12
12
  title: 'ERROR.404.TITLE',
13
13
  description: 'ERROR.404.DESCRIPTION'
14
14
  },
15
- component: () => import('../views/404NotFound.vue')
15
+ component: () => import('../views/Page404NotFound.vue')
16
16
  },
17
17
  {
18
18
  path: '/:pathMatch(.*)*',
@@ -21,7 +21,7 @@ export default [
21
21
  title: 'ERROR.401.TITLE',
22
22
  description: 'ERROR.401.DESCRIPTION'
23
23
  },
24
- component: () => import('../views/401Unauthorized.vue')
24
+ component: () => import('../views/Page401Unauthorized.vue')
25
25
  },
26
26
  {
27
27
  path: '/:pathMatch(.*)*',
@@ -30,7 +30,7 @@ export default [
30
30
  title: 'ERROR.403.TITLE',
31
31
  description: 'ERROR.403.DESCRIPTION'
32
32
  },
33
- component: () => import('../views/403Forbidden.vue')
33
+ component: () => import('../views/Page403Forbidden.vue')
34
34
  },
35
35
  {
36
36
  path: '/:pathMatch(.*)*',
@@ -39,6 +39,6 @@ export default [
39
39
  title: 'ERROR.TITLE',
40
40
  description: 'ERROR.DESCRIPTION'
41
41
  },
42
- component: () => import('../views/ErrorPage.vue')
42
+ component: () => import('../views/PageError.vue')
43
43
  }
44
44
  ]
@@ -1,2 +1,4 @@
1
- export { useConfigStore } from './config'
1
+ export { useConfigStore } from './useConfigStore'
2
+ export { usePageMeta } from './usePageMeta'
2
3
  export { useTranslator } from './useTranslator'
4
+ export { useAlertsStore } from './useAlertsStore'
@@ -0,0 +1,30 @@
1
+ import { defineStore } from 'pinia'
2
+ import type { AlertInterface } from '../interfaces'
3
+
4
+ /**
5
+ * Alerts Store
6
+ *
7
+ * Manages application alerts in a reactive store. Templates can use this
8
+ * store to display alerts to users, including errors, warnings,
9
+ * informational, or success messages. When an alert is added, templates
10
+ * should automatically update the interface to reflect the change.
11
+ */
12
+ export const useAlertsStore = defineStore('alerts', {
13
+ state: () => ({
14
+ alerts: [] as AlertInterface[]
15
+ }),
16
+ actions: {
17
+ push(alert: AlertInterface) {
18
+ this.alerts.push(alert)
19
+ },
20
+ pop() {
21
+ return this.alerts.pop()
22
+ },
23
+ shift() {
24
+ return this.alerts.shift()
25
+ },
26
+ clear() {
27
+ this.alerts = []
28
+ }
29
+ }
30
+ })
@@ -0,0 +1,212 @@
1
+ import { describe, expect, beforeEach, afterEach, test, vi } from 'vitest'
2
+ import axios from 'axios'
3
+ import { useConfigStore } from '../../stores/useConfigStore'
4
+ import { useCsrf } from '../../composables/useCsrf'
5
+ import { nextTick } from 'vue'
6
+
7
+ // Mock the config store
8
+ vi.mock('../../stores/useConfigStore')
9
+ const mockUseConfigStore = {
10
+ get: vi.fn()
11
+ }
12
+
13
+ describe('Csrf Composable', () => {
14
+ afterEach(() => {
15
+ vi.clearAllMocks()
16
+ vi.resetAllMocks()
17
+ })
18
+
19
+ beforeEach(() => {
20
+ // Mock the config store
21
+ mockUseConfigStore.get.mockImplementation((key, defaultValue) => {
22
+ if (key === 'csrf.enabled') return true
23
+ if (key === 'csrf.name') return 'csrf'
24
+ return defaultValue
25
+ })
26
+ vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
27
+
28
+ // Reset axios defaults
29
+ axios.defaults.headers.post = {}
30
+ axios.defaults.headers.put = {}
31
+ axios.defaults.headers.delete = {}
32
+ axios.defaults.headers.patch = {}
33
+
34
+ // Reset the document head
35
+ document.head.innerHTML = ''
36
+ })
37
+
38
+ test('initializes CSRF token name and value from meta tags', () => {
39
+ document.head.innerHTML = `
40
+ <meta name="csrf_name" content="123456">
41
+ <meta name="csrf_value" content="7c4a8d09">
42
+ `
43
+
44
+ const { name, token } = useCsrf()
45
+ expect(name.value).toBe('123456')
46
+ expect(token.value).toBe('7c4a8d09')
47
+ })
48
+
49
+ test('sets axios headers correctly when CSRF is enabled', async () => {
50
+ const csrf = useCsrf()
51
+
52
+ // Expect axios default headers
53
+ expect(axios.defaults.headers.post).toEqual({})
54
+ expect(axios.defaults.headers.put).toEqual({})
55
+ expect(axios.defaults.headers.delete).toEqual({})
56
+ expect(axios.defaults.headers.patch).toEqual({})
57
+
58
+ // Set CSRF token values - Will trigger the WatchEffect
59
+ csrf.name.value = '654321'
60
+ csrf.token.value = 'abcdef'
61
+
62
+ // Wait for the next tick to ensure watchEffect is triggered
63
+ await nextTick()
64
+
65
+ // Expect axios headers to be set correctly
66
+ expect(csrf.isEnabled()).toBe(true)
67
+ expect(csrf.name.value).toBe('654321')
68
+ expect(csrf.token.value).toBe('abcdef')
69
+ expect(csrf.key_name.value).toBe('csrf_name')
70
+ expect(csrf.key_value.value).toBe('csrf_value')
71
+ expect(axios.defaults.headers.post['csrf_name']).toBe('654321')
72
+ expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
73
+ expect(axios.defaults.headers.put['csrf_name']).toBe('654321')
74
+ expect(axios.defaults.headers.put['csrf_value']).toBe('abcdef')
75
+ expect(axios.defaults.headers.delete['csrf_name']).toBe('654321')
76
+ expect(axios.defaults.headers.delete['csrf_value']).toBe('abcdef')
77
+ expect(axios.defaults.headers.patch['csrf_name']).toBe('654321')
78
+ expect(axios.defaults.headers.patch['csrf_value']).toBe('abcdef')
79
+ })
80
+
81
+ test('does not set axios headers when CSRF is disabled', () => {
82
+ // Change the mock implementation to simulate CSRF being disabled
83
+ mockUseConfigStore.get.mockImplementation((key, defaultValue) => {
84
+ if (key === 'csrf.enabled') return false // CSRF is disabled
85
+ if (key === 'csrf.name') return 'csrf'
86
+ return defaultValue
87
+ })
88
+ vi.mocked(useConfigStore).mockReturnValue(mockUseConfigStore as any)
89
+
90
+ // Get the CSRF composable
91
+ const csrf = useCsrf()
92
+
93
+ // Assert everything is empty
94
+ expect(csrf.isEnabled()).toBe(false)
95
+ expect(csrf.name.value).toBe('')
96
+ expect(csrf.token.value).toBe('')
97
+ expect(axios.defaults.headers.post).toEqual({})
98
+ expect(axios.defaults.headers.put).toEqual({})
99
+ expect(axios.defaults.headers.delete).toEqual({})
100
+ expect(axios.defaults.headers.patch).toEqual({})
101
+ })
102
+
103
+ test('updates CSRF token updates meta tags', async () => {
104
+ document.head.innerHTML = `
105
+ <meta name="csrf_name" content="old_name">
106
+ <meta name="csrf_value" content="old_value">
107
+ `
108
+
109
+ // Assert initial state
110
+ const csrf = useCsrf()
111
+ expect(csrf.name.value).toBe('old_name')
112
+ expect(csrf.token.value).toBe('old_value')
113
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
114
+ 'old_name'
115
+ )
116
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
117
+ 'old_value'
118
+ )
119
+
120
+ // Update CSRF tokens manually
121
+ csrf.name.value = 'new_name'
122
+ csrf.token.value = 'new_value'
123
+
124
+ // Wait for the next tick to ensure watchEffect is triggered
125
+ await nextTick()
126
+
127
+ // Assert new state
128
+ expect(csrf.name.value).toBe('new_name')
129
+ expect(csrf.token.value).toBe('new_value')
130
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
131
+ 'new_name'
132
+ )
133
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
134
+ 'new_value'
135
+ )
136
+ })
137
+
138
+ test('CSRF token can be updated from headers', async () => {
139
+ const csrf = useCsrf()
140
+
141
+ // Assert initial state
142
+ expect(csrf.name.value).toBe('')
143
+ expect(csrf.token.value).toBe('')
144
+
145
+ const headers = {
146
+ 'csrf-name': 'new_name',
147
+ 'csrf-value': 'new_value'
148
+ }
149
+ csrf.updateFromHeaders(headers)
150
+
151
+ // Wait for the next tick to ensure watchEffect is triggered
152
+ await nextTick()
153
+
154
+ expect(csrf.name.value).toBe('new_name')
155
+ expect(csrf.token.value).toBe('new_value')
156
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
157
+ 'new_name'
158
+ )
159
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
160
+ 'new_value'
161
+ )
162
+ expect(axios.defaults.headers.post['csrf_name']).toBe('new_name')
163
+ expect(axios.defaults.headers.post['csrf_value']).toBe('new_value')
164
+ expect(axios.defaults.headers.put['csrf_name']).toBe('new_name')
165
+ expect(axios.defaults.headers.put['csrf_value']).toBe('new_value')
166
+ expect(axios.defaults.headers.delete['csrf_name']).toBe('new_name')
167
+ expect(axios.defaults.headers.delete['csrf_value']).toBe('new_value')
168
+ expect(axios.defaults.headers.patch['csrf_name']).toBe('new_name')
169
+ expect(axios.defaults.headers.patch['csrf_value']).toBe('new_value')
170
+ })
171
+
172
+ test('CSRF token can handle empty headers', async () => {
173
+ document.head.innerHTML = `
174
+ <meta name="csrf_name" content="123456">
175
+ <meta name="csrf_value" content="abcdef">
176
+ `
177
+
178
+ // Assert initial state
179
+ const csrf = useCsrf()
180
+ expect(csrf.name.value).toBe('123456')
181
+ expect(csrf.token.value).toBe('abcdef')
182
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
183
+ '123456'
184
+ )
185
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
186
+ 'abcdef'
187
+ )
188
+ expect(axios.defaults.headers.post['csrf_name']).toBe('123456')
189
+ expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
190
+
191
+ // Call updateFromHeaders with empty headers
192
+ const headers = {
193
+ foo: 'bar'
194
+ }
195
+ csrf.updateFromHeaders(headers)
196
+
197
+ // Wait for the next tick to ensure watchEffect is triggered
198
+ await nextTick()
199
+
200
+ // Assert state remains unchanged
201
+ expect(csrf.name.value).toBe('123456')
202
+ expect(csrf.token.value).toBe('abcdef')
203
+ expect(document.querySelector("meta[name='csrf_name']")?.getAttribute('content')).toBe(
204
+ '123456'
205
+ )
206
+ expect(document.querySelector("meta[name='csrf_value']")?.getAttribute('content')).toBe(
207
+ 'abcdef'
208
+ )
209
+ expect(axios.defaults.headers.post['csrf_name']).toBe('123456')
210
+ expect(axios.defaults.headers.post['csrf_value']).toBe('abcdef')
211
+ })
212
+ })
@@ -1,12 +1,13 @@
1
1
  import { describe, expect, test, vi } from 'vitest'
2
2
  import { createApp } from 'vue'
3
- import { useConfigStore } from '../stores/config'
3
+ import { useConfigStore } from '../stores/useConfigStore'
4
4
  import plugin from '..'
5
- import * as Config from '../stores/config'
5
+ import * as Config from '../stores/useConfigStore'
6
6
  import * as Translator from '../stores/useTranslator'
7
7
 
8
8
  const mockConfigStore = {
9
- load: vi.fn()
9
+ load: vi.fn(),
10
+ get: vi.fn().mockReturnValue('csrf')
10
11
  }
11
12
 
12
13
  const mockTranslatorStore = {
@@ -1,7 +1,7 @@
1
1
  import { describe, expect, beforeEach, test, vi } from 'vitest'
2
2
  import { setActivePinia, createPinia } from 'pinia'
3
3
  import axios from 'axios'
4
- import { useConfigStore } from '../../stores/config'
4
+ import { useConfigStore } from '../../stores/useConfigStore'
5
5
 
6
6
  const testConfig = {
7
7
  name: 'Test Config',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@userfrosting/sprinkle-core",
3
- "version": "6.0.0-alpha.4",
3
+ "version": "6.0.0-alpha.6",
4
4
  "type": "module",
5
5
  "description": "Core Sprinkle for UserFrosting",
6
6
  "funding": "https://opencollective.com/userfrosting",
@@ -49,7 +49,7 @@
49
49
  "@types/luxon": "^3.4.2",
50
50
  "@types/node": "^20.12.5",
51
51
  "@vitejs/plugin-vue": "^5.0.4",
52
- "@vitest/coverage-v8": "^1.6.0",
52
+ "@vitest/coverage-v8": "^3.1.1",
53
53
  "@vue/eslint-config-prettier": "^9.0.0",
54
54
  "@vue/eslint-config-typescript": "^13.0.0",
55
55
  "@vue/test-utils": "^2.4.6",
@@ -59,10 +59,10 @@
59
59
  "happy-dom": "^15.11.6",
60
60
  "less": "^4.2.0",
61
61
  "npm-run-all2": "^6.1.2",
62
- "prettier": "^3.2.5",
63
- "vite": "^5.2.8",
62
+ "prettier": "^3.6.2",
63
+ "vite": "^6.2",
64
64
  "vite-plugin-dts": "^4.0.0",
65
- "vitest": "^1.6.0",
65
+ "vitest": "^3.1.1",
66
66
  "vue": "^3.4.21",
67
67
  "vue-router": "^4.2.4",
68
68
  "vue-tsc": "^2.0.11"