Compare commits

...
Author SHA1 Message Date
Deluan e329667ab0 fix: address review feedback on authProvider
Remove unused vi import from test file. Use Number() || 0 instead of
parseInt with string fallback to handle corrupted sessionStorage values
that would produce NaN.
2026-05-03 13:50:15 -04:00
Deluan d6c5d2487c style: format authProvider test with prettier 2026-05-03 13:35:34 -04:00
Deluan 33f92275f2 fix(ui): handle network errors in checkError for externalized auth
When using externalized authentication (reverse proxy with Authentik,
Authelia, etc.), expired proxy sessions cause CORS-blocked redirects that
surface as TypeErrors instead of HTTP 401s. The existing checkError only
handled status === 401, so these network errors were shown as vague
"NetworkError" notifications instead of triggering re-authentication.

Enhanced checkError to detect network errors when extAuthLogoutURL is
configured and reload the page to let the proxy redirect to the auth
provider. A sessionStorage-based 30-second guard prevents infinite reload
loops. Also extracted an isNetworkError helper to deduplicate the
detection logic already present in the login method.

Added missing removeItem to the localStorage mock in setupTests, and
added comprehensive tests for the new checkError behavior.
2026-05-03 13:35:04 -04:00
3 changed files with 122 additions and 6 deletions

No files matched your search

+19 -6
View File
@@ -26,6 +26,11 @@ function storeAuthenticationInfo(authInfo) {
localStorage.setItem('is-authenticated', 'true')
}
const isNetworkError = (error) => {
const msg = error?.message || ''
return msg === 'Failed to fetch' || msg.includes('NetworkError')
}
const authProvider = {
login: ({ username, password }) => {
let url = baseUrl('/auth/login')
@@ -53,10 +58,7 @@ const authProvider = {
return response
})
.catch((error) => {
if (
error.message === 'Failed to fetch' ||
error.stack === 'TypeError: Failed to fetch'
) {
if (isNetworkError(error)) {
throw new Error('errors.network_error')
}
@@ -78,11 +80,22 @@ const authProvider = {
? Promise.resolve()
: Promise.reject(),
checkError: ({ status }) => {
if (status === 401) {
checkError: (error) => {
if (error?.status === 401) {
removeItems()
return Promise.reject()
}
if (config.extAuthLogoutURL && isNetworkError(error)) {
const now = Date.now()
const lastReload =
Number(sessionStorage.getItem('ext-auth-reload-ts')) || 0
if (now - lastReload > 30000) {
sessionStorage.setItem('ext-auth-reload-ts', String(now))
removeItems()
window.location.reload()
return new Promise(() => {})
}
}
return Promise.resolve()
},
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, beforeEach, afterEach, expect } from 'vitest'
import config from './config'
import authProvider from './authProvider'
describe('authProvider', () => {
beforeEach(() => {
localStorage.clear()
sessionStorage.clear()
localStorage.setItem('is-authenticated', 'true')
localStorage.setItem('token', 'test-token')
localStorage.setItem('userId', 'test-user')
localStorage.setItem('role', 'admin')
config.extAuthLogoutURL = ''
})
afterEach(() => {
config.extAuthLogoutURL = ''
})
describe('checkError', () => {
it('rejects and clears storage on 401', async () => {
await expect(authProvider.checkError({ status: 401 })).rejects.toBe(
undefined,
)
expect(localStorage.getItem('is-authenticated')).toBeNull()
})
it('resolves on non-401 HTTP errors', async () => {
await expect(
authProvider.checkError({ status: 500 }),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('resolves on network error without extAuth configured', async () => {
config.extAuthLogoutURL = ''
await expect(
authProvider.checkError(new TypeError('Failed to fetch')),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('clears auth and sets reload guard on TypeError with extAuth', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
// window.location.reload throws in jsdom, so we catch it
try {
authProvider.checkError(new TypeError('Failed to fetch'))
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
expect(sessionStorage.getItem('ext-auth-reload-ts')).not.toBeNull()
})
it('clears auth on Firefox NetworkError with extAuth', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
const error = new Error('NetworkError when attempting to fetch resource')
try {
authProvider.checkError(error)
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
expect(sessionStorage.getItem('ext-auth-reload-ts')).not.toBeNull()
})
it('does not reload-loop within 30 seconds', async () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
sessionStorage.setItem('ext-auth-reload-ts', String(Date.now() - 10000))
await expect(
authProvider.checkError(new TypeError('Failed to fetch')),
).resolves.toBeUndefined()
expect(localStorage.getItem('is-authenticated')).toBe('true')
})
it('allows reload again after 30 seconds', () => {
config.extAuthLogoutURL = 'https://auth.example.com/logout'
sessionStorage.setItem('ext-auth-reload-ts', String(Date.now() - 31000))
try {
authProvider.checkError(new TypeError('Failed to fetch'))
} catch {
// jsdom "Not implemented: navigation" is expected
}
expect(localStorage.getItem('is-authenticated')).toBeNull()
const ts = parseInt(sessionStorage.getItem('ext-auth-reload-ts'), 10)
expect(Date.now() - ts).toBeLessThan(5000)
})
})
describe('checkAuth', () => {
it('resolves when authenticated', async () => {
await expect(authProvider.checkAuth()).resolves.toBeUndefined()
})
it('rejects when not authenticated', async () => {
localStorage.removeItem('is-authenticated')
await expect(authProvider.checkAuth()).rejects.toBe(undefined)
})
})
})
+3
View File
@@ -14,6 +14,9 @@ const localStorageMock = (function () {
setItem: function (key, value) {
store[key] = value.toString()
},
removeItem: function (key) {
delete store[key]
},
clear: function () {
store = {}
},