Consistently use apiJson for fetches, with timeout and proper error handling (less code duplication).

This commit is contained in:
Leo Vasanko
2025-12-03 13:00:24 -06:00
parent ceb99de738
commit 4306323c44
9 changed files with 173 additions and 194 deletions
+4 -6
View File
@@ -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]
+2 -4
View File
@@ -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')
}
+12 -15
View File
@@ -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
View File
@@ -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) {
+85 -9
View File
@@ -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
}
}