@morscherlab/mint-sdk 1.0.0-beta.6 → 1.0.0-rc.1
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.
- package/dist/__tests__/composables/useProtocolTemplates.test.d.ts +1 -0
- package/dist/__tests__/stores/settings.test.d.ts +1 -0
- package/dist/{auth-QQj2kkze.js → auth-CBG3bWEc.js} +50 -20
- package/dist/auth-CBG3bWEc.js.map +1 -0
- package/dist/components/SettingsModal.vue.d.ts +5 -0
- package/dist/components/index.js +2 -2
- package/dist/{components-DihbSJjU.js → components-5KSfsVqf.js} +49 -29
- package/dist/components-5KSfsVqf.js.map +1 -0
- package/dist/composables/index.js +3 -3
- package/dist/{composables-BcgZ6diz.js → composables-D4Myb30a.js} +3 -3
- package/dist/{composables-BcgZ6diz.js.map → composables-D4Myb30a.js.map} +1 -1
- package/dist/index.js +5 -5
- package/dist/install.js +2 -2
- package/dist/stores/index.js +1 -1
- package/dist/styles.css +16 -0
- package/dist/templates/index.js +1 -1
- package/dist/{templates-Cyt0Suwf.js → templates-BSlxwV2c.js} +12 -8
- package/dist/templates-BSlxwV2c.js.map +1 -0
- package/dist/{useExperimentData-CM6Y0u5L.js → useExperimentData-BbbdI5xT.js} +97 -25
- package/dist/useExperimentData-BbbdI5xT.js.map +1 -0
- package/package.json +1 -1
- package/src/__tests__/components/GroupAssigner.test.ts +18 -0
- package/src/__tests__/composables/useApi.test.ts +45 -0
- package/src/__tests__/composables/useAuth.test.ts +20 -0
- package/src/__tests__/composables/useProtocolTemplates.test.ts +64 -0
- package/src/__tests__/stores/settings.test.ts +78 -0
- package/src/components/AppAvatarMenu.vue +6 -3
- package/src/components/AppTopBar.vue +15 -10
- package/src/components/AuditTrail.vue +1 -1
- package/src/components/Calendar.vue +6 -2
- package/src/components/ConcentrationInput.vue +3 -2
- package/src/components/GroupAssigner.vue +8 -3
- package/src/components/NumberInput.vue +5 -3
- package/src/components/SampleHierarchyTree.vue +3 -2
- package/src/components/SettingsModal.vue +7 -0
- package/src/components/UnitInput.vue +6 -2
- package/src/components/WellPlate.vue +3 -3
- package/src/composables/useApi.ts +113 -16
- package/src/composables/useAutoGroup.ts +13 -8
- package/src/composables/useProtocolTemplates.ts +13 -1
- package/src/composables/useRackEditor.ts +3 -2
- package/src/stores/auth.ts +48 -23
- package/src/stores/settings.ts +10 -0
- package/src/styles/components/settings-modal.css +9 -0
- package/dist/auth-QQj2kkze.js.map +0 -1
- package/dist/components-DihbSJjU.js.map +0 -1
- package/dist/templates-Cyt0Suwf.js.map +0 -1
- package/dist/useExperimentData-CM6Y0u5L.js.map +0 -1
|
@@ -1,17 +1,97 @@
|
|
|
1
|
-
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
|
|
1
|
+
import axios, { type AxiosInstance, type AxiosRequestConfig, type InternalAxiosRequestConfig } from 'axios'
|
|
2
2
|
import { useSettingsStore } from '../stores/settings'
|
|
3
3
|
import { useAuthStore } from '../stores/auth'
|
|
4
4
|
|
|
5
5
|
let apiClientInstance: AxiosInstance | null = null
|
|
6
6
|
let interceptorAttached = false
|
|
7
7
|
|
|
8
|
+
interface MintAxiosRequestConfig extends AxiosRequestConfig {
|
|
9
|
+
_mintSkipAuth?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface MintInternalAxiosRequestConfig extends InternalAxiosRequestConfig {
|
|
13
|
+
_mintSkipAuth?: boolean
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type MutableHeaders = Record<string, unknown> & {
|
|
17
|
+
has?: (header: string) => boolean
|
|
18
|
+
set?: (header: string, value: string) => void
|
|
19
|
+
delete?: (header: string) => void
|
|
20
|
+
}
|
|
21
|
+
|
|
8
22
|
function joinUrlPath(baseUrl: string, path: string): string {
|
|
9
23
|
if (!path) return baseUrl
|
|
24
|
+
if (path.startsWith('?') || path.startsWith('#')) return `${baseUrl.replace(/\/+$/, '')}${path}`
|
|
10
25
|
const normalizedBase = baseUrl.replace(/\/+$/, '')
|
|
11
26
|
const normalizedPath = path.replace(/^\/+/, '/')
|
|
12
27
|
return `${normalizedBase}${normalizedPath.startsWith('/') ? normalizedPath : `/${normalizedPath}`}`
|
|
13
28
|
}
|
|
14
29
|
|
|
30
|
+
function getBasePath(baseUrl: string): string {
|
|
31
|
+
if (!baseUrl) return '/'
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'
|
|
35
|
+
return new URL(baseUrl, origin).pathname.replace(/\/+$/, '') || '/'
|
|
36
|
+
} catch {
|
|
37
|
+
const path = baseUrl.replace(/^https?:\/\/[^/]+/i, '')
|
|
38
|
+
return path.replace(/\/+$/, '') || '/'
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function normalizeRequestUrl(baseUrl: string, url: string): string {
|
|
43
|
+
if (!url || /^https?:\/\//.test(url)) return url
|
|
44
|
+
|
|
45
|
+
const basePath = getBasePath(baseUrl)
|
|
46
|
+
if (basePath === '/') return url
|
|
47
|
+
|
|
48
|
+
const normalizedUrl = url.startsWith('/') ? url : `/${url}`
|
|
49
|
+
if (
|
|
50
|
+
normalizedUrl === basePath
|
|
51
|
+
|| normalizedUrl.startsWith(`${basePath}?`)
|
|
52
|
+
|| normalizedUrl.startsWith(`${basePath}#`)
|
|
53
|
+
) {
|
|
54
|
+
return normalizedUrl.slice(basePath.length)
|
|
55
|
+
}
|
|
56
|
+
if (normalizedUrl.startsWith(`${basePath}/`)) {
|
|
57
|
+
return normalizedUrl.slice(basePath.length) || ''
|
|
58
|
+
}
|
|
59
|
+
return url
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function asMutableHeaders(headers: AxiosRequestConfig['headers']): MutableHeaders | null {
|
|
63
|
+
return headers ? (headers as MutableHeaders) : null
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function hasAuthorizationHeader(headers: AxiosRequestConfig['headers']): boolean {
|
|
67
|
+
const bag = asMutableHeaders(headers)
|
|
68
|
+
if (!bag) return false
|
|
69
|
+
if (typeof bag.has === 'function') return bag.has('Authorization')
|
|
70
|
+
return Object.keys(bag).some((key) => key.toLowerCase() === 'authorization')
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function setAuthorizationHeader(headers: AxiosRequestConfig['headers'], value: string): void {
|
|
74
|
+
const bag = asMutableHeaders(headers)
|
|
75
|
+
if (!bag) return
|
|
76
|
+
if (typeof bag.set === 'function') {
|
|
77
|
+
bag.set('Authorization', value)
|
|
78
|
+
return
|
|
79
|
+
}
|
|
80
|
+
bag.Authorization = value
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function deleteAuthorizationHeader(headers: AxiosRequestConfig['headers']): void {
|
|
84
|
+
const bag = asMutableHeaders(headers)
|
|
85
|
+
if (!bag) return
|
|
86
|
+
if (typeof bag.delete === 'function') {
|
|
87
|
+
bag.delete('Authorization')
|
|
88
|
+
return
|
|
89
|
+
}
|
|
90
|
+
for (const key of Object.keys(bag)) {
|
|
91
|
+
if (key.toLowerCase() === 'authorization') delete bag[key]
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
15
95
|
function getApiClient(): AxiosInstance {
|
|
16
96
|
if (!apiClientInstance) {
|
|
17
97
|
apiClientInstance = axios.create({
|
|
@@ -56,51 +136,68 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
56
136
|
// Attach auth interceptor only once (reads token dynamically, not from closure)
|
|
57
137
|
if (!interceptorAttached) {
|
|
58
138
|
apiClient.interceptors.request.use((config) => {
|
|
59
|
-
|
|
60
|
-
|
|
139
|
+
const request = config as MintInternalAxiosRequestConfig
|
|
140
|
+
if (request._mintSkipAuth) {
|
|
141
|
+
delete request._mintSkipAuth
|
|
142
|
+
deleteAuthorizationHeader(request.headers)
|
|
143
|
+
return request
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const currentAuthStore = useAuthStore()
|
|
147
|
+
if (currentAuthStore.token && config.headers && !hasAuthorizationHeader(config.headers)) {
|
|
148
|
+
setAuthorizationHeader(config.headers, `Bearer ${currentAuthStore.token}`)
|
|
61
149
|
}
|
|
62
150
|
return config
|
|
63
151
|
})
|
|
64
152
|
interceptorAttached = true
|
|
65
153
|
}
|
|
66
154
|
|
|
155
|
+
function getBaseUrl(): string {
|
|
156
|
+
return options.baseUrl ?? settingsStore.getApiBaseUrl()
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function normalizeUrl(url: string): string {
|
|
160
|
+
return normalizeRequestUrl(getBaseUrl(), url)
|
|
161
|
+
}
|
|
162
|
+
|
|
67
163
|
// Build per-request config that applies this caller's options
|
|
68
|
-
function requestConfig(config?: AxiosRequestConfig):
|
|
69
|
-
const base:
|
|
70
|
-
baseURL:
|
|
164
|
+
function requestConfig(config?: AxiosRequestConfig): MintAxiosRequestConfig {
|
|
165
|
+
const base: MintAxiosRequestConfig = {
|
|
166
|
+
baseURL: getBaseUrl(),
|
|
71
167
|
timeout: options.timeout ?? settingsStore.requestTimeout,
|
|
72
168
|
...config,
|
|
73
169
|
}
|
|
74
170
|
// Strip auth header if explicitly disabled
|
|
75
171
|
if (options.withAuth === false) {
|
|
76
|
-
base.
|
|
172
|
+
base._mintSkipAuth = true
|
|
173
|
+
deleteAuthorizationHeader(base.headers)
|
|
77
174
|
}
|
|
78
175
|
return base
|
|
79
176
|
}
|
|
80
177
|
|
|
81
178
|
// Generic request methods
|
|
82
179
|
async function get<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
83
|
-
const response = await apiClient.get<T>(url, requestConfig(config))
|
|
180
|
+
const response = await apiClient.get<T>(normalizeUrl(url), requestConfig(config))
|
|
84
181
|
return response.data
|
|
85
182
|
}
|
|
86
183
|
|
|
87
184
|
async function post<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
88
|
-
const response = await apiClient.post<T>(url, data, requestConfig(config))
|
|
185
|
+
const response = await apiClient.post<T>(normalizeUrl(url), data, requestConfig(config))
|
|
89
186
|
return response.data
|
|
90
187
|
}
|
|
91
188
|
|
|
92
189
|
async function put<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
93
|
-
const response = await apiClient.put<T>(url, data, requestConfig(config))
|
|
190
|
+
const response = await apiClient.put<T>(normalizeUrl(url), data, requestConfig(config))
|
|
94
191
|
return response.data
|
|
95
192
|
}
|
|
96
193
|
|
|
97
194
|
async function patch<T>(url: string, data?: unknown, config?: AxiosRequestConfig): Promise<T> {
|
|
98
|
-
const response = await apiClient.patch<T>(url, data, requestConfig(config))
|
|
195
|
+
const response = await apiClient.patch<T>(normalizeUrl(url), data, requestConfig(config))
|
|
99
196
|
return response.data
|
|
100
197
|
}
|
|
101
198
|
|
|
102
199
|
async function del<T>(url: string, config?: AxiosRequestConfig): Promise<T> {
|
|
103
|
-
const response = await apiClient.delete<T>(url, requestConfig(config))
|
|
200
|
+
const response = await apiClient.delete<T>(normalizeUrl(url), requestConfig(config))
|
|
104
201
|
return response.data
|
|
105
202
|
}
|
|
106
203
|
|
|
@@ -117,7 +214,7 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
117
214
|
})
|
|
118
215
|
}
|
|
119
216
|
|
|
120
|
-
const response = await apiClient.post<T>(url, formData, requestConfig({
|
|
217
|
+
const response = await apiClient.post<T>(normalizeUrl(url), formData, requestConfig({
|
|
121
218
|
// Let Axios set Content-Type with the correct multipart boundary
|
|
122
219
|
headers: { 'Content-Type': undefined },
|
|
123
220
|
}))
|
|
@@ -126,7 +223,7 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
126
223
|
|
|
127
224
|
// Download helper - returns blob URL
|
|
128
225
|
async function download(url: string, filename?: string): Promise<string> {
|
|
129
|
-
const response = await apiClient.get(url, requestConfig({ responseType: 'blob' }))
|
|
226
|
+
const response = await apiClient.get(normalizeUrl(url), requestConfig({ responseType: 'blob' }))
|
|
130
227
|
const blob = new Blob([response.data])
|
|
131
228
|
const blobUrl = URL.createObjectURL(blob)
|
|
132
229
|
|
|
@@ -150,8 +247,8 @@ export function useApi(options: ApiClientOptions = {}): UseApiReturn {
|
|
|
150
247
|
|
|
151
248
|
// Build full URL for external use (e.g., <a href="...">)
|
|
152
249
|
function buildUrl(path: string): string {
|
|
153
|
-
const baseUrl =
|
|
154
|
-
return joinUrlPath(baseUrl, path)
|
|
250
|
+
const baseUrl = getBaseUrl()
|
|
251
|
+
return joinUrlPath(baseUrl, normalizeRequestUrl(baseUrl, path))
|
|
155
252
|
}
|
|
156
253
|
|
|
157
254
|
// WebSocket URL builder
|
|
@@ -254,10 +254,12 @@ export function computeGroups(
|
|
|
254
254
|
}
|
|
255
255
|
const groupKey = keyParts.join(' / ')
|
|
256
256
|
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
const group = groupMap.get(groupKey)
|
|
258
|
+
if (group) {
|
|
259
|
+
group.push(sample)
|
|
260
|
+
} else {
|
|
261
|
+
groupMap.set(groupKey, [sample])
|
|
259
262
|
}
|
|
260
|
-
groupMap.get(groupKey)!.push(sample)
|
|
261
263
|
|
|
262
264
|
// Build metadata row with ALL columns
|
|
263
265
|
const fields: Record<string, string> = {}
|
|
@@ -369,10 +371,12 @@ export function computeGroupsFromCsv(
|
|
|
369
371
|
const keyParts = enabledCols.map(col => row[col.originalName ?? col.name])
|
|
370
372
|
const groupKey = keyParts.join(' / ')
|
|
371
373
|
|
|
372
|
-
|
|
373
|
-
|
|
374
|
+
const group = groupMap.get(groupKey)
|
|
375
|
+
if (group) {
|
|
376
|
+
group.push(sampleName)
|
|
377
|
+
} else {
|
|
378
|
+
groupMap.set(groupKey, [sampleName])
|
|
374
379
|
}
|
|
375
|
-
groupMap.get(groupKey)!.push(sampleName)
|
|
376
380
|
|
|
377
381
|
// Build metadata row with ALL columns — use display name as key, original for lookup
|
|
378
382
|
const fields: Record<string, string> = {}
|
|
@@ -417,8 +421,9 @@ export function useAutoGroup() {
|
|
|
417
421
|
)
|
|
418
422
|
|
|
419
423
|
const samples = computed(() => {
|
|
420
|
-
|
|
421
|
-
|
|
424
|
+
const data = csvData.value
|
|
425
|
+
if (isTabularMode.value && data) {
|
|
426
|
+
return data.rows.map(r => r[data.sampleColumn])
|
|
422
427
|
}
|
|
423
428
|
return rawText.value
|
|
424
429
|
.split('\n')
|
|
@@ -348,11 +348,23 @@ const BUILT_IN_TEMPLATES: StepTemplate[] = [
|
|
|
348
348
|
// Storage key for custom templates
|
|
349
349
|
const STORAGE_KEY = 'mint-custom-protocol-templates'
|
|
350
350
|
|
|
351
|
+
function isStepTemplate(value: unknown): value is StepTemplate {
|
|
352
|
+
if (!value || typeof value !== 'object') return false
|
|
353
|
+
const candidate = value as Partial<StepTemplate>
|
|
354
|
+
return (
|
|
355
|
+
typeof candidate.id === 'string'
|
|
356
|
+
&& typeof candidate.type === 'string'
|
|
357
|
+
&& typeof candidate.name === 'string'
|
|
358
|
+
&& Array.isArray(candidate.parameters)
|
|
359
|
+
)
|
|
360
|
+
}
|
|
361
|
+
|
|
351
362
|
function loadCustomTemplates(): StepTemplate[] {
|
|
352
363
|
try {
|
|
353
364
|
const stored = localStorage.getItem(STORAGE_KEY)
|
|
354
365
|
if (stored) {
|
|
355
|
-
|
|
366
|
+
const parsed = JSON.parse(stored)
|
|
367
|
+
return Array.isArray(parsed) ? parsed.filter(isStepTemplate) : []
|
|
356
368
|
}
|
|
357
369
|
} catch {
|
|
358
370
|
// Ignore errors
|
|
@@ -198,8 +198,9 @@ export function useRackEditor(
|
|
|
198
198
|
})
|
|
199
199
|
|
|
200
200
|
function reset(): void {
|
|
201
|
-
|
|
202
|
-
|
|
201
|
+
const rack = createDefaultRack('Rack 1', 0)
|
|
202
|
+
racks.value = [rack]
|
|
203
|
+
activeRackId.value = rack.id
|
|
203
204
|
}
|
|
204
205
|
|
|
205
206
|
return {
|
package/src/stores/auth.ts
CHANGED
|
@@ -5,7 +5,38 @@ import type { AuthConfig, UserInfo } from '../types'
|
|
|
5
5
|
const AUTH_TOKEN_KEY = 'mint-auth-token'
|
|
6
6
|
const AUTH_EXPIRES_KEY = 'mint-auth-expires'
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
function getLocalStorage(): Storage | null {
|
|
9
|
+
try {
|
|
10
|
+
const storage = globalThis.localStorage
|
|
11
|
+
return typeof storage?.getItem === 'function' ? storage : null
|
|
12
|
+
} catch {
|
|
13
|
+
return null
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function readStoredItem(key: string): string | null {
|
|
18
|
+
try {
|
|
19
|
+
return getLocalStorage()?.getItem(key) ?? null
|
|
20
|
+
} catch {
|
|
21
|
+
return null
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function writeStoredItem(key: string, value: string): void {
|
|
26
|
+
try {
|
|
27
|
+
getLocalStorage()?.setItem(key, value)
|
|
28
|
+
} catch {
|
|
29
|
+
// Keep auth usable in-memory when browser storage is blocked.
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function removeStoredItem(key: string): void {
|
|
34
|
+
try {
|
|
35
|
+
getLocalStorage()?.removeItem(key)
|
|
36
|
+
} catch {
|
|
37
|
+
// Keep auth cleanup usable in-memory when browser storage is blocked.
|
|
38
|
+
}
|
|
39
|
+
}
|
|
9
40
|
|
|
10
41
|
export const useAuthStore = defineStore('mint-auth', () => {
|
|
11
42
|
// State
|
|
@@ -52,20 +83,18 @@ export const useAuthStore = defineStore('mint-auth', () => {
|
|
|
52
83
|
|
|
53
84
|
// Actions
|
|
54
85
|
function initialize() {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
clearToken()
|
|
68
|
-
}
|
|
86
|
+
const storedToken = readStoredItem(AUTH_TOKEN_KEY)
|
|
87
|
+
const storedExpires = readStoredItem(AUTH_EXPIRES_KEY)
|
|
88
|
+
|
|
89
|
+
if (storedToken) {
|
|
90
|
+
token.value = storedToken
|
|
91
|
+
|
|
92
|
+
if (storedExpires) {
|
|
93
|
+
const expires = new Date(storedExpires)
|
|
94
|
+
if (expires > new Date()) {
|
|
95
|
+
tokenExpires.value = expires
|
|
96
|
+
} else {
|
|
97
|
+
clearToken()
|
|
69
98
|
}
|
|
70
99
|
}
|
|
71
100
|
}
|
|
@@ -78,10 +107,8 @@ export const useAuthStore = defineStore('mint-auth', () => {
|
|
|
78
107
|
const expires = new Date(Date.now() + expiresIn * 1000)
|
|
79
108
|
tokenExpires.value = expires
|
|
80
109
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
localStorage.setItem(AUTH_EXPIRES_KEY, expires.toISOString())
|
|
84
|
-
}
|
|
110
|
+
writeStoredItem(AUTH_TOKEN_KEY, accessToken)
|
|
111
|
+
writeStoredItem(AUTH_EXPIRES_KEY, expires.toISOString())
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
function clearToken() {
|
|
@@ -90,10 +117,8 @@ export const useAuthStore = defineStore('mint-auth', () => {
|
|
|
90
117
|
username.value = null
|
|
91
118
|
userInfo.value = null
|
|
92
119
|
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
localStorage.removeItem(AUTH_EXPIRES_KEY)
|
|
96
|
-
}
|
|
120
|
+
removeStoredItem(AUTH_TOKEN_KEY)
|
|
121
|
+
removeStoredItem(AUTH_EXPIRES_KEY)
|
|
97
122
|
}
|
|
98
123
|
|
|
99
124
|
function setUserInfo(info: UserInfo) {
|
package/src/stores/settings.ts
CHANGED
|
@@ -24,6 +24,7 @@ export interface SettingsState {
|
|
|
24
24
|
}
|
|
25
25
|
|
|
26
26
|
const STORAGE_KEY = 'mint-settings'
|
|
27
|
+
const LEGACY_STORAGE_KEY = 'mld-settings'
|
|
27
28
|
|
|
28
29
|
function getDefaultServerHost(): string {
|
|
29
30
|
if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') {
|
|
@@ -68,6 +69,15 @@ function loadSettings(): SettingsState {
|
|
|
68
69
|
const parsed = JSON.parse(stored)
|
|
69
70
|
return { ...defaultSettings, ...parsed }
|
|
70
71
|
}
|
|
72
|
+
|
|
73
|
+
const legacyStored = localStorage.getItem(LEGACY_STORAGE_KEY)
|
|
74
|
+
if (legacyStored) {
|
|
75
|
+
const parsed = JSON.parse(legacyStored)
|
|
76
|
+
const migrated = { ...defaultSettings, ...parsed }
|
|
77
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(migrated))
|
|
78
|
+
localStorage.removeItem(LEGACY_STORAGE_KEY)
|
|
79
|
+
return migrated
|
|
80
|
+
}
|
|
71
81
|
} catch (e) {
|
|
72
82
|
console.warn('Failed to load settings from localStorage:', e)
|
|
73
83
|
}
|
|
@@ -94,6 +94,15 @@ html.dark .mint-settings-modal__option-btn--active {
|
|
|
94
94
|
padding: 0 !important;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
.mint-settings-modal__footer {
|
|
98
|
+
display: flex;
|
|
99
|
+
flex-direction: column;
|
|
100
|
+
gap: 0.75rem;
|
|
101
|
+
margin-top: 1rem;
|
|
102
|
+
padding-top: 1rem;
|
|
103
|
+
border-top: 1px solid var(--border-light);
|
|
104
|
+
}
|
|
105
|
+
|
|
97
106
|
/* ─────────────────────────────────────────────────────────────────────
|
|
98
107
|
* Schema-driven content grid (used by both layouts)
|
|
99
108
|
* Cols come from `--mint-settings-cols` set inline by the component;
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
{"version":3,"file":"auth-QQj2kkze.js","names":[],"sources":["../src/stores/settings.ts","../src/stores/auth.ts"],"sourcesContent":["import { defineStore } from 'pinia'\nimport { ref, watch } from 'vue'\nimport type { ThemeMode, ColorPalette, TableDensity } from '../types'\n\ndeclare global {\n interface ImportMetaEnv {\n readonly VITE_API_PREFIX?: string\n }\n\n interface ImportMeta {\n readonly env: ImportMetaEnv\n }\n}\n\nexport interface SettingsState {\n serverHost: string\n serverPort: number\n requestTimeout: number\n wsAutoReconnect: boolean\n wsReconnectInterval: number\n theme: ThemeMode\n colorPalette: ColorPalette\n tableDensity: TableDensity\n}\n\nconst STORAGE_KEY = 'mint-settings'\n\nfunction getDefaultServerHost(): string {\n if (typeof window !== 'undefined' && window.location.hostname !== 'localhost') {\n return window.location.hostname\n }\n return 'localhost'\n}\n\nfunction getDefaultServerPort(): number {\n if (typeof window !== 'undefined') {\n if (window.location.port) {\n return parseInt(window.location.port, 10)\n }\n // Standard ports: 443 for HTTPS, 80 for HTTP (browser omits from location.port)\n return window.location.protocol === 'https:' ? 443 : 80\n }\n return 8000\n}\n\nconst defaultSettings: SettingsState = {\n serverHost: getDefaultServerHost(),\n serverPort: getDefaultServerPort(),\n requestTimeout: 120000,\n wsAutoReconnect: true,\n wsReconnectInterval: 5000,\n theme: 'system',\n colorPalette: 'default',\n tableDensity: 'normal',\n}\n\nexport const colorPalettes: Record<ColorPalette, { name: string; hues: [number, number] }> = {\n default: { name: 'Default (Cyan-Pink)', hues: [180, 320] },\n colorblind: { name: 'Colorblind-friendly', hues: [45, 260] },\n viridis: { name: 'Viridis', hues: [280, 80] },\n pastel: { name: 'Pastel', hues: [200, 340] },\n}\n\nfunction loadSettings(): SettingsState {\n try {\n const stored = localStorage.getItem(STORAGE_KEY)\n if (stored) {\n const parsed = JSON.parse(stored)\n return { ...defaultSettings, ...parsed }\n }\n } catch (e) {\n console.warn('Failed to load settings from localStorage:', e)\n }\n return { ...defaultSettings }\n}\n\nfunction saveSettings(settings: SettingsState): void {\n try {\n localStorage.setItem(STORAGE_KEY, JSON.stringify(settings))\n } catch (e) {\n console.warn('Failed to save settings to localStorage:', e)\n }\n}\n\nfunction getSystemPrefersDark(): boolean {\n if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false\n return window.matchMedia('(prefers-color-scheme: dark)').matches\n}\n\nexport const useSettingsStore = defineStore('mint-settings', () => {\n // State\n const serverHost = ref(defaultSettings.serverHost)\n const serverPort = ref(defaultSettings.serverPort)\n const requestTimeout = ref(defaultSettings.requestTimeout)\n const wsAutoReconnect = ref(defaultSettings.wsAutoReconnect)\n const wsReconnectInterval = ref(defaultSettings.wsReconnectInterval)\n const theme = ref<ThemeMode>(defaultSettings.theme)\n const systemPrefersDark = ref(getSystemPrefersDark())\n const colorPalette = ref<ColorPalette>(defaultSettings.colorPalette)\n const tableDensity = ref<TableDensity>(defaultSettings.tableDensity)\n\n // API prefix - can be configured via env variable VITE_API_PREFIX\n const apiPrefix: string = (import.meta.env?.VITE_API_PREFIX as string | undefined) ?? '/api'\n\n function _isSameOrigin(): { same: boolean; currentHost: string; currentPort: string } {\n const currentHost = typeof window !== 'undefined' ? window.location.hostname : 'localhost'\n const currentPort = typeof window !== 'undefined' ? window.location.port : '8000'\n const effectivePort = currentPort || (window.location.protocol === 'https:' ? '443' : '80')\n const same =\n (serverHost.value === currentHost && String(serverPort.value) === effectivePort) ||\n (serverHost.value === 'localhost' && currentHost !== 'localhost')\n return { same, currentHost, currentPort }\n }\n\n function getApiBaseUrl(): string {\n const { same } = _isSameOrigin()\n if (same) return apiPrefix\n return `http://${serverHost.value}:${serverPort.value}${apiPrefix}`\n }\n\n function getWsBaseUrl(): string {\n const { same, currentHost, currentPort } = _isSameOrigin()\n const protocol = typeof window !== 'undefined' && window.location.protocol === 'https:' ? 'wss:' : 'ws:'\n if (same) return `${protocol}//${currentHost}${currentPort ? ':' + currentPort : ''}${apiPrefix}`\n return `ws://${serverHost.value}:${serverPort.value}${apiPrefix}`\n }\n\n let _initialized = false\n\n function initialize() {\n if (_initialized) return\n _initialized = true\n\n const loaded = loadSettings()\n serverHost.value = loaded.serverHost\n serverPort.value = loaded.serverPort\n requestTimeout.value = loaded.requestTimeout\n wsAutoReconnect.value = loaded.wsAutoReconnect\n wsReconnectInterval.value = loaded.wsReconnectInterval\n theme.value = loaded.theme\n colorPalette.value = loaded.colorPalette\n tableDensity.value = loaded.tableDensity\n\n applyTheme()\n }\n\n function applyTheme() {\n if (typeof document === 'undefined') return\n const dark = theme.value === 'system' ? systemPrefersDark.value : theme.value === 'dark'\n document.documentElement.classList.toggle('dark', dark)\n }\n\n watch(theme, () => {\n applyTheme()\n persistSettings()\n })\n\n if (typeof window !== 'undefined' && typeof window.matchMedia === 'function') {\n window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', (event) => {\n systemPrefersDark.value = event.matches\n if (theme.value === 'system') {\n applyTheme()\n }\n })\n }\n\n function persistSettings() {\n saveSettings({\n serverHost: serverHost.value,\n serverPort: serverPort.value,\n requestTimeout: requestTimeout.value,\n wsAutoReconnect: wsAutoReconnect.value,\n wsReconnectInterval: wsReconnectInterval.value,\n theme: theme.value,\n colorPalette: colorPalette.value,\n tableDensity: tableDensity.value,\n })\n }\n\n watch([serverHost, serverPort, requestTimeout, wsAutoReconnect, wsReconnectInterval, colorPalette, tableDensity], () => {\n persistSettings()\n })\n\n function resetToDefaults() {\n serverHost.value = defaultSettings.serverHost\n serverPort.value = defaultSettings.serverPort\n requestTimeout.value = defaultSettings.requestTimeout\n wsAutoReconnect.value = defaultSettings.wsAutoReconnect\n wsReconnectInterval.value = defaultSettings.wsReconnectInterval\n theme.value = defaultSettings.theme\n colorPalette.value = defaultSettings.colorPalette\n tableDensity.value = defaultSettings.tableDensity\n }\n\n function getPaletteHues(): [number, number] {\n return colorPalettes[colorPalette.value].hues\n }\n\n function isDark(): boolean {\n return theme.value === 'system' ? systemPrefersDark.value : theme.value === 'dark'\n }\n\n return {\n serverHost,\n serverPort,\n requestTimeout,\n wsAutoReconnect,\n wsReconnectInterval,\n theme,\n systemPrefersDark,\n colorPalette,\n tableDensity,\n initialize,\n applyTheme,\n persistSettings,\n resetToDefaults,\n getPaletteHues,\n isDark,\n getApiBaseUrl,\n getWsBaseUrl,\n }\n})\n","import { defineStore } from 'pinia'\nimport { ref, computed } from 'vue'\nimport type { AuthConfig, UserInfo } from '../types'\n\nconst AUTH_TOKEN_KEY = 'mint-auth-token'\nconst AUTH_EXPIRES_KEY = 'mint-auth-expires'\n\nconst hasLocalStorage = typeof localStorage !== 'undefined' && typeof localStorage.getItem === 'function'\n\nexport const useAuthStore = defineStore('mint-auth', () => {\n // State\n const token = ref<string | null>(null)\n const tokenExpires = ref<Date | null>(null)\n const username = ref<string | null>(null)\n const userInfo = ref<UserInfo | null>(null)\n const authConfig = ref<AuthConfig>({\n authRequired: true,\n passkeyEnabled: false,\n passkeyRegistered: false,\n registrationEnabled: false,\n databaseMode: 'none',\n })\n const isInitialized = ref(false)\n const isLoading = ref(false)\n const error = ref<string | null>(null)\n\n // Computed\n const isAuthenticated = computed(() => {\n if (!authConfig.value.authRequired) {\n return true\n }\n if (!token.value) {\n return false\n }\n if (tokenExpires.value && tokenExpires.value < new Date()) {\n return false\n }\n return true\n })\n\n const needsAuth = computed(() => {\n return authConfig.value.authRequired && !isAuthenticated.value\n })\n\n const isAdmin = computed(() => {\n return userInfo.value?.role === 'admin'\n })\n\n const canRegister = computed(() => {\n return authConfig.value.registrationEnabled\n })\n\n // Actions\n function initialize() {\n if (hasLocalStorage) {\n const storedToken = localStorage.getItem(AUTH_TOKEN_KEY)\n const storedExpires = localStorage.getItem(AUTH_EXPIRES_KEY)\n\n if (storedToken) {\n token.value = storedToken\n\n if (storedExpires) {\n const expires = new Date(storedExpires)\n if (expires > new Date()) {\n tokenExpires.value = expires\n } else {\n clearToken()\n }\n }\n }\n }\n\n isInitialized.value = true\n }\n\n function setToken(accessToken: string, expiresIn: number) {\n token.value = accessToken\n const expires = new Date(Date.now() + expiresIn * 1000)\n tokenExpires.value = expires\n\n if (hasLocalStorage) {\n localStorage.setItem(AUTH_TOKEN_KEY, accessToken)\n localStorage.setItem(AUTH_EXPIRES_KEY, expires.toISOString())\n }\n }\n\n function clearToken() {\n token.value = null\n tokenExpires.value = null\n username.value = null\n userInfo.value = null\n\n if (hasLocalStorage) {\n localStorage.removeItem(AUTH_TOKEN_KEY)\n localStorage.removeItem(AUTH_EXPIRES_KEY)\n }\n }\n\n function setUserInfo(info: UserInfo) {\n userInfo.value = info\n username.value = info.username\n }\n\n function setAuthConfig(config: AuthConfig) {\n authConfig.value = config\n }\n\n function setUsername(name: string) {\n username.value = name\n }\n\n function setError(message: string | null) {\n error.value = message\n }\n\n function setLoading(loading: boolean) {\n isLoading.value = loading\n }\n\n function logout() {\n clearToken()\n }\n\n return {\n // State\n token,\n tokenExpires,\n username,\n userInfo,\n authConfig,\n isInitialized,\n isLoading,\n error,\n\n // Computed\n isAuthenticated,\n needsAuth,\n isAdmin,\n canRegister,\n\n // Actions\n initialize,\n setToken,\n clearToken,\n setAuthConfig,\n setUsername,\n setUserInfo,\n setError,\n setLoading,\n logout,\n }\n})\n"],"mappings":"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"}
|