@morscherlab/mint-sdk 1.3.0-beta.1 → 1.3.0-beta.11

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 (136) hide show
  1. package/README.md +23 -140
  2. package/dist/{ResourcePickerListInternal-c3IT8PIu.js → ResourcePickerListInternal-CFAw4vTZ.js} +26 -19
  3. package/dist/ResourcePickerListInternal-CFAw4vTZ.js.map +1 -0
  4. package/dist/components/FilePicker.vue.d.ts +4 -1
  5. package/dist/components/SettingsModal.schema.d.ts +1 -1
  6. package/dist/components/SettingsModal.vue.d.ts +2 -0
  7. package/dist/components/WellPlate.colors.d.ts +1 -5
  8. package/dist/components/WellPlate.rendering.d.ts +5 -2
  9. package/dist/components/index.d.ts +32 -17
  10. package/dist/components/index.js +3 -3
  11. package/dist/components/internal/FilePickerListInternal.vue.d.ts +2 -0
  12. package/dist/components/internal/ResourcePickerListInternal.vue.d.ts +9 -3
  13. package/dist/{components-CvY105kJ.js → components-vHfxV2PB.js} +425 -392
  14. package/dist/components-vHfxV2PB.js.map +1 -0
  15. package/dist/composables/createFilePickerAdapter.d.ts +5 -1
  16. package/dist/composables/filePicker/usePickerNavigation.d.ts +3 -2
  17. package/dist/composables/index.d.ts +0 -3
  18. package/dist/composables/index.js +4 -4
  19. package/dist/composables/useAutoGroup.d.ts +1 -1
  20. package/dist/composables/useBioTemplateComponents.d.ts +15 -3
  21. package/dist/composables/useBioTemplateControls.d.ts +5 -1
  22. package/dist/composables/useBioTemplatePackWorkspace.d.ts +2 -0
  23. package/dist/composables/useBioTemplatePresetWorkspace.d.ts +5 -1
  24. package/dist/composables/useBioTemplateWorkspace.d.ts +5 -1
  25. package/dist/composables/useExperimentSave.d.ts +5 -1
  26. package/dist/composables/useFilePicker.d.ts +4 -1
  27. package/dist/composables/useGroupAssignment.d.ts +5 -1
  28. package/dist/composables/usePlatformContext.d.ts +3 -0
  29. package/dist/composables/useTemplateCollection.d.ts +5 -1
  30. package/dist/composables-D6uqp0UF.js +591 -0
  31. package/dist/composables-D6uqp0UF.js.map +1 -0
  32. package/dist/index.js +24 -24
  33. package/dist/index.js.map +1 -1
  34. package/dist/install.js +1 -1
  35. package/dist/lcms.d.ts +11 -11
  36. package/dist/styles.css +228 -214
  37. package/dist/templates/index.js +1 -1
  38. package/dist/{templates-Bj9hZ7iV.js → templates-BU1pet28.js} +31 -44
  39. package/dist/templates-BU1pet28.js.map +1 -0
  40. package/dist/types/auth.d.ts +0 -27
  41. package/dist/types/components.d.ts +8 -0
  42. package/dist/types/fileBrowserTypes.d.ts +13 -0
  43. package/dist/types/filePicker.d.ts +8 -1
  44. package/dist/types/index.d.ts +2 -2
  45. package/dist/types/platform.d.ts +2 -0
  46. package/dist/utils/rack.d.ts +16 -16
  47. package/dist/{validation-B4Y8_eEe.js → validation-Dmp9Q5W0.js} +50 -12
  48. package/dist/validation-Dmp9Q5W0.js.map +1 -0
  49. package/package.json +1 -6
  50. package/src/__tests__/components/ActionMenu.test.ts +10 -0
  51. package/src/__tests__/components/AppTopBar.test.ts +24 -8
  52. package/src/__tests__/components/AppTopBarPageSelector.test.ts +4 -4
  53. package/src/__tests__/components/EmptyState.test.ts +15 -0
  54. package/src/__tests__/components/ExperimentSelectorModal.test.ts +12 -5
  55. package/src/__tests__/components/FilePicker.test.ts +65 -1
  56. package/src/__tests__/components/FileUploader.test.ts +6 -9
  57. package/src/__tests__/components/ResourcePickerListInternal.test.ts +23 -4
  58. package/src/__tests__/components/SettingsModal.test.ts +22 -0
  59. package/src/__tests__/components/WellPlate.decoration.test.ts +4 -28
  60. package/src/__tests__/components/WellPlate.rendering.test.ts +20 -33
  61. package/src/__tests__/components/WellPlate.sampleTypeIndicator.test.ts +7 -7
  62. package/src/__tests__/composables/createFilePickerAdapter.test.ts +92 -1
  63. package/src/__tests__/composables/usePickerNavigation.test.ts +61 -1
  64. package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +18 -6
  65. package/src/__tests__/docs/frontendDocsCatalog.test.ts +2 -2
  66. package/src/__tests__/styles/controlRefreshRules.test.ts +21 -8
  67. package/src/__tests__/styles/menusShellRefresh.test.ts +20 -5
  68. package/src/__tests__/templates/templatesDeprecation.test.ts +58 -11
  69. package/src/components/ActionMenu.vue +10 -2
  70. package/src/components/AppTopBar.vue +6 -4
  71. package/src/components/EmptyState.vue +3 -1
  72. package/src/components/ExperimentSelectorModal.vue +4 -1
  73. package/src/components/FilePicker.vue +42 -0
  74. package/src/components/FileUploader.vue +30 -46
  75. package/src/components/SettingsModal.schema.ts +4 -2
  76. package/src/components/SettingsModal.vue +4 -2
  77. package/src/components/WellPlate.colors.ts +6 -10
  78. package/src/components/WellPlate.legend.ts +4 -4
  79. package/src/components/WellPlate.rendering.ts +9 -8
  80. package/src/components/WellPlate.vue +5 -22
  81. package/src/components/index.ts +32 -17
  82. package/src/components/internal/AppTopBarPageSelectorInternal.vue +3 -6
  83. package/src/components/internal/FilePickerListInternal.vue +11 -2
  84. package/src/components/internal/ResourcePickerListInternal.vue +21 -27
  85. package/src/composables/autoGroup/index.ts +1 -1
  86. package/src/composables/createFilePickerAdapter.ts +82 -22
  87. package/src/composables/filePicker/usePickerNavigation.ts +29 -2
  88. package/src/composables/index.ts +0 -7
  89. package/src/composables/useAutoGroup.ts +1 -1
  90. package/src/composables/useBioTemplateComponents.ts +15 -3
  91. package/src/composables/useBioTemplateControls.ts +5 -1
  92. package/src/composables/useBioTemplatePackWorkspace.ts +2 -0
  93. package/src/composables/useBioTemplatePresetWorkspace.ts +5 -1
  94. package/src/composables/useBioTemplateWorkspace.ts +5 -1
  95. package/src/composables/useExperimentSave.ts +5 -1
  96. package/src/composables/useFilePicker.ts +8 -1
  97. package/src/composables/useGroupAssignment.ts +5 -1
  98. package/src/composables/usePlatformFilePickerAdapter.ts +26 -4
  99. package/src/composables/useTemplateCollection.ts +5 -1
  100. package/src/index.ts +1 -1
  101. package/src/lcms.ts +11 -11
  102. package/src/styles/components/action-menu.css +10 -0
  103. package/src/styles/components/app-page-selector.css +61 -17
  104. package/src/styles/components/app-pill-nav.css +1 -1
  105. package/src/styles/components/app-top-bar.css +18 -38
  106. package/src/styles/components/empty-state.css +3 -2
  107. package/src/styles/components/experiment-selector-modal.css +3 -9
  108. package/src/styles/components/file-picker.css +23 -1
  109. package/src/styles/components/file-uploader.css +22 -55
  110. package/src/styles/components/resource-picker-list.css +70 -36
  111. package/src/styles/components/well-plate.css +18 -55
  112. package/src/types/auth.ts +0 -37
  113. package/src/types/components.ts +8 -0
  114. package/src/types/fileBrowserTypes.ts +15 -0
  115. package/src/types/filePicker.ts +8 -1
  116. package/src/types/index.ts +2 -5
  117. package/src/types/platform.ts +2 -0
  118. package/src/utils/rack.ts +16 -16
  119. package/dist/ResourcePickerListInternal-c3IT8PIu.js.map +0 -1
  120. package/dist/__tests__/composables/useAuth.test.d.ts +0 -1
  121. package/dist/__tests__/composables/usePasskey.test.d.ts +0 -1
  122. package/dist/components-CvY105kJ.js.map +0 -1
  123. package/dist/composables/useAuth.d.ts +0 -47
  124. package/dist/composables/usePasskey.d.ts +0 -10
  125. package/dist/composables/useWellPlateEditor.d.ts +0 -33
  126. package/dist/composables-DL4Vca7L.js +0 -1193
  127. package/dist/composables-DL4Vca7L.js.map +0 -1
  128. package/dist/plotly.min-D6Ge6qgX.js +0 -168582
  129. package/dist/plotly.min-D6Ge6qgX.js.map +0 -1
  130. package/dist/templates-Bj9hZ7iV.js.map +0 -1
  131. package/dist/validation-B4Y8_eEe.js.map +0 -1
  132. package/src/__tests__/composables/useAuth.test.ts +0 -290
  133. package/src/__tests__/composables/usePasskey.test.ts +0 -38
  134. package/src/composables/useAuth.ts +0 -470
  135. package/src/composables/usePasskey.ts +0 -216
  136. package/src/composables/useWellPlateEditor.ts +0 -415
@@ -1,470 +0,0 @@
1
- import axios from 'axios'
2
- import { ref, onMounted, onUnmounted, watch, getCurrentInstance, type Ref } from 'vue'
3
- import { useAuthStore } from '../stores/auth'
4
- import { useSettingsStore } from '../stores/settings'
5
- import type { AuthConfig, UserInfo, LoginResponse, TokenVerifyResponse, UpdateProfileRequest } from '../types'
6
- import type { RoleInfo } from '../permissions'
7
-
8
- interface UserResponse {
9
- id: number
10
- username: string
11
- shortname: string | null
12
- email: string | null
13
- role: string
14
- role_obj?: RoleInfo | null
15
- is_active: boolean
16
- // Set by PUT /users/me after a password change, which revokes the old token.
17
- access_token?: string | null
18
- expires_in?: number | null
19
- }
20
-
21
- interface RefreshResponse {
22
- access_token: string
23
- expires_in: number
24
- token_type: string
25
- }
26
-
27
- // Token refresh configuration
28
- const TOKEN_REFRESH_MARGIN_MS = 5 * 60 * 1000 // Refresh 5 minutes before expiry
29
- const TOKEN_REFRESH_CHECK_INTERVAL_MS = 60 * 1000 // Check every minute
30
-
31
- /**
32
- * Authentication composable with automatic token refresh.
33
- *
34
- * Features:
35
- * - Automatic token refresh before expiration
36
- * - Login/logout/register functionality
37
- * - Token verification on startup
38
- * - User profile management
39
- *
40
- * @example
41
- * ```typescript
42
- * const { login, logout, isAuthenticated, user } = useAuth()
43
- *
44
- * // Login
45
- * const success = await login('username', 'password')
46
- *
47
- * // Automatic refresh is enabled by default
48
- * // Tokens are refreshed 5 minutes before expiration
49
- * ```
50
- */
51
- export interface UseAuthReturn {
52
- login: (username: string, password: string) => Promise<boolean>
53
- logout: () => void
54
- register: (username: string, password: string, email?: string) => Promise<boolean>
55
- verifyToken: () => Promise<boolean>
56
- fetchAuthConfig: () => Promise<AuthConfig>
57
- initializeAuth: () => Promise<void>
58
- getCurrentUser: () => Promise<UserInfo | null>
59
- getAuthHeader: () => Record<string, string>
60
- updateProfile: (data: { email?: string; shortname?: string; currentPassword?: string; newPassword?: string }) => Promise<{ success: boolean; error?: string }>
61
- refreshToken: () => Promise<boolean>
62
- isRefreshing: Ref<boolean>
63
- }
64
-
65
- // Module-level singletons to prevent duplicate refresh requests and timers
66
- // across multiple useAuth() instances
67
- let _refreshPromise: Promise<boolean> | null = null
68
- let _refreshTimerId: number | null = null
69
- let _mountedConsumerCount = 0
70
-
71
- /** @internal Reset module-level state — only for use in tests. */
72
- export function _resetAuthStateForTesting(): void {
73
- _refreshPromise = null
74
- if (_refreshTimerId !== null) {
75
- clearTimeout(_refreshTimerId)
76
- _refreshTimerId = null
77
- }
78
- _mountedConsumerCount = 0
79
- }
80
-
81
- /** Manages authentication state with login/logout/register and automatic JWT token refresh. */
82
- export function useAuth(): UseAuthReturn {
83
- const authStore = useAuthStore()
84
- const settingsStore = useSettingsStore()
85
-
86
- const isRefreshing = ref(false)
87
-
88
- function getApiBaseUrl(): string {
89
- return settingsStore.getApiBaseUrl()
90
- }
91
-
92
- async function fetchAuthConfig(): Promise<AuthConfig> {
93
- try {
94
- const response = await axios.get<{
95
- auth_required: boolean
96
- passkey_enabled: boolean
97
- passkey_registered?: boolean
98
- registration_enabled?: boolean
99
- database_mode?: string
100
- access?: {
101
- experiment_visibility_mode?: 'open' | 'restricted'
102
- experimentVisibilityMode?: 'open' | 'restricted'
103
- }
104
- }>(`${getApiBaseUrl()}/setup/config/public`)
105
-
106
- const config: AuthConfig = {
107
- authRequired: response.data.auth_required,
108
- passkeyEnabled: response.data.passkey_enabled,
109
- passkeyRegistered: response.data.passkey_registered ?? false,
110
- registrationEnabled: response.data.registration_enabled ?? false,
111
- databaseMode: response.data.database_mode ?? 'none',
112
- experimentVisibilityMode:
113
- response.data.access?.experiment_visibility_mode
114
- ?? response.data.access?.experimentVisibilityMode
115
- ?? 'open',
116
- }
117
-
118
- authStore.setAuthConfig(config)
119
- return config
120
- } catch (error) {
121
- console.error('Failed to fetch auth config:', error)
122
- return {
123
- authRequired: false,
124
- passkeyEnabled: false,
125
- passkeyRegistered: false,
126
- registrationEnabled: false,
127
- databaseMode: 'none',
128
- experimentVisibilityMode: 'open',
129
- }
130
- }
131
- }
132
-
133
- async function login(username: string, password: string): Promise<boolean> {
134
- authStore.setLoading(true)
135
- authStore.setError(null)
136
-
137
- try {
138
- const response = await axios.post<LoginResponse>(
139
- `${getApiBaseUrl()}/auth/login`,
140
- { username, password }
141
- )
142
-
143
- authStore.setToken(response.data.access_token, response.data.expires_in)
144
- authStore.setUsername(username)
145
-
146
- await getCurrentUser()
147
-
148
- // Start auto-refresh after successful login
149
- scheduleTokenRefresh()
150
-
151
- return true
152
- } catch (error) {
153
- if (axios.isAxiosError(error) && error.response) {
154
- authStore.setError(error.response.data.detail || 'Login failed')
155
- } else {
156
- authStore.setError('Network error. Please try again.')
157
- }
158
- return false
159
- } finally {
160
- authStore.setLoading(false)
161
- }
162
- }
163
-
164
- async function register(username: string, password: string, email?: string): Promise<boolean> {
165
- authStore.setLoading(true)
166
- authStore.setError(null)
167
-
168
- try {
169
- await axios.post<UserResponse>(
170
- `${getApiBaseUrl()}/users/register`,
171
- { username, password, email }
172
- )
173
-
174
- return await login(username, password)
175
- } catch (error) {
176
- if (axios.isAxiosError(error) && error.response) {
177
- authStore.setError(error.response.data.detail || 'Registration failed')
178
- } else {
179
- authStore.setError('Network error. Please try again.')
180
- }
181
- return false
182
- } finally {
183
- authStore.setLoading(false)
184
- }
185
- }
186
-
187
- async function getCurrentUser(): Promise<UserInfo | null> {
188
- if (!authStore.token) {
189
- return null
190
- }
191
-
192
- try {
193
- const response = await axios.get<UserResponse>(
194
- `${getApiBaseUrl()}/users/me`,
195
- { headers: getAuthHeader() }
196
- )
197
-
198
- const userInfo: UserInfo = {
199
- id: response.data.id,
200
- username: response.data.username,
201
- shortname: response.data.shortname,
202
- email: response.data.email,
203
- role: response.data.role,
204
- roleObj: response.data.role_obj ?? null,
205
- isActive: response.data.is_active,
206
- }
207
-
208
- authStore.setUserInfo(userInfo)
209
- return userInfo
210
- } catch {
211
- return null
212
- }
213
- }
214
-
215
- async function verifyToken(): Promise<boolean> {
216
- if (!authStore.token) {
217
- return false
218
- }
219
-
220
- try {
221
- const response = await axios.get<TokenVerifyResponse>(
222
- `${getApiBaseUrl()}/auth/verify`,
223
- {
224
- headers: {
225
- Authorization: `Bearer ${authStore.token}`,
226
- },
227
- }
228
- )
229
-
230
- if (response.data.valid && response.data.username) {
231
- authStore.setUsername(response.data.username)
232
- return true
233
- }
234
-
235
- authStore.clearToken()
236
- return false
237
- } catch {
238
- authStore.clearToken()
239
- return false
240
- }
241
- }
242
-
243
- /**
244
- * Refresh the authentication token.
245
- * Called automatically before token expiration.
246
- * Uses promise caching to prevent concurrent refresh requests.
247
- */
248
- async function refreshToken(): Promise<boolean> {
249
- if (!authStore.token) return false
250
- if (_refreshPromise) return _refreshPromise
251
-
252
- _refreshPromise = (async () => {
253
- isRefreshing.value = true
254
- // A password change can swap in a fresh token while this refresh is in
255
- // flight; its late answer must not replace or clear that token.
256
- const sentToken = authStore.token
257
-
258
- try {
259
- const response = await axios.post<RefreshResponse>(
260
- `${getApiBaseUrl()}/auth/refresh`,
261
- {},
262
- { headers: getAuthHeader() }
263
- )
264
-
265
- if (authStore.token !== sentToken) return true // superseded by a newer token
266
- authStore.setToken(response.data.access_token, response.data.expires_in)
267
-
268
- // Reschedule next refresh
269
- scheduleTokenRefresh()
270
-
271
- return true
272
- } catch (error) {
273
- // If refresh fails, the token may have been revoked
274
- // Clear auth state and let user re-login
275
- if (axios.isAxiosError(error) && error.response?.status === 401 && authStore.token === sentToken) {
276
- console.warn('[Auth] Token refresh failed - session expired')
277
- authStore.clearToken()
278
- stopTokenRefresh()
279
- }
280
- return false
281
- } finally {
282
- isRefreshing.value = false
283
- _refreshPromise = null
284
- }
285
- })()
286
-
287
- return _refreshPromise
288
- }
289
-
290
- /**
291
- * Schedule automatic token refresh before expiration.
292
- */
293
- function scheduleTokenRefresh(): void {
294
- // Clear any existing timer
295
- stopTokenRefresh()
296
-
297
- if (!authStore.tokenExpires) {
298
- return
299
- }
300
-
301
- const expiresAt = authStore.tokenExpires.getTime()
302
- const refreshAt = expiresAt - TOKEN_REFRESH_MARGIN_MS
303
- const now = Date.now()
304
-
305
- if (refreshAt <= now) {
306
- // Token is already close to expiring or expired, refresh now
307
- refreshToken()
308
- return
309
- }
310
-
311
- // Schedule refresh
312
- const delay = refreshAt - now
313
- _refreshTimerId = window.setTimeout(() => {
314
- refreshToken()
315
- }, delay)
316
- }
317
-
318
- /**
319
- * Stop automatic token refresh.
320
- */
321
- function stopTokenRefresh(): void {
322
- if (_refreshTimerId !== null) {
323
- window.clearTimeout(_refreshTimerId)
324
- _refreshTimerId = null
325
- }
326
- }
327
-
328
- /**
329
- * Check if token needs refresh and refresh if necessary.
330
- * Called periodically as a safety net.
331
- */
332
- function checkAndRefreshIfNeeded(): void {
333
- if (!authStore.token || !authStore.tokenExpires) {
334
- return
335
- }
336
-
337
- const expiresAt = authStore.tokenExpires.getTime()
338
- const refreshAt = expiresAt - TOKEN_REFRESH_MARGIN_MS
339
- const now = Date.now()
340
-
341
- if (now >= refreshAt) {
342
- refreshToken()
343
- }
344
- }
345
-
346
- async function initializeAuth(): Promise<void> {
347
- authStore.initialize()
348
- await fetchAuthConfig()
349
-
350
- if (authStore.token) {
351
- const valid = await verifyToken()
352
- if (valid) {
353
- await getCurrentUser()
354
- // Start auto-refresh for existing session
355
- scheduleTokenRefresh()
356
- }
357
- }
358
- }
359
-
360
- function logout(): void {
361
- stopTokenRefresh()
362
- authStore.logout()
363
- }
364
-
365
- function getAuthHeader(): Record<string, string> {
366
- if (authStore.token) {
367
- return { Authorization: `Bearer ${authStore.token}` }
368
- }
369
- return {}
370
- }
371
-
372
- async function updateProfile(data: {
373
- email?: string
374
- shortname?: string
375
- currentPassword?: string
376
- newPassword?: string
377
- }): Promise<{ success: boolean; error?: string }> {
378
- if (!authStore.token) {
379
- return { success: false, error: 'Not authenticated' }
380
- }
381
-
382
- try {
383
- const requestData: UpdateProfileRequest = {}
384
- if (data.email !== undefined) requestData.email = data.email
385
- if (data.shortname !== undefined) requestData.shortname = data.shortname
386
- if (data.currentPassword) requestData.current_password = data.currentPassword
387
- if (data.newPassword) requestData.new_password = data.newPassword
388
-
389
- const response = await axios.put<UserResponse>(
390
- `${getApiBaseUrl()}/users/me`,
391
- requestData,
392
- { headers: getAuthHeader() }
393
- )
394
-
395
- if (response.data.access_token && response.data.expires_in) {
396
- authStore.setToken(response.data.access_token, response.data.expires_in)
397
- scheduleTokenRefresh()
398
- }
399
-
400
- const userInfo: UserInfo = {
401
- id: response.data.id,
402
- username: response.data.username,
403
- shortname: response.data.shortname,
404
- email: response.data.email,
405
- role: response.data.role,
406
- roleObj: response.data.role_obj ?? null,
407
- isActive: response.data.is_active,
408
- }
409
- authStore.setUserInfo(userInfo)
410
-
411
- return { success: true }
412
- } catch (error) {
413
- if (axios.isAxiosError(error) && error.response) {
414
- return { success: false, error: error.response.data.detail || 'Update failed' }
415
- }
416
- return { success: false, error: 'Network error. Please try again.' }
417
- }
418
- }
419
-
420
- // Set up periodic check as safety net (only inside component setup)
421
- let checkInterval: number | null = null
422
-
423
- if (getCurrentInstance()) {
424
- onMounted(() => {
425
- _mountedConsumerCount += 1
426
- checkInterval = window.setInterval(checkAndRefreshIfNeeded, TOKEN_REFRESH_CHECK_INTERVAL_MS)
427
- })
428
-
429
- onUnmounted(() => {
430
- if (checkInterval !== null) {
431
- window.clearInterval(checkInterval)
432
- checkInterval = null
433
- }
434
-
435
- _mountedConsumerCount = Math.max(0, _mountedConsumerCount - 1)
436
- if (_mountedConsumerCount === 0) {
437
- stopTokenRefresh()
438
- }
439
- })
440
-
441
- // Watch for token changes to reschedule refresh
442
- watch(
443
- () => authStore.tokenExpires,
444
- (newExpires) => {
445
- if (newExpires) {
446
- scheduleTokenRefresh()
447
- } else {
448
- stopTokenRefresh()
449
- }
450
- }
451
- )
452
- }
453
-
454
- return {
455
- // Core auth methods
456
- login,
457
- logout,
458
- register,
459
- verifyToken,
460
- fetchAuthConfig,
461
- initializeAuth,
462
- getCurrentUser,
463
- getAuthHeader,
464
- updateProfile,
465
-
466
- // Token refresh
467
- refreshToken,
468
- isRefreshing,
469
- }
470
- }
@@ -1,216 +0,0 @@
1
- import axios from 'axios'
2
- import { useAuthStore } from '../stores/auth'
3
- import { useSettingsStore } from '../stores/settings'
4
- import type { CredentialInfo } from '../types'
5
-
6
- // Lazy-load @simplewebauthn/browser so plugins that never call usePasskey
7
- // don't need the optional peer dep installed (avoids build failures).
8
- async function loadWebAuthn() {
9
- try {
10
- return await import('@simplewebauthn/browser')
11
- } catch {
12
- throw new Error(
13
- '@simplewebauthn/browser is required for passkey support. Install it: bun add @simplewebauthn/browser',
14
- )
15
- }
16
- }
17
-
18
- interface PasskeyLoginResponse {
19
- access_token: string
20
- token_type: string
21
- expires_in: number
22
- }
23
-
24
- /** Registers and authenticates passkeys using WebAuthn, lazily loading the browser dependency. */
25
- export function usePasskey() {
26
- const authStore = useAuthStore()
27
- const settingsStore = useSettingsStore()
28
-
29
- function getApiBaseUrl(): string {
30
- return settingsStore.getApiBaseUrl()
31
- }
32
-
33
- async function isSupported(): Promise<boolean> {
34
- try {
35
- const { browserSupportsWebAuthn } = await loadWebAuthn()
36
- return browserSupportsWebAuthn()
37
- } catch {
38
- return false
39
- }
40
- }
41
-
42
- async function registerPasskey(deviceName?: string): Promise<boolean> {
43
- const webauthn = await loadWebAuthn()
44
- if (!webauthn.browserSupportsWebAuthn()) {
45
- authStore.setError('WebAuthn is not supported in this browser')
46
- return false
47
- }
48
-
49
- authStore.setLoading(true)
50
- authStore.setError(null)
51
-
52
- try {
53
- const optionsResponse = await axios.get<{ options: string }>(
54
- `${getApiBaseUrl()}/auth/passkey/register/options`,
55
- {
56
- headers: { Authorization: `Bearer ${authStore.token}` },
57
- withCredentials: true,
58
- }
59
- )
60
-
61
- const options = JSON.parse(optionsResponse.data.options)
62
-
63
- const credential = await webauthn.startRegistration({ optionsJSON: options })
64
-
65
- await axios.post(
66
- `${getApiBaseUrl()}/auth/passkey/register/verify`,
67
- {
68
- credential: JSON.stringify(credential),
69
- device_name: deviceName,
70
- },
71
- {
72
- headers: { Authorization: `Bearer ${authStore.token}` },
73
- withCredentials: true,
74
- }
75
- )
76
-
77
- authStore.setAuthConfig({
78
- ...authStore.authConfig,
79
- passkeyRegistered: true,
80
- })
81
-
82
- return true
83
- } catch (error) {
84
- if (axios.isAxiosError(error) && error.response) {
85
- authStore.setError(error.response.data.detail || 'Passkey registration failed')
86
- } else if (error instanceof Error) {
87
- if (error.name === 'NotAllowedError') {
88
- authStore.setError('Registration was cancelled or timed out')
89
- } else if (error.name === 'InvalidStateError') {
90
- authStore.setError('This authenticator is already registered')
91
- } else {
92
- authStore.setError(error.message)
93
- }
94
- } else {
95
- authStore.setError('Passkey registration failed')
96
- }
97
- return false
98
- } finally {
99
- authStore.setLoading(false)
100
- }
101
- }
102
-
103
- async function loginWithPasskey(): Promise<boolean> {
104
- const webauthn = await loadWebAuthn()
105
- if (!webauthn.browserSupportsWebAuthn()) {
106
- authStore.setError('WebAuthn is not supported in this browser')
107
- return false
108
- }
109
-
110
- authStore.setLoading(true)
111
- authStore.setError(null)
112
-
113
- try {
114
- const optionsResponse = await axios.get<{ options: string }>(
115
- `${getApiBaseUrl()}/auth/passkey/login/options`,
116
- { withCredentials: true }
117
- )
118
-
119
- const options = JSON.parse(optionsResponse.data.options)
120
-
121
- const credential = await webauthn.startAuthentication({ optionsJSON: options })
122
-
123
- const response = await axios.post<PasskeyLoginResponse>(
124
- `${getApiBaseUrl()}/auth/passkey/login/verify`,
125
- { credential: JSON.stringify(credential) },
126
- { withCredentials: true }
127
- )
128
-
129
- authStore.setToken(response.data.access_token, response.data.expires_in)
130
-
131
- return true
132
- } catch (error) {
133
- if (axios.isAxiosError(error) && error.response) {
134
- if (error.response.status === 404) {
135
- authStore.setError('No passkeys registered. Please login with password first.')
136
- } else {
137
- authStore.setError(error.response.data.detail || 'Passkey login failed')
138
- }
139
- } else if (error instanceof Error) {
140
- if (error.name === 'NotAllowedError') {
141
- authStore.setError('Authentication was cancelled or timed out')
142
- } else {
143
- authStore.setError(error.message)
144
- }
145
- } else {
146
- authStore.setError('Passkey login failed')
147
- }
148
- return false
149
- } finally {
150
- authStore.setLoading(false)
151
- }
152
- }
153
-
154
- async function listCredentials(): Promise<CredentialInfo[]> {
155
- try {
156
- const response = await axios.get<{ credentials: CredentialInfo[] }>(
157
- `${getApiBaseUrl()}/auth/passkey/credentials`,
158
- {
159
- headers: { Authorization: `Bearer ${authStore.token}` },
160
- }
161
- )
162
- return response.data.credentials
163
- } catch {
164
- return []
165
- }
166
- }
167
-
168
- async function deleteCredential(credentialId: string): Promise<boolean> {
169
- try {
170
- await axios.delete(
171
- `${getApiBaseUrl()}/auth/passkey/credentials/${encodeURIComponent(credentialId)}`,
172
- {
173
- headers: { Authorization: `Bearer ${authStore.token}` },
174
- }
175
- )
176
-
177
- const remaining = await listCredentials()
178
- if (remaining.length === 0) {
179
- authStore.setAuthConfig({
180
- ...authStore.authConfig,
181
- passkeyRegistered: false,
182
- })
183
- }
184
-
185
- return true
186
- } catch {
187
- return false
188
- }
189
- }
190
-
191
- async function deleteAllCredentials(): Promise<boolean> {
192
- try {
193
- await axios.delete(`${getApiBaseUrl()}/auth/passkey/credentials`, {
194
- headers: { Authorization: `Bearer ${authStore.token}` },
195
- })
196
-
197
- authStore.setAuthConfig({
198
- ...authStore.authConfig,
199
- passkeyRegistered: false,
200
- })
201
-
202
- return true
203
- } catch {
204
- return false
205
- }
206
- }
207
-
208
- return {
209
- isSupported,
210
- registerPasskey,
211
- loginWithPasskey,
212
- listCredentials,
213
- deleteCredential,
214
- deleteAllCredentials,
215
- }
216
- }