Consistently use apiJson for fetches, with timeout and proper error handling (less code duplication).
This commit is contained in:
@@ -5,7 +5,7 @@ import CredentialList from '@/components/CredentialList.vue'
|
||||
import RegistrationLinkModal from '@/components/RegistrationLinkModal.vue'
|
||||
import SessionList from '@/components/SessionList.vue'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiFetch } from '@/utils/api'
|
||||
import { apiJson } from '@/utils/api'
|
||||
|
||||
const props = defineProps({
|
||||
selectedUser: Object,
|
||||
@@ -30,8 +30,7 @@ function handleEditName() {
|
||||
|
||||
async function handleDelete(credential) {
|
||||
try {
|
||||
const res = await apiFetch(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/credentials/${credential.credential_uuid}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
emit('onUserNameSaved') // Reuse to refresh user detail
|
||||
} else {
|
||||
@@ -47,8 +46,7 @@ async function handleTerminateSession(session) {
|
||||
if (!sessionId) return
|
||||
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
|
||||
try {
|
||||
const res = await apiFetch(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
const data = await res.json()
|
||||
const data = await apiJson(`/auth/api/admin/orgs/${props.selectedUser.org_uuid}/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
|
||||
if (data.status === 'ok') {
|
||||
if (data.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
@@ -62,7 +60,7 @@ async function handleTerminateSession(session) {
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Terminate session error', err)
|
||||
authStore.showMessage('Failed to terminate session', 'error')
|
||||
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
|
||||
} finally {
|
||||
const next = { ...terminatingSessions.value }
|
||||
delete next[sessionId]
|
||||
|
||||
@@ -101,7 +101,7 @@ import { useAuthStore } from '@/stores/auth'
|
||||
import { adminUiPath, makeUiHref } from '@/utils/settings'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { goBack } from '@/utils/helpers'
|
||||
import { apiFetch } from '@/utils/api'
|
||||
import { apiJson } from '@/utils/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const updateInterval = ref(null)
|
||||
@@ -174,9 +174,7 @@ const saveName = async () => {
|
||||
if (!name) { authStore.showMessage('Name cannot be empty', 'error'); return }
|
||||
try {
|
||||
saving.value = true
|
||||
const res = await apiFetch('/auth/api/user/display-name', { method: 'PUT', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ display_name: name }) })
|
||||
const data = await res.json()
|
||||
if (!res.ok || data.detail) throw new Error(data.detail || 'Update failed')
|
||||
await apiJson('/auth/api/user/display-name', { method: 'PUT', body: { display_name: name } })
|
||||
showNameDialog.value = false
|
||||
await authStore.loadUserInfo()
|
||||
authStore.showMessage('Name updated successfully!', 'success', 3000)
|
||||
|
||||
@@ -59,7 +59,7 @@ import { ref, onMounted, watch, computed, nextTick } from 'vue'
|
||||
import QRCode from 'qrcode/lib/browser'
|
||||
import { formatDate } from '@/utils/helpers'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import { apiFetch } from '@/utils/api'
|
||||
import { apiJson, getUserFriendlyErrorMessage, shouldShowErrorToast } from '@/utils/api'
|
||||
|
||||
const authStore = useAuthStore()
|
||||
|
||||
@@ -87,9 +87,7 @@ const expirationMessage = computed(() => {
|
||||
|
||||
async function fetchLink() {
|
||||
try {
|
||||
const res = await apiFetch(props.endpoint, { method: 'POST' })
|
||||
const data = await res.json()
|
||||
if (data.detail) throw new Error(data.detail)
|
||||
const data = await apiJson(props.endpoint, { method: 'POST' })
|
||||
url.value = data.url
|
||||
expires.value = data.expires
|
||||
emit('generated', { url: data.url, expires: data.expires })
|
||||
@@ -98,6 +96,9 @@ async function fetchLink() {
|
||||
if (props.autoCopy) copy()
|
||||
} catch (e) {
|
||||
console.error('Failed to create link', e)
|
||||
if (shouldShowErrorToast(e)) {
|
||||
authStore.showMessage(getUserFriendlyErrorMessage(e), 'error', 4000)
|
||||
}
|
||||
// Close the dialog on any error (auth cancelled, network error, etc.)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import passkey from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiJson, getUserFriendlyErrorMessage } from '@/utils/api'
|
||||
|
||||
const props = defineProps({
|
||||
mode: {
|
||||
@@ -120,13 +121,7 @@ async function fetchSettings() {
|
||||
|
||||
async function fetchUserInfo() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/user-info', { method: 'POST' })
|
||||
if (!res.ok) {
|
||||
userInfo.value = null
|
||||
currentView.value = 'login'
|
||||
return
|
||||
}
|
||||
userInfo.value = await res.json()
|
||||
userInfo.value = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
// Determine view based on authentication status
|
||||
if (isAuthenticated.value && props.mode !== 'reauth') {
|
||||
currentView.value = 'forbidden'
|
||||
@@ -136,6 +131,11 @@ async function fetchUserInfo() {
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load user info', error)
|
||||
// For 401/403 just go to login, for other errors show message
|
||||
if (error.status !== 401 && error.status !== 403) {
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
}
|
||||
userInfo.value = null
|
||||
currentView.value = 'login'
|
||||
}
|
||||
}
|
||||
@@ -168,12 +168,14 @@ async function logoutUser() {
|
||||
if (loading.value) return
|
||||
loading.value = true
|
||||
try {
|
||||
await fetch('/auth/api/logout', { method: 'POST' })
|
||||
await apiJson('/auth/api/logout', { method: 'POST' })
|
||||
userInfo.value = null
|
||||
// Switch to login view after logout
|
||||
currentView.value = 'login'
|
||||
showMessage('Logged out. You can sign in with a different account.', 'info', 3000)
|
||||
} catch (_) { /* ignore */ }
|
||||
} catch (error) {
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
}
|
||||
finally { loading.value = false }
|
||||
emit('logout')
|
||||
}
|
||||
@@ -185,16 +187,11 @@ function openProfile() {
|
||||
}
|
||||
|
||||
async function setSessionCookie(sessionToken) {
|
||||
const response = await fetch('/auth/api/set-session', {
|
||||
return await apiJson('/auth/api/set-session', {
|
||||
method: 'POST', headers: { Authorization: `Bearer ${sessionToken}` }
|
||||
})
|
||||
const payload = await safeParseJson(response)
|
||||
if (!response.ok || payload?.detail) throw new Error(payload?.detail || 'Session could not be established.')
|
||||
return payload
|
||||
}
|
||||
|
||||
async function safeParseJson(response) { try { return await response.json() } catch (_) { return null } }
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
await fetchUserInfo()
|
||||
|
||||
+11
-56
@@ -1,7 +1,7 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { register, authenticate } from '@/utils/passkey'
|
||||
import { getSettings } from '@/utils/settings'
|
||||
import { apiFetch } from '@/utils/api'
|
||||
import { apiJson } from '@/utils/api'
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: () => ({
|
||||
@@ -39,14 +39,10 @@ export const useAuthStore = defineStore('auth', {
|
||||
}
|
||||
},
|
||||
async setSessionCookie(sessionToken) {
|
||||
const response = await fetch('/auth/api/set-session', {
|
||||
const result = await apiJson('/auth/api/set-session', {
|
||||
method: 'POST',
|
||||
headers: {'Authorization': `Bearer ${sessionToken}`},
|
||||
})
|
||||
const result = await response.json()
|
||||
if (result.detail) {
|
||||
throw new Error(result.detail)
|
||||
}
|
||||
return result
|
||||
},
|
||||
async register() {
|
||||
@@ -83,42 +79,21 @@ export const useAuthStore = defineStore('auth', {
|
||||
this.settings = await getSettings()
|
||||
},
|
||||
async loadUserInfo() {
|
||||
const response = await apiFetch('/auth/api/user-info', { method: 'POST' })
|
||||
let result = null
|
||||
try {
|
||||
result = await response.json()
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors (unlikely)
|
||||
this.userInfo = await apiJson('/auth/api/user-info', { method: 'POST' })
|
||||
console.log('User info loaded:', this.userInfo)
|
||||
} catch (error) {
|
||||
this.showMessage(error.message || 'Failed to load user info', 'error', 5000)
|
||||
throw error
|
||||
}
|
||||
if (!response.ok || result?.detail) {
|
||||
const message = result?.detail || 'Failed to load user info'
|
||||
this.showMessage(message, 'error', 5000)
|
||||
throw new Error(message)
|
||||
}
|
||||
this.userInfo = result
|
||||
console.log('User info loaded:', result)
|
||||
},
|
||||
async deleteCredential(uuid) {
|
||||
const response = await apiFetch(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||
const result = await response.json()
|
||||
if (!response.ok || result.detail) {
|
||||
throw new Error(result.detail || 'Failed to delete credential')
|
||||
}
|
||||
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
|
||||
await this.loadUserInfo()
|
||||
},
|
||||
async terminateSession(sessionId) {
|
||||
try {
|
||||
const res = await apiFetch(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||
let payload = null
|
||||
try {
|
||||
payload = await res.json()
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
if (!res.ok || payload?.detail) {
|
||||
const message = payload?.detail || 'Failed to terminate session'
|
||||
throw new Error(message)
|
||||
}
|
||||
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
|
||||
if (payload?.current_session_terminated) {
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
@@ -133,17 +108,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logout() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/logout', {method: 'POST'})
|
||||
if (!res.ok) {
|
||||
let message = 'Logout failed'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.detail) message = data.detail
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
await apiJson('/auth/api/logout', {method: 'POST'})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
@@ -153,17 +118,7 @@ export const useAuthStore = defineStore('auth', {
|
||||
},
|
||||
async logoutEverywhere() {
|
||||
try {
|
||||
const res = await fetch('/auth/api/user/logout-all', {method: 'POST'})
|
||||
if (!res.ok) {
|
||||
let message = 'Logout failed'
|
||||
try {
|
||||
const data = await res.json()
|
||||
if (data?.detail) message = data.detail
|
||||
} catch (_) {
|
||||
// ignore JSON parse errors
|
||||
}
|
||||
throw new Error(message)
|
||||
}
|
||||
await apiJson('/auth/api/user/logout-all', {method: 'POST'})
|
||||
sessionStorage.clear()
|
||||
location.reload()
|
||||
} catch (error) {
|
||||
|
||||
@@ -6,6 +6,9 @@
|
||||
* successful authentication.
|
||||
*/
|
||||
|
||||
/** Default timeout for API requests in milliseconds */
|
||||
const DEFAULT_TIMEOUT_MS = 1000
|
||||
|
||||
/**
|
||||
* Custom error class for API errors with full response context.
|
||||
*/
|
||||
@@ -20,6 +23,17 @@ export class ApiError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Custom error class for network/timeout errors.
|
||||
*/
|
||||
export class NetworkError extends Error {
|
||||
constructor(message, originalError = null) {
|
||||
super(message)
|
||||
this.name = 'NetworkError'
|
||||
this.originalError = originalError
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Error thrown when user cancels authentication.
|
||||
*/
|
||||
@@ -139,18 +153,40 @@ if (typeof window !== 'undefined') {
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @param {number} [options.timeout] - Timeout in ms (default: 10000, use 0 to disable)
|
||||
* @returns {Promise<Response>} - The fetch response
|
||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||
* @throws {NetworkError} - If network error or timeout occurs
|
||||
*/
|
||||
export async function apiFetch(url, options = {}) {
|
||||
const { timeout = DEFAULT_TIMEOUT_MS, ...fetchOptions } = options
|
||||
|
||||
// Ensure credentials are included for cookie-based auth
|
||||
const fetchOptions = {
|
||||
...options,
|
||||
credentials: options.credentials || 'include',
|
||||
fetchOptions.credentials = fetchOptions.credentials || 'include'
|
||||
|
||||
// Add timeout signal if specified and not already present
|
||||
if (timeout > 0 && !fetchOptions.signal) {
|
||||
fetchOptions.signal = AbortSignal.timeout(timeout)
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const response = await fetch(url, fetchOptions)
|
||||
let response
|
||||
try {
|
||||
response = await fetch(url, fetchOptions)
|
||||
} catch (error) {
|
||||
// Handle network errors and timeouts
|
||||
if (error.name === 'TimeoutError') {
|
||||
throw new NetworkError('Request timed out', error)
|
||||
}
|
||||
if (error.name === 'AbortError') {
|
||||
// Re-throw abort errors as-is (user-initiated cancellation)
|
||||
throw error
|
||||
}
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
throw new NetworkError('Unable to connect to server', error)
|
||||
}
|
||||
throw new NetworkError(error.message || 'Network error', error)
|
||||
}
|
||||
|
||||
// Check for auth errors (401/403)
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
@@ -182,17 +218,25 @@ export async function apiFetch(url, options = {}) {
|
||||
|
||||
/**
|
||||
* Convenience method for JSON API calls.
|
||||
* Automatically sets Content-Type for POST/PUT/PATCH with body.
|
||||
* Automatically sets Accept and Content-Type headers.
|
||||
* Returns parsed JSON directly if response is ok, throws ApiError otherwise.
|
||||
*
|
||||
* @param {string|URL} url - The URL to fetch
|
||||
* @param {RequestInit} [options] - Fetch options
|
||||
* @returns {Promise<any>} - Parsed JSON response
|
||||
* @throws {ApiError} - If response has error detail or request fails
|
||||
* @throws {ApiError} - If response is not ok
|
||||
* @throws {NetworkError} - If network error or timeout occurs
|
||||
* @throws {AuthCancelledError} - If authentication is cancelled by user
|
||||
*/
|
||||
export async function apiJson(url, options = {}) {
|
||||
const fetchOptions = { ...options }
|
||||
|
||||
// Set default headers, allowing caller overrides
|
||||
fetchOptions.headers = {
|
||||
'Accept': 'application/json',
|
||||
...fetchOptions.headers,
|
||||
}
|
||||
|
||||
// Set Content-Type for requests with JSON body
|
||||
if (fetchOptions.body && typeof fetchOptions.body === 'object' && !(fetchOptions.body instanceof FormData)) {
|
||||
fetchOptions.headers = {
|
||||
@@ -212,6 +256,39 @@ export async function apiJson(url, options = {}) {
|
||||
return data
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert an error to a user-friendly message.
|
||||
* @param {Error} error - The error to convert
|
||||
* @returns {string} - User-friendly error message
|
||||
*/
|
||||
export function getUserFriendlyErrorMessage(error) {
|
||||
if (error instanceof NetworkError) {
|
||||
return error.message
|
||||
}
|
||||
if (error instanceof ApiError) {
|
||||
return error.message
|
||||
}
|
||||
if (error.name === 'TimeoutError') {
|
||||
return 'Request timed out'
|
||||
}
|
||||
if (error.name === 'TypeError' && error.message === 'Failed to fetch') {
|
||||
return 'Unable to connect to server'
|
||||
}
|
||||
return error.message || 'An error occurred'
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error should show a toast to the user.
|
||||
* @param {Error} error - The error to check
|
||||
* @returns {boolean} - Whether to show a toast
|
||||
*/
|
||||
export function shouldShowErrorToast(error) {
|
||||
// Don't show toast for user cancellations
|
||||
if (error instanceof AuthCancelledError) return false
|
||||
if (error.name === 'AbortError') return false
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an API caller with error handling (toast + console.error).
|
||||
* Wraps apiJson calls with consistent error handling for apps.
|
||||
@@ -229,14 +306,13 @@ export function createApiCaller(showMessage) {
|
||||
try {
|
||||
return await apiJson(url, options)
|
||||
} catch (error) {
|
||||
if (error instanceof AuthCancelledError) {
|
||||
// User cancelled - don't show error toast, just re-throw
|
||||
if (!shouldShowErrorToast(error)) {
|
||||
throw error
|
||||
}
|
||||
// Log full error details
|
||||
console.error(`API error for ${url}:`, error instanceof ApiError ? { status: error.status, statusText: error.statusText, data: error.data } : error)
|
||||
// Show user-friendly toast
|
||||
showMessage(error.message || 'An error occurred', 'error')
|
||||
showMessage(getUserFriendlyErrorMessage(error), 'error', 4000)
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user