@morscherlab/mint-sdk 1.3.0-beta.3 → 1.3.0-beta.5
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/README.md +23 -140
- package/dist/{ResourcePickerListInternal-c3IT8PIu.js → ResourcePickerListInternal-BEXnyBNz.js} +7 -3
- package/dist/ResourcePickerListInternal-BEXnyBNz.js.map +1 -0
- package/dist/components/FilePicker.vue.d.ts +4 -1
- package/dist/components/WellPlate.colors.d.ts +1 -5
- package/dist/components/WellPlate.rendering.d.ts +5 -2
- package/dist/components/index.d.ts +32 -17
- package/dist/components/index.js +3 -3
- package/dist/components/internal/FilePickerListInternal.vue.d.ts +2 -0
- package/dist/{components-CvY105kJ.js → components-BTzcqrJp.js} +400 -358
- package/dist/components-BTzcqrJp.js.map +1 -0
- package/dist/composables/createFilePickerAdapter.d.ts +5 -1
- package/dist/composables/filePicker/usePickerNavigation.d.ts +3 -2
- package/dist/composables/index.d.ts +0 -3
- package/dist/composables/index.js +4 -4
- package/dist/composables/useAutoGroup.d.ts +1 -1
- package/dist/composables/useBioTemplateComponents.d.ts +15 -3
- package/dist/composables/useBioTemplateControls.d.ts +5 -1
- package/dist/composables/useBioTemplatePackWorkspace.d.ts +2 -0
- package/dist/composables/useBioTemplatePresetWorkspace.d.ts +5 -1
- package/dist/composables/useBioTemplateWorkspace.d.ts +5 -1
- package/dist/composables/useExperimentSave.d.ts +5 -1
- package/dist/composables/useFilePicker.d.ts +4 -1
- package/dist/composables/useGroupAssignment.d.ts +5 -1
- package/dist/composables/useTemplateCollection.d.ts +5 -1
- package/dist/composables-Doqb7g1z.js +591 -0
- package/dist/composables-Doqb7g1z.js.map +1 -0
- package/dist/index.js +24 -24
- package/dist/index.js.map +1 -1
- package/dist/install.js +1 -1
- package/dist/lcms.d.ts +11 -11
- package/dist/styles.css +60 -89
- package/dist/templates/index.js +1 -1
- package/dist/{templates-Bj9hZ7iV.js → templates-DgAG5kMq.js} +2 -2
- package/dist/{templates-Bj9hZ7iV.js.map → templates-DgAG5kMq.js.map} +1 -1
- package/dist/types/auth.d.ts +0 -27
- package/dist/types/components.d.ts +4 -0
- package/dist/types/fileBrowserTypes.d.ts +13 -0
- package/dist/types/filePicker.d.ts +8 -1
- package/dist/types/index.d.ts +2 -2
- package/dist/utils/rack.d.ts +16 -16
- package/dist/{validation-B4Y8_eEe.js → validation-DILksa0c.js} +50 -12
- package/dist/validation-DILksa0c.js.map +1 -0
- package/package.json +1 -6
- package/src/__tests__/components/ActionMenu.test.ts +10 -0
- package/src/__tests__/components/AppTopBar.test.ts +16 -8
- package/src/__tests__/components/EmptyState.test.ts +15 -0
- package/src/__tests__/components/FilePicker.test.ts +65 -1
- package/src/__tests__/components/WellPlate.decoration.test.ts +4 -28
- package/src/__tests__/components/WellPlate.rendering.test.ts +20 -33
- package/src/__tests__/components/WellPlate.sampleTypeIndicator.test.ts +7 -7
- package/src/__tests__/composables/createFilePickerAdapter.test.ts +92 -1
- package/src/__tests__/composables/usePickerNavigation.test.ts +61 -1
- package/src/__tests__/composables/usePlatformFilePickerAdapter.test.ts +18 -6
- package/src/__tests__/docs/frontendDocsCatalog.test.ts +2 -2
- package/src/__tests__/styles/controlRefreshRules.test.ts +0 -6
- package/src/__tests__/styles/menusShellRefresh.test.ts +8 -2
- package/src/__tests__/templates/templatesDeprecation.test.ts +51 -11
- package/src/components/ActionMenu.vue +10 -2
- package/src/components/AppTopBar.vue +2 -4
- package/src/components/EmptyState.vue +3 -1
- package/src/components/FilePicker.vue +42 -0
- package/src/components/WellPlate.colors.ts +6 -10
- package/src/components/WellPlate.legend.ts +4 -4
- package/src/components/WellPlate.rendering.ts +9 -8
- package/src/components/WellPlate.vue +5 -22
- package/src/components/index.ts +32 -17
- package/src/components/internal/FilePickerListInternal.vue +11 -2
- package/src/composables/autoGroup/index.ts +1 -1
- package/src/composables/createFilePickerAdapter.ts +82 -22
- package/src/composables/filePicker/usePickerNavigation.ts +29 -2
- package/src/composables/index.ts +0 -7
- package/src/composables/useAutoGroup.ts +1 -1
- package/src/composables/useBioTemplateComponents.ts +15 -3
- package/src/composables/useBioTemplateControls.ts +5 -1
- package/src/composables/useBioTemplatePackWorkspace.ts +2 -0
- package/src/composables/useBioTemplatePresetWorkspace.ts +5 -1
- package/src/composables/useBioTemplateWorkspace.ts +5 -1
- package/src/composables/useExperimentSave.ts +5 -1
- package/src/composables/useFilePicker.ts +8 -1
- package/src/composables/useGroupAssignment.ts +5 -1
- package/src/composables/usePlatformFilePickerAdapter.ts +26 -4
- package/src/composables/useTemplateCollection.ts +5 -1
- package/src/index.ts +1 -1
- package/src/lcms.ts +11 -11
- package/src/styles/components/action-menu.css +10 -0
- package/src/styles/components/app-top-bar.css +7 -31
- package/src/styles/components/empty-state.css +3 -2
- package/src/styles/components/file-picker.css +23 -1
- package/src/styles/components/well-plate.css +18 -55
- package/src/types/auth.ts +0 -37
- package/src/types/components.ts +4 -0
- package/src/types/fileBrowserTypes.ts +15 -0
- package/src/types/filePicker.ts +8 -1
- package/src/types/index.ts +2 -5
- package/src/utils/rack.ts +16 -16
- package/dist/ResourcePickerListInternal-c3IT8PIu.js.map +0 -1
- package/dist/__tests__/composables/useAuth.test.d.ts +0 -1
- package/dist/__tests__/composables/usePasskey.test.d.ts +0 -1
- package/dist/components-CvY105kJ.js.map +0 -1
- package/dist/composables/useAuth.d.ts +0 -47
- package/dist/composables/usePasskey.d.ts +0 -10
- package/dist/composables/useWellPlateEditor.d.ts +0 -33
- package/dist/composables-DL4Vca7L.js +0 -1193
- package/dist/composables-DL4Vca7L.js.map +0 -1
- package/dist/plotly.min-D6Ge6qgX.js +0 -168582
- package/dist/plotly.min-D6Ge6qgX.js.map +0 -1
- package/dist/validation-B4Y8_eEe.js.map +0 -1
- package/src/__tests__/composables/useAuth.test.ts +0 -290
- package/src/__tests__/composables/usePasskey.test.ts +0 -38
- package/src/composables/useAuth.ts +0 -470
- package/src/composables/usePasskey.ts +0 -216
- 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
|
-
}
|