diff --git a/medcat-trainer/webapp/frontend/src/App.vue b/medcat-trainer/webapp/frontend/src/App.vue index 6a5e9e063..93b30899f 100644 --- a/medcat-trainer/webapp/frontend/src/App.vue +++ b/medcat-trainer/webapp/frontend/src/App.vue @@ -45,6 +45,11 @@ + + + Your session has expired. Please log in again. + + @@ -60,6 +65,7 @@ import Login from '@/components/common/Login.vue' import EventBus from '@/event-bus' import { isOidcEnabled, getRuntimeConfig } from './runtimeConfig'; import { getMenuItems } from './plugins/registry' +import { UNAUTHORIZED_EVENT, resetUnauthorizedGuard } from './httpAuth' export default { name: 'App', @@ -71,6 +77,7 @@ export default { isAdmin: false, version: '', useOidc: isOidcEnabled(), + sessionExpired: false, } }, computed: { @@ -99,6 +106,8 @@ export default { } }, loginSuccessful () { + this.sessionExpired = false + resetUnauthorizedGuard() if (!this.useOidc) { this.loginModal = false this.uname = this.$cookies.get('username') @@ -110,6 +119,14 @@ export default { this.$router.push({ name: 'home' }) } }, + onUnauthorized () { + // A request was rejected with 401: the stored token is no longer valid + // (e.g. server-side session/token cleared). Prompt the user to log back in. + this.uname = null + this.isAdmin = false + this.sessionExpired = true + this.openLogin() + }, updateOidcUser () { if (this.$keycloak && this.$keycloak.tokenParsed) { this.uname = this.$keycloak.tokenParsed.preferred_username || null @@ -139,6 +156,7 @@ export default { }, mounted () { EventBus.$on('login:success', this.loginSuccessful) + EventBus.$on(UNAUTHORIZED_EVENT, this.onUnauthorized) if (!this.useOidc) { this.uname = this.$cookies.get('username') || null @@ -154,6 +172,7 @@ export default { }, beforeDestroy () { EventBus.$off('login:success', this.loginSuccessful) + EventBus.$off(UNAUTHORIZED_EVENT, this.onUnauthorized) }, created () { this.$http.get('/api/version/').then(resp => { @@ -168,6 +187,16 @@ export default { min-height: 100vh; } +.session-expired-alert { + position: fixed; + top: 64px; + left: 50%; + transform: translateX(-50%); + z-index: 60; + margin: 0; + text-align: center; +} + .header-gradient { background: linear-gradient(135deg, #126cad 0%, #3d0372 50%, #8e1b73 100%); position: fixed; diff --git a/medcat-trainer/webapp/frontend/src/httpAuth.ts b/medcat-trainer/webapp/frontend/src/httpAuth.ts new file mode 100644 index 000000000..3873c04a7 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/httpAuth.ts @@ -0,0 +1,53 @@ +import type { AxiosInstance } from 'axios' +import EventBus from './event-bus' + +/** + * Emitted when an authenticated request is rejected with a 401, i.e. the + * stored token is no longer valid server-side (e.g. after a DB reset / fresh + * deploy or an expired session). Listeners are responsible for prompting the + * user to re-authenticate. + */ +export const UNAUTHORIZED_EVENT = 'auth:unauthorized' + +const AUTH_COOKIES = ['api-token', 'username', 'admin', 'user-id'] + +// Guard so that a burst of parallel requests all returning 401 only triggers a +// single re-login prompt. Reset via resetUnauthorizedGuard() on login success. +let handlingUnauthorized = false + +/** Clear stale auth state and notify the app that re-login is required. */ +export function handleUnauthorized(http: AxiosInstance): void { + if (handlingUnauthorized) { + return + } + handlingUnauthorized = true + + delete http.defaults.headers.common['Authorization'] + for (const name of AUTH_COOKIES) { + document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 GMT; path=/` + } + + EventBus.$emit(UNAUTHORIZED_EVENT) +} + +/** Re-arm the guard once the user has successfully re-authenticated. */ +export function resetUnauthorizedGuard(): void { + handlingUnauthorized = false +} + +/** + * Register a global response interceptor that turns any 401 into a single + * re-login prompt. The traditional login request uses its own axios instance, + * so a wrong-password 401 there is unaffected by this handler. + */ +export function registerUnauthorizedInterceptor(http: AxiosInstance): number { + return http.interceptors.response.use( + response => response, + error => { + if (error?.response?.status === 401) { + handleUnauthorized(http) + } + return Promise.reject(error) + } + ) +} diff --git a/medcat-trainer/webapp/frontend/src/main.ts b/medcat-trainer/webapp/frontend/src/main.ts index e371a307a..f7bc82648 100644 --- a/medcat-trainer/webapp/frontend/src/main.ts +++ b/medcat-trainer/webapp/frontend/src/main.ts @@ -24,6 +24,7 @@ import * as components from 'vuetify/components' import * as directives from 'vuetify/directives' import {authPlugin} from "./auth"; import { loadRuntimeConfig, isOidcEnabled } from './runtimeConfig'; +import { registerUnauthorizedInterceptor } from './httpAuth' import { initPluginBootstrap } from './plugins/bootstrap' import PluginSlot from '@/components/plugins/PluginSlot.vue' @@ -54,6 +55,7 @@ async function bootstrap() { const app = createApp(App) app.config.globalProperties.$http = axios + registerUnauthorizedInterceptor(axios) app.component("v-select", vSelect) app.component('vue-simple-context-menu', VueSimpleContextMenu) app.component('font-awesome-icon', FontAwesomeIcon) diff --git a/medcat-trainer/webapp/frontend/src/tests/httpAuth.spec.ts b/medcat-trainer/webapp/frontend/src/tests/httpAuth.spec.ts new file mode 100644 index 000000000..ce5f12acf --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/httpAuth.spec.ts @@ -0,0 +1,95 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' +import { + UNAUTHORIZED_EVENT, + handleUnauthorized, + resetUnauthorizedGuard, + registerUnauthorizedInterceptor +} from '@/httpAuth' +import EventBus from '@/event-bus' + +// Minimal axios-like stub capturing the registered response interceptor handlers. +const makeHttpStub = () => { + const handlers: { + onFulfilled?: (resp: unknown) => unknown + onRejected?: (error: unknown) => unknown + } = {} + return { + handlers, + defaults: { headers: { common: { Authorization: 'Token abc' } as Record } }, + interceptors: { + response: { + use: (onFulfilled: (resp: unknown) => unknown, onRejected: (error: unknown) => unknown) => { + handlers.onFulfilled = onFulfilled + handlers.onRejected = onRejected + return 1 + } + } + } + } +} + +describe('httpAuth unauthorized handling', () => { + beforeEach(() => { + resetUnauthorizedGuard() + // Seed auth cookies as if the user were logged in. + for (const c of ['api-token', 'username', 'admin', 'user-id']) { + document.cookie = `${c}=value; path=/` + } + }) + + afterEach(() => { + EventBus.$off(UNAUTHORIZED_EVENT) + }) + + it('clears auth state and emits the unauthorized event', () => { + const http = makeHttpStub() + const onEvent = vi.fn() + EventBus.$on(UNAUTHORIZED_EVENT, onEvent) + + handleUnauthorized(http as never) + + expect(http.defaults.headers.common['Authorization']).toBeUndefined() + expect(document.cookie).not.toContain('api-token=value') + expect(document.cookie).not.toContain('username=value') + expect(onEvent).toHaveBeenCalledTimes(1) + }) + + it('only prompts once for a burst of 401s until the guard is reset', () => { + const http = makeHttpStub() + const onEvent = vi.fn() + EventBus.$on(UNAUTHORIZED_EVENT, onEvent) + + handleUnauthorized(http as never) + handleUnauthorized(http as never) + handleUnauthorized(http as never) + expect(onEvent).toHaveBeenCalledTimes(1) + + resetUnauthorizedGuard() + handleUnauthorized(http as never) + expect(onEvent).toHaveBeenCalledTimes(2) + }) + + it('interceptor triggers the handler on a 401 response and re-rejects', async () => { + const http = makeHttpStub() + registerUnauthorizedInterceptor(http as never) + const onEvent = vi.fn() + EventBus.$on(UNAUTHORIZED_EVENT, onEvent) + + const error = { response: { status: 401 } } + await expect(http.handlers.onRejected!(error)).rejects.toBe(error) + expect(onEvent).toHaveBeenCalledTimes(1) + }) + + it('interceptor ignores non-401 errors', async () => { + const http = makeHttpStub() + registerUnauthorizedInterceptor(http as never) + const onEvent = vi.fn() + EventBus.$on(UNAUTHORIZED_EVENT, onEvent) + + const error = { response: { status: 500 } } + await expect(http.handlers.onRejected!(error)).rejects.toBe(error) + expect(onEvent).not.toHaveBeenCalled() + // A successful response passes through untouched. + expect(http.handlers.onFulfilled!({ ok: true })).toEqual({ ok: true }) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/tests/views/TrainAnnotations.spec.ts b/medcat-trainer/webapp/frontend/src/tests/views/TrainAnnotations.spec.ts new file mode 100644 index 000000000..9c7895f01 --- /dev/null +++ b/medcat-trainer/webapp/frontend/src/tests/views/TrainAnnotations.spec.ts @@ -0,0 +1,169 @@ +import { describe, it, expect, vi } from 'vitest' +import { shallowMount, flushPromises } from '@vue/test-utils' +import { createRouter, createWebHistory } from 'vue-router' +import TrainAnnotations from '@/views/TrainAnnotations.vue' + +const routes = [ + { path: '/train-annotations/:projectId/:docId?', name: 'train-annotations', component: TrainAnnotations } +] + +const router = createRouter({ + history: createWebHistory(), + routes +}) + +const project = { + id: 1, + name: 'Example Project', + dataset: 1, + require_entity_validation: true, + validated_documents: [], + prepared_documents: [123], + cdb_search_filter: [], + tasks: [], + relations: [] +} + +// Mount the view without triggering the created() data-fetch cascade: the default +// $http.get returns a promise that never settles so we can drive fetchEntities directly. +const mountView = (getImpl: (url: string) => Promise) => { + const mockGet = vi.fn(getImpl) + const wrapper = shallowMount(TrainAnnotations, { + props: { projectId: 1 }, + global: { + plugins: [router], + mocks: { + $http: { get: mockGet } + } + } + }) + return { wrapper, mockGet } +} + +describe('TrainAnnotations.vue fetchData', () => { + it('surfaces an error modal when the project request fails', async () => { + const { wrapper } = mountView((url) => { + if (url.startsWith('/api/project-annotate-entities/')) { + return Promise.reject({ response: { status: 500, data: { message: 'Database unavailable' } } }) + } + return new Promise(() => {}) + }) + + wrapper.vm.fetchData() + await flushPromises() + + expect(wrapper.vm.errors.modal).toBe(true) + expect(wrapper.vm.errors.message).toBe('Database unavailable') + expect(wrapper.vm.project).toBeNull() + }) + + it('does not show the error modal on 401 (handled globally by httpAuth)', async () => { + const { wrapper } = mountView((url) => { + if (url.startsWith('/api/project-annotate-entities/')) { + return Promise.reject({ response: { status: 401, data: { detail: 'Invalid token.' } } }) + } + return new Promise(() => {}) + }) + + wrapper.vm.fetchData() + await flushPromises() + + expect(wrapper.vm.errors.modal).toBe(false) + }) +}) + +describe('TrainAnnotations.vue fetchEntities', () => { + it('surfaces an error and clears the loading state when annotated-entities fails', async () => { + const { wrapper } = mountView((url) => { + if (url.startsWith('/api/annotated-entities/')) { + return Promise.reject({ response: { data: { message: 'Invalid token.' } } }) + } + // Stall created() lifecycle requests so they don't interfere with the test. + return new Promise(() => {}) + }) + + wrapper.vm.project = project + wrapper.vm.currentDoc = { id: 123, text: 'some clinical text' } + wrapper.vm.loadingMsg = 'Preparing Document...' + + wrapper.vm.fetchEntities() + await flushPromises() + + expect(wrapper.vm.errors.modal).toBe(true) + expect(wrapper.vm.errors.message).toBe('Invalid token.') + // The document must not be left stuck on a perpetual loading state. + expect(wrapper.vm.loadingMsg).toBeNull() + expect(wrapper.vm.nextEntSetUrl).toBeNull() + }) + + it('pages through document sets to reach a deep-linked doc beyond the first batch', async () => { + // 15 single-doc pages: the target (id 12) only appears after the first + // LOAD_NUM_DOC_PAGES (10) batch, exercising the recursive page-advance. + const TOTAL_PAGES = 15 + const TARGET_ID = 12 + const pageUrl = (p: number) => `/api/documents/?dataset=1&page=${p}` + const docsResponse = (page: number) => ({ + data: { + results: [{ id: page, name: `doc-${page}`, text: `text ${page}` }], + count: TOTAL_PAGES, + previous: page === 1 ? null : pageUrl(page - 1), + next: page === TOTAL_PAGES ? null : pageUrl(page + 1) + } + }) + + await router.push({ name: 'train-annotations', params: { projectId: 1, docId: TARGET_ID } }) + + const { wrapper, mockGet } = mountView((url) => { + if (url.startsWith('/api/project-annotate-entities/')) { + return Promise.resolve({ + data: { count: 1, results: [{ ...project, prepared_documents: [TARGET_ID] }] } + }) + } + if (url.startsWith('/api/documents/')) { + // First call (no nextDocSetUrl) requests page 1; subsequent calls carry ?page=N. + const match = url.match(/page=(\d+)/) + const page = match ? Number(match[1]) : 1 + return Promise.resolve(docsResponse(page)) + } + if (url.startsWith('/api/annotated-entities/')) { + return Promise.resolve({ data: { results: [], previous: null, next: null } }) + } + return new Promise(() => {}) + }) + + wrapper.vm.fetchData() + await flushPromises() + + // The deep-linked document is selected and every page was fetched exactly once. + expect(wrapper.vm.currentDoc.id).toBe(TARGET_ID) + const docPageCalls = mockGet.mock.calls.filter(c => String(c[0]).startsWith('/api/documents/')) + expect(docPageCalls).toHaveLength(TOTAL_PAGES) + }) + + it('loads entities and clears the loading state on success', async () => { + const { wrapper } = mountView((url) => { + if (url.startsWith('/api/annotated-entities/')) { + return Promise.resolve({ + data: { + results: [{ id: 10, start_ind: 0, end_ind: 4, validated: 1, correct: 1 }], + previous: null, + next: null + } + }) + } + return new Promise(() => {}) + }) + + wrapper.vm.project = project + wrapper.vm.currentDoc = { id: 123, text: 'some clinical text' } + wrapper.vm.loadingMsg = 'Preparing Document...' + + wrapper.vm.fetchEntities() + await flushPromises() + + expect(wrapper.vm.errors.modal).toBe(false) + expect(wrapper.vm.loadingMsg).toBeNull() + expect(wrapper.vm.ents).toHaveLength(1) + expect(wrapper.vm.currentEnt.id).toBe(10) + }) +}) diff --git a/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue b/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue index f5b6802ca..3b3f668d2 100644 --- a/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue +++ b/medcat-trainer/webapp/frontend/src/views/TrainAnnotations.vue @@ -417,28 +417,51 @@ export default { } else { this.project = resp.data.results[0] this.fetchCDBSearchIndex() + const loadFirstUnvalidatedDoc = () => { + // find first unvalidated doc. + const ids = _.difference(this.docIds, this.project.validated_documents) + if (ids.length > 0) { + this.loadDoc(this.docIdsToDocs[ids[0]]) + } else { + // no unvalidated docs and no next doc URL. Go back to first doc + this.loadDoc(this.docs[0]) + } + } const loadedDocs = () => { this.docIds = this.docs.map(d => d.id) this.docIdsToDocs = Object.assign({}, ...this.docs.map(item => ({[item['id']]: item}))) const docIdRoute = Number(this.$route.params.docId) if (docIdRoute) { - while (!this.docs.map(d => d.id).includes(docIdRoute)) { + if (this.docIdsToDocs[docIdRoute]) { + this.loadDoc(this.docIdsToDocs[docIdRoute]) + } else if (this.nextDocSetUrl) { + // Target document hasn't been paged in yet. Load the next set and + // re-run this check once it resolves - fetchDocuments calls this same + // callback on completion. (Previously a synchronous while-loop here + // span forever, as this.docs only updates in the async response.) this.fetchDocuments(0, loadedDocs) - } - this.loadDoc(this.docIdsToDocs[docIdRoute]) - } else { - // find first unvalidated doc. - const ids = _.difference(this.docIds, this.project.validated_documents) - if (ids.length > 0) { - this.loadDoc(this.docIdsToDocs[ids[0]]) } else { - // no unvalidated docs and no next doc URL. Go back to first doc - this.loadDoc(this.docs[0]) + // docId is not present anywhere in the dataset, fall back to first doc. + loadFirstUnvalidatedDoc() } + } else { + loadFirstUnvalidatedDoc() } } this.fetchDocuments(0, loadedDocs) } + }).catch(err => { + // 401 is handled globally by httpAuth (session-expired banner + login prompt). + if (err.response?.status === 401) { + return + } + this.errors.modal = true + this.errors.message = 'Failed to load project' + if (err.response) { + this.errors.message = err.response.data?.message || err.response.data?.detail || this.errors.message + this.errors.description = err.response.data?.description || '' + this.errors.stacktrace = err.response.data?.stacktrace + } }) }, fetchDocuments(numPagesLoaded, finishedLoading) { @@ -568,6 +591,18 @@ export default { } } } + }).catch(err => { + // Without this, a failed annotated-entities request was swallowed, leaving the + // document stuck on a loading state with no feedback - i.e. the document never loads. + this.nextEntSetUrl = null + this.loadingMsg = null + this.errors.modal = true + this.errors.message = 'Failed to load document annotations. Please try again by refreshing the page.' + if (err.response) { + this.errors.message = err.response.data?.message || this.errors.message + this.errors.description = err.response.data?.description || '' + this.errors.stacktrace = err.response.data?.stacktrace + } }) }, selectEntityFromSummary(entIdx) {