@morscherlab/mint-sdk 1.0.0-beta.7 → 1.0.0-rc.2

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.
Files changed (163) hide show
  1. package/README.md +9 -1
  2. package/dist/__tests__/components/LcmsSequenceTable.test.d.ts +1 -0
  3. package/dist/__tests__/components/ProgressBar.test.d.ts +1 -0
  4. package/dist/__tests__/components/RackEditor.test.d.ts +1 -0
  5. package/dist/__tests__/components/SequenceProgressBar.test.d.ts +1 -0
  6. package/dist/__tests__/composables/useExperimentSamples.test.d.ts +1 -0
  7. package/dist/__tests__/composables/useProtocolTemplates.test.d.ts +1 -0
  8. package/dist/__tests__/stores/settings.test.d.ts +1 -0
  9. package/dist/__tests__/utils/instrument.test.d.ts +1 -0
  10. package/dist/__tests__/utils/lcms.test.d.ts +1 -0
  11. package/dist/__tests__/utils/permissions.test.d.ts +1 -0
  12. package/dist/__tests__/utils/rack.test.d.ts +1 -0
  13. package/dist/{auth-QQj2kkze.js → auth-B7g4J4ZF.js} +148 -24
  14. package/dist/auth-B7g4J4ZF.js.map +1 -0
  15. package/dist/components/AutoGroupModal.vue.d.ts +1 -1
  16. package/dist/components/BaseCheckbox.vue.d.ts +1 -1
  17. package/dist/components/BaseToggle.vue.d.ts +2 -2
  18. package/dist/components/BioTemplateExperimentWorkspaceView.vue.d.ts +1 -1
  19. package/dist/components/BioTemplatePackWorkspaceView.vue.d.ts +1 -1
  20. package/dist/components/BioTemplatePresetWorkspaceView.vue.d.ts +1 -1
  21. package/dist/components/DoseDesignWorkspaceView.vue.d.ts +1 -1
  22. package/dist/components/FormulaInput.vue.d.ts +1 -1
  23. package/dist/components/InstrumentAlertLog.vue.d.ts +22 -0
  24. package/dist/components/InstrumentStateBadge.vue.d.ts +11 -0
  25. package/dist/components/InstrumentStatusCard.vue.d.ts +13 -0
  26. package/dist/components/LcmsSequenceTable.vue.d.ts +26 -0
  27. package/dist/components/ProgressBar.vue.d.ts +1 -0
  28. package/dist/components/RackEditor.vue.d.ts +41 -3
  29. package/dist/components/ReagentList.vue.d.ts +1 -1
  30. package/dist/components/SampleSelector.vue.d.ts +5 -2
  31. package/dist/components/SegmentedControl.vue.d.ts +2 -0
  32. package/dist/components/SequenceInput.vue.d.ts +1 -1
  33. package/dist/components/SequenceProgressBar.vue.d.ts +15 -0
  34. package/dist/components/SettingsModal.vue.d.ts +8 -1
  35. package/dist/components/TagsInput.vue.d.ts +1 -1
  36. package/dist/components/WellPlate.vue.d.ts +42 -3
  37. package/dist/components/index.d.ts +5 -0
  38. package/dist/components/index.js +3 -3
  39. package/dist/{components-DihbSJjU.js → components-BhK-dW99.js} +2135 -1075
  40. package/dist/components-BhK-dW99.js.map +1 -0
  41. package/dist/composables/experimentDesignData.d.ts +17 -0
  42. package/dist/composables/index.d.ts +2 -0
  43. package/dist/composables/index.js +4 -4
  44. package/dist/composables/useControlSchema.d.ts +11 -0
  45. package/dist/composables/useExperimentData.d.ts +11 -3
  46. package/dist/composables/useExperimentSamples.d.ts +42 -0
  47. package/dist/composables/usePlatformContext.d.ts +54 -0
  48. package/dist/{composables-BcgZ6diz.js → composables-Bg7CFuNz.js} +5 -3
  49. package/dist/composables-Bg7CFuNz.js.map +1 -0
  50. package/dist/index.d.ts +4 -0
  51. package/dist/index.js +168 -6
  52. package/dist/index.js.map +1 -0
  53. package/dist/install.js +2 -2
  54. package/dist/instrument.d.ts +7 -0
  55. package/dist/lcms.d.ts +27 -0
  56. package/dist/permissions.d.ts +46 -0
  57. package/dist/stores/auth.d.ts +74 -2
  58. package/dist/stores/index.js +1 -1
  59. package/dist/styles.css +3186 -1070
  60. package/dist/templates/builders.d.ts +7 -3
  61. package/dist/templates/index.d.ts +2 -2
  62. package/dist/templates/index.js +2 -2
  63. package/dist/templates/presets.d.ts +12 -0
  64. package/dist/templates/types.d.ts +16 -1
  65. package/dist/{templates-Cyt0Suwf.js → templates-BorLR_7p.js} +324 -10
  66. package/dist/templates-BorLR_7p.js.map +1 -0
  67. package/dist/types/auth.d.ts +2 -0
  68. package/dist/types/components.d.ts +32 -3
  69. package/dist/types/form-builder.d.ts +2 -1
  70. package/dist/types/index.d.ts +4 -1
  71. package/dist/types/instrument.d.ts +56 -0
  72. package/dist/types/platform.d.ts +3 -0
  73. package/dist/{useExperimentData-CM6Y0u5L.js → useProtocolTemplates-n6AJqSqv.js} +627 -380
  74. package/dist/useProtocolTemplates-n6AJqSqv.js.map +1 -0
  75. package/dist/utils/rack.d.ts +47 -0
  76. package/package.json +1 -1
  77. package/src/__tests__/components/AppTopBar.test.ts +15 -0
  78. package/src/__tests__/components/BaseTabs.test.ts +15 -0
  79. package/src/__tests__/components/GroupAssigner.test.ts +18 -0
  80. package/src/__tests__/components/LcmsSequenceTable.test.ts +57 -0
  81. package/src/__tests__/components/ProgressBar.test.ts +18 -0
  82. package/src/__tests__/components/RackEditor.test.ts +125 -0
  83. package/src/__tests__/components/SampleSelector.test.ts +25 -0
  84. package/src/__tests__/components/SegmentedControl.test.ts +45 -0
  85. package/src/__tests__/components/SequenceProgressBar.test.ts +39 -0
  86. package/src/__tests__/components/SettingsModal.test.ts +83 -2
  87. package/src/__tests__/composables/useApi.test.ts +45 -0
  88. package/src/__tests__/composables/useAuth.test.ts +20 -0
  89. package/src/__tests__/composables/useControlSchema.test.ts +4 -0
  90. package/src/__tests__/composables/useExperimentData.test.ts +23 -0
  91. package/src/__tests__/composables/useExperimentSamples.test.ts +91 -0
  92. package/src/__tests__/composables/useProtocolTemplates.test.ts +64 -0
  93. package/src/__tests__/stores/settings.test.ts +78 -0
  94. package/src/__tests__/templates/templates.test.ts +86 -0
  95. package/src/__tests__/utils/instrument.test.ts +47 -0
  96. package/src/__tests__/utils/lcms.test.ts +73 -0
  97. package/src/__tests__/utils/permissions.test.ts +50 -0
  98. package/src/__tests__/utils/rack.test.ts +120 -0
  99. package/src/components/AppAvatarMenu.vue +6 -3
  100. package/src/components/AppTopBar.vue +16 -10
  101. package/src/components/AuditTrail.vue +1 -1
  102. package/src/components/BaseTabs.vue +22 -1
  103. package/src/components/Calendar.vue +6 -2
  104. package/src/components/ConcentrationInput.vue +3 -2
  105. package/src/components/GroupAssigner.vue +8 -3
  106. package/src/components/InstrumentAlertLog.vue +191 -0
  107. package/src/components/InstrumentStateBadge.vue +50 -0
  108. package/src/components/InstrumentStatusCard.vue +188 -0
  109. package/src/components/LcmsSequenceTable.vue +191 -0
  110. package/src/components/NumberInput.vue +5 -3
  111. package/src/components/ProgressBar.vue +3 -0
  112. package/src/components/RackEditor.vue +73 -2
  113. package/src/components/SampleHierarchyTree.vue +3 -2
  114. package/src/components/SampleSelector.vue +28 -9
  115. package/src/components/SegmentedControl.story.vue +17 -0
  116. package/src/components/SegmentedControl.vue +14 -3
  117. package/src/components/SequenceProgressBar.vue +71 -0
  118. package/src/components/SettingsModal.vue +49 -2
  119. package/src/components/UnitInput.vue +6 -2
  120. package/src/components/WellPlate.vue +145 -24
  121. package/src/components/index.ts +5 -0
  122. package/src/components/internal/WellEditPopupInternal.vue +1 -0
  123. package/src/composables/experimentDesignData.ts +182 -0
  124. package/src/composables/index.ts +14 -0
  125. package/src/composables/useApi.ts +113 -16
  126. package/src/composables/useAuth.ts +4 -0
  127. package/src/composables/useAutoGroup.ts +18 -9
  128. package/src/composables/useControlSchema.ts +21 -0
  129. package/src/composables/useExperimentData.ts +57 -16
  130. package/src/composables/useExperimentSamples.ts +142 -0
  131. package/src/composables/useProtocolTemplates.ts +13 -1
  132. package/src/composables/useRackEditor.ts +3 -2
  133. package/src/index.ts +27 -0
  134. package/src/instrument.ts +90 -0
  135. package/src/lcms.ts +108 -0
  136. package/src/permissions.ts +143 -0
  137. package/src/stores/auth.ts +79 -26
  138. package/src/stores/settings.ts +10 -0
  139. package/src/styles/components/instrument-monitor.css +478 -0
  140. package/src/styles/components/lcms-sequence-table.css +189 -0
  141. package/src/styles/components/sequence-progress-bar.css +63 -0
  142. package/src/styles/components/settings-modal.css +9 -0
  143. package/src/styles/components/tabs.css +9 -0
  144. package/src/styles/components/well-edit-popup.css +7 -1
  145. package/src/styles/components/well-plate.css +5 -0
  146. package/src/styles/index.css +3 -0
  147. package/src/templates/builders.ts +201 -0
  148. package/src/templates/controlSchemas.ts +68 -0
  149. package/src/templates/index.ts +2 -0
  150. package/src/templates/presets.ts +23 -0
  151. package/src/templates/types.ts +17 -0
  152. package/src/types/auth.ts +3 -0
  153. package/src/types/components.ts +45 -3
  154. package/src/types/form-builder.ts +2 -1
  155. package/src/types/index.ts +35 -0
  156. package/src/types/instrument.ts +61 -0
  157. package/src/types/platform.ts +4 -0
  158. package/src/utils/rack.ts +209 -0
  159. package/dist/auth-QQj2kkze.js.map +0 -1
  160. package/dist/components-DihbSJjU.js.map +0 -1
  161. package/dist/composables-BcgZ6diz.js.map +0 -1
  162. package/dist/templates-Cyt0Suwf.js.map +0 -1
  163. package/dist/useExperimentData-CM6Y0u5L.js.map +0 -1
@@ -0,0 +1,182 @@
1
+ import type { SelectOption } from '../types'
2
+
3
+ export interface ExperimentDesignDataResponse {
4
+ experiment_id?: number
5
+ plugin_id?: string
6
+ data?: Record<string, unknown>
7
+ schema_version?: string
8
+ updated_at?: string | null
9
+ }
10
+
11
+ export interface ExtractExperimentSamplesOptions {
12
+ includeControls?: boolean
13
+ }
14
+
15
+ function isRecord(value: unknown): value is Record<string, unknown> {
16
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
17
+ }
18
+
19
+ function isPlatformDesignDataResponse(value: Record<string, unknown>): boolean {
20
+ return (
21
+ 'data' in value
22
+ && (
23
+ 'experiment_id' in value
24
+ || 'plugin_id' in value
25
+ || 'schema_version' in value
26
+ || 'updated_at' in value
27
+ )
28
+ )
29
+ }
30
+
31
+ /** Return the plugin-defined design_data payload from common platform and plugin response shapes. */
32
+ export function unwrapExperimentDesignData(
33
+ rawData: Record<string, unknown> | null | undefined,
34
+ ): Record<string, unknown> | null {
35
+ if (!rawData) return null
36
+
37
+ if (isRecord(rawData.data) && isPlatformDesignDataResponse(rawData)) {
38
+ return rawData.data
39
+ }
40
+
41
+ if (isRecord(rawData.design_data)) {
42
+ return rawData.design_data
43
+ }
44
+
45
+ if (isRecord(rawData.designData)) {
46
+ return rawData.designData
47
+ }
48
+
49
+ return rawData
50
+ }
51
+
52
+ function shouldIncludeSample(record: Record<string, unknown>, options: ExtractExperimentSamplesOptions): boolean {
53
+ if (options.includeControls) return true
54
+ const rawType = record.sample_type ?? record.sampleType ?? record.type ?? record.well_type ?? record.wellType
55
+ const type = typeof rawType === 'string' ? rawType.toLowerCase() : ''
56
+ return !['blank', 'qc'].includes(type)
57
+ }
58
+
59
+ function readSampleValue(record: Record<string, unknown>): string | null {
60
+ const candidates = [
61
+ record.sample_name,
62
+ record.sampleName,
63
+ record.sample_id,
64
+ record.sampleId,
65
+ record.name,
66
+ record.id,
67
+ record.label,
68
+ ]
69
+
70
+ for (const candidate of candidates) {
71
+ if (typeof candidate === 'string' && candidate.trim()) {
72
+ return candidate
73
+ }
74
+ if (typeof candidate === 'number' && Number.isFinite(candidate)) {
75
+ return String(candidate)
76
+ }
77
+ }
78
+
79
+ return null
80
+ }
81
+
82
+ function readSampleLabel(record: Record<string, unknown>, fallback: string): string {
83
+ const label = record.name ?? record.label ?? record.sample_name ?? record.sampleName
84
+ return typeof label === 'string' && label.trim() ? label : fallback
85
+ }
86
+
87
+ function readSampleDescription(record: Record<string, unknown>): string | undefined {
88
+ const parts = [
89
+ record.group,
90
+ record.batch,
91
+ record.batch_id,
92
+ record.batchId,
93
+ record.plate_id,
94
+ record.plateId,
95
+ record.well_id,
96
+ record.wellId,
97
+ ].filter((value): value is string | number =>
98
+ (typeof value === 'string' && value.trim().length > 0)
99
+ || typeof value === 'number',
100
+ )
101
+
102
+ return parts.length > 0 ? parts.map(String).join(' / ') : undefined
103
+ }
104
+
105
+ function addSampleRecord(
106
+ options: SelectOption<string>[],
107
+ seen: Set<string>,
108
+ value: unknown,
109
+ extractOptions: ExtractExperimentSamplesOptions,
110
+ ): void {
111
+ if (typeof value === 'string') {
112
+ const trimmed = value.trim()
113
+ if (trimmed && !seen.has(trimmed)) {
114
+ seen.add(trimmed)
115
+ options.push({ value: trimmed, label: trimmed })
116
+ }
117
+ return
118
+ }
119
+
120
+ if (!isRecord(value) || !shouldIncludeSample(value, extractOptions)) return
121
+
122
+ const sampleValue = readSampleValue(value)
123
+ if (!sampleValue || seen.has(sampleValue)) return
124
+
125
+ seen.add(sampleValue)
126
+ options.push({
127
+ value: sampleValue,
128
+ label: readSampleLabel(value, sampleValue),
129
+ description: readSampleDescription(value),
130
+ })
131
+ }
132
+
133
+ function addSampleArray(
134
+ options: SelectOption<string>[],
135
+ seen: Set<string>,
136
+ value: unknown,
137
+ extractOptions: ExtractExperimentSamplesOptions,
138
+ ): void {
139
+ if (!Array.isArray(value)) return
140
+ for (const sample of value) {
141
+ addSampleRecord(options, seen, sample, extractOptions)
142
+ }
143
+ }
144
+
145
+ /** Extract selectable sample options from raw or wrapped experiment design_data. */
146
+ export function extractSampleOptionsFromDesignData(
147
+ rawData: Record<string, unknown> | null | undefined,
148
+ options: ExtractExperimentSamplesOptions = {},
149
+ ): SelectOption<string>[] {
150
+ const designData = unwrapExperimentDesignData(rawData)
151
+ if (!designData) return []
152
+
153
+ const sampleOptions: SelectOption<string>[] = []
154
+ const seen = new Set<string>()
155
+
156
+ addSampleArray(sampleOptions, seen, designData.samples, options)
157
+
158
+ if (isRecord(designData.templates)) {
159
+ for (const template of Object.values(designData.templates)) {
160
+ if (!isRecord(template)) continue
161
+ const templateData = isRecord(template.data) ? template.data : template
162
+ addSampleArray(sampleOptions, seen, templateData.samples, options)
163
+ }
164
+ }
165
+
166
+ if (Array.isArray(designData.plates)) {
167
+ for (const plate of designData.plates) {
168
+ if (!isRecord(plate)) continue
169
+ addSampleArray(sampleOptions, seen, plate.samples, options)
170
+ }
171
+ }
172
+
173
+ return sampleOptions
174
+ }
175
+
176
+ /** Extract SampleSelector-compatible string values from raw or wrapped experiment design_data. */
177
+ export function extractSampleNamesFromDesignData(
178
+ rawData: Record<string, unknown> | null | undefined,
179
+ options: ExtractExperimentSamplesOptions = {},
180
+ ): string[] {
181
+ return extractSampleOptionsFromDesignData(rawData, options).map(sample => sample.value)
182
+ }
@@ -144,6 +144,20 @@ export {
144
144
  type UseExperimentDataOptions,
145
145
  type UseExperimentDataReturn,
146
146
  } from './useExperimentData'
147
+ export {
148
+ extractSampleNamesFromDesignData,
149
+ extractSampleOptionsFromDesignData,
150
+ unwrapExperimentDesignData,
151
+ type ExperimentDesignDataResponse,
152
+ type ExtractExperimentSamplesOptions,
153
+ } from './experimentDesignData'
154
+ export {
155
+ useExperimentSamples,
156
+ type ExperimentDesignDataSource,
157
+ type ExperimentIdSource,
158
+ type UseExperimentSamplesOptions,
159
+ type UseExperimentSamplesReturn,
160
+ } from './useExperimentSamples'
147
161
  export {
148
162
  getFieldRegistryEntry,
149
163
  getTypeDefault,
@@ -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
- if (authStore.token && config.headers && !config.headers.Authorization) {
60
- config.headers.Authorization = `Bearer ${authStore.token}`
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): AxiosRequestConfig {
69
- const base: AxiosRequestConfig = {
70
- baseURL: options.baseUrl ?? settingsStore.getApiBaseUrl(),
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.headers = { ...base.headers, Authorization: undefined }
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 = options.baseUrl ?? settingsStore.getApiBaseUrl()
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
@@ -3,6 +3,7 @@ import { ref, onMounted, onUnmounted, watch, getCurrentInstance, type Ref } from
3
3
  import { useAuthStore } from '../stores/auth'
4
4
  import { useSettingsStore } from '../stores/settings'
5
5
  import type { AuthConfig, UserInfo, LoginResponse, TokenVerifyResponse, UpdateProfileRequest } from '../types'
6
+ import type { RoleInfo } from '../permissions'
6
7
 
7
8
  interface UserResponse {
8
9
  id: string
@@ -10,6 +11,7 @@ interface UserResponse {
10
11
  shortname: string | null
11
12
  email: string | null
12
13
  role: string
14
+ role_obj?: RoleInfo | null
13
15
  is_active: boolean
14
16
  }
15
17
 
@@ -187,6 +189,7 @@ export function useAuth(): UseAuthReturn {
187
189
  shortname: response.data.shortname,
188
190
  email: response.data.email,
189
191
  role: response.data.role,
192
+ roleObj: response.data.role_obj ?? null,
190
193
  isActive: response.data.is_active,
191
194
  }
192
195
 
@@ -379,6 +382,7 @@ export function useAuth(): UseAuthReturn {
379
382
  shortname: response.data.shortname,
380
383
  email: response.data.email,
381
384
  role: response.data.role,
385
+ roleObj: response.data.role_obj ?? null,
382
386
  isActive: response.data.is_active,
383
387
  }
384
388
  authStore.setUserInfo(userInfo)
@@ -9,6 +9,7 @@ import type {
9
9
  ParsedCsvData,
10
10
  } from '../types/auto-group'
11
11
  import type { SampleGroup } from '../types/components'
12
+ import { unwrapExperimentDesignData } from './experimentDesignData'
12
13
 
13
14
  export const DEFAULT_COLORS = [
14
15
  '#3B82F6', '#10B981', '#F59E0B', '#EF4444', '#8B5CF6',
@@ -254,10 +255,12 @@ export function computeGroups(
254
255
  }
255
256
  const groupKey = keyParts.join(' / ')
256
257
 
257
- if (!groupMap.has(groupKey)) {
258
- groupMap.set(groupKey, [])
258
+ const group = groupMap.get(groupKey)
259
+ if (group) {
260
+ group.push(sample)
261
+ } else {
262
+ groupMap.set(groupKey, [sample])
259
263
  }
260
- groupMap.get(groupKey)!.push(sample)
261
264
 
262
265
  // Build metadata row with ALL columns
263
266
  const fields: Record<string, string> = {}
@@ -309,7 +312,10 @@ export function computeGroups(
309
312
  export function extractSamplesFromDesignData(
310
313
  rawData: Record<string, unknown>,
311
314
  ): ParsedCsvData | null {
312
- const samples = rawData.samples
315
+ const designData = unwrapExperimentDesignData(rawData)
316
+ if (!designData) return null
317
+
318
+ const samples = designData.samples
313
319
  if (!Array.isArray(samples) || samples.length === 0) return null
314
320
 
315
321
  // Single pass: filter QC/blank and collect all condition keys
@@ -369,10 +375,12 @@ export function computeGroupsFromCsv(
369
375
  const keyParts = enabledCols.map(col => row[col.originalName ?? col.name])
370
376
  const groupKey = keyParts.join(' / ')
371
377
 
372
- if (!groupMap.has(groupKey)) {
373
- groupMap.set(groupKey, [])
378
+ const group = groupMap.get(groupKey)
379
+ if (group) {
380
+ group.push(sampleName)
381
+ } else {
382
+ groupMap.set(groupKey, [sampleName])
374
383
  }
375
- groupMap.get(groupKey)!.push(sampleName)
376
384
 
377
385
  // Build metadata row with ALL columns — use display name as key, original for lookup
378
386
  const fields: Record<string, string> = {}
@@ -417,8 +425,9 @@ export function useAutoGroup() {
417
425
  )
418
426
 
419
427
  const samples = computed(() => {
420
- if (isTabularMode.value && csvData.value) {
421
- return csvData.value.rows.map(r => r[csvData.value!.sampleColumn])
428
+ const data = csvData.value
429
+ if (isTabularMode.value && data) {
430
+ return data.rows.map(r => r[data.sampleColumn])
422
431
  }
423
432
  return rawText.value
424
433
  .split('\n')
@@ -6,6 +6,7 @@ import type {
6
6
  SettingsModalSchema,
7
7
  TopBarSettingsConfig,
8
8
  } from '../types'
9
+ import type { AccessPolicy, AccessAudienceInput } from '../permissions'
9
10
  import type {
10
11
  FieldCondition,
11
12
  FieldValidation,
@@ -34,6 +35,11 @@ export interface ControlSectionConfig {
34
35
  iconBg?: string
35
36
  columns?: 1 | 2 | 3
36
37
  condition?: FieldCondition
38
+ access?: AccessPolicy
39
+ visibleFor?: AccessAudienceInput
40
+ requiresAdmin?: boolean
41
+ permissions?: readonly string[]
42
+ anyPermissions?: readonly string[]
37
43
  defaultOpen?: boolean
38
44
  showToggle?: boolean
39
45
  }
@@ -70,6 +76,11 @@ export interface ControlDefinition {
70
76
  pattern?: string | { value: string; message: string }
71
77
  validation?: FieldValidation
72
78
  condition?: FieldCondition
79
+ access?: AccessPolicy
80
+ visibleFor?: AccessAudienceInput
81
+ requiresAdmin?: boolean
82
+ permissions?: readonly string[]
83
+ anyPermissions?: readonly string[]
73
84
  options?: readonly ControlOption[]
74
85
  section?: string
75
86
  sectionLabel?: string
@@ -760,6 +771,11 @@ export function controlsToSettingsSchema(
760
771
  fields: sectionControls.map(controlToFormField),
761
772
  columns: config.columns ?? options.columns ?? 1,
762
773
  condition: config.condition,
774
+ access: config.access,
775
+ visibleFor: config.visibleFor,
776
+ requiresAdmin: config.requiresAdmin,
777
+ permissions: config.permissions,
778
+ anyPermissions: config.anyPermissions,
763
779
  }
764
780
  }),
765
781
  }
@@ -1150,6 +1166,11 @@ function controlToFormField(control: NormalizedControl): FormFieldSchema {
1150
1166
  readonly: definition.readonly,
1151
1167
  validation: validationForControl(definition),
1152
1168
  condition: definition.condition,
1169
+ access: definition.access,
1170
+ visibleFor: definition.visibleFor,
1171
+ requiresAdmin: definition.requiresAdmin,
1172
+ permissions: definition.permissions,
1173
+ anyPermissions: definition.anyPermissions,
1153
1174
  colSpan: definition.colSpan,
1154
1175
  props,
1155
1176
  }