diff --git a/dbrepo-ui/components/dialogs/EditTuple.vue b/dbrepo-ui/components/dialogs/EditTuple.vue index 81a2f3e12a..16cbec37a7 100644 --- a/dbrepo-ui/components/dialogs/EditTuple.vue +++ b/dbrepo-ui/components/dialogs/EditTuple.vue @@ -316,6 +316,7 @@ export default { mounted () { this.fetchContainer() this.oldTuple = Object.assign({}, this.tuple) + this.restoreDraft() this.validate() }, computed: { @@ -337,6 +338,10 @@ export default { } return this.table.constraints.primary_key.map(pk => pk.column) }, + draftKey () { + const row = this.edit ? this.primaryKeyColumns.map(pk => this.oldTuple[pk.internal_name]) : 'new' + return `dbrepo-tuple-draft:${this.$route.params.database_id}:${this.$route.params.table_id}:${JSON.stringify(row)}` + }, title () { return (this.edit ? this.$t('toolbars.table.data.edit') : this.$t('toolbars.table.data.add')) + ' ' + this.$t('toolbars.table.data.tuple') }, @@ -352,6 +357,7 @@ export default { watch: { tuple: { handler () { + this.saveDraft() this.validate() }, deep: true @@ -362,8 +368,35 @@ export default { console.debug('validate form') this.$refs.form.validate() }, + /* keep unsaved input in this tab so it survives the dialog closing when the session expires and the sign-in redirect */ + saveDraft () { + try { + sessionStorage.setItem(this.draftKey, JSON.stringify(this.tuple)) + } catch (error) { + console.warn('Failed to save tuple draft', error) + } + }, + restoreDraft () { + try { + const draft = sessionStorage.getItem(this.draftKey) + if (draft) { + console.debug('restore tuple draft', this.draftKey) + Object.assign(this.tuple, JSON.parse(draft)) + } + } catch (error) { + console.warn('Failed to restore tuple draft', error) + } + }, + clearDraft () { + try { + sessionStorage.removeItem(this.draftKey) + } catch (error) { + console.warn('Failed to clear tuple draft', error) + } + }, cancel () { this.menu = false + this.clearDraft() this.$emit('close', { success: false }) }, hint (column) { @@ -447,6 +480,7 @@ export default { .then(() => { const toast = useToastInstance() toast.success(this.$t('success.data.update')) + this.clearDraft() this.$emit('close', { success: true }) this.loading = false }) @@ -480,6 +514,7 @@ export default { .then(() => { const toast = useToastInstance() toast.success(this.$t('success.data.add')) + this.clearDraft() this.$emit('close', { success: true }) this.loading = false }) diff --git a/dbrepo-ui/composables/axios-instance.ts b/dbrepo-ui/composables/axios-instance.ts index 31746818e9..d0e9a854bb 100644 --- a/dbrepo-ui/composables/axios-instance.ts +++ b/dbrepo-ui/composables/axios-instance.ts @@ -1,24 +1,10 @@ import axios, {type AxiosInstance} from 'axios' -let instance: AxiosInstance | null = null; - -function tokenToExpiryDate(token: string): number { - if (!token) { - return -1 - } - const exp: number = jwtDecode(token).exp - if (exp) { - return exp * 1000 - } - return -1 -} +/* refresh this many seconds before the access token expires to tolerate clock skew and request latency */ +const EXPIRY_MARGIN_SECONDS = 30 -function isExpiredToken(token: string): boolean { - if (!token) { - return false - } - return tokenToExpiryDate(token) < Date.now() -} +let instance: AxiosInstance | null = null; +let pendingRefresh: Promise | null = null; export const useAxiosInstance = () => { const config = useRuntimeConfig() @@ -34,14 +20,24 @@ export const useAxiosInstance = () => { baseURL: config.public.api.client }); instance.interceptors.request.use(async (config) => { - const { loggedIn, user, canRefresh, refresh } = useOidcAuth() - if (!loggedIn) { + const { loggedIn, user, fetch } = useOidcAuth() + if (!loggedIn.value) { return config } - if (canRefresh) { - await refresh() + if (user.value!.expireAt - EXPIRY_MARGIN_SECONDS <= Date.now() / 1000) { + /* the session endpoint refreshes the token server-side, or clears the session when it cannot; concurrent requests share one call */ + pendingRefresh ??= fetch() + .then(() => { + if (!loggedIn.value) { + useToastInstance().error(useNuxtApp().$i18n.t('error.auth.expired')) + } + }) + .finally(() => { + pendingRefresh = null + }) + await pendingRefresh } - const { accessToken } = user.value + const accessToken = user.value?.accessToken if (!accessToken) { return config } diff --git a/dbrepo-ui/locales/en-US.json b/dbrepo-ui/locales/en-US.json index 12eff9307e..0b245e22c0 100644 --- a/dbrepo-ui/locales/en-US.json +++ b/dbrepo-ui/locales/en-US.json @@ -1230,7 +1230,8 @@ }, "auth": { "connection": "Failed to contact auth service", - "invalid": "Failed to authenticate in auth service" + "invalid": "Failed to authenticate in auth service", + "expired": "Your session has expired. Please sign in again." }, "analyse": { "invalid": "Failed to analyse dataset", diff --git a/dbrepo-ui/tests/axios-instance.test.ts b/dbrepo-ui/tests/axios-instance.test.ts new file mode 100644 index 0000000000..86cfb2140c --- /dev/null +++ b/dbrepo-ui/tests/axios-instance.test.ts @@ -0,0 +1,72 @@ +import {beforeEach, expect, mock, test} from 'bun:test' + +/* stand-ins for the Nuxt auto-imports used by the interceptor, shaped like nuxt-oidc-auth's useOidcAuth() */ +const session: { value: any } = { value: undefined } +const fetch = mock(async () => {}) +const toastError = mock((message: string) => {}) +const g = globalThis as any +g.useRuntimeConfig = () => ({ public: { api: { client: 'http://localhost' } } }) +g.useOidcAuth = () => ({ + loggedIn: { get value() { return Boolean(session.value?.expireAt) } }, + user: { get value() { return session.value } }, + fetch +}) +g.useToastInstance = () => ({ error: toastError }) +g.useNuxtApp = () => ({ $i18n: { t: (key: string) => key } }) + +const { useAxiosInstance } = await import('../composables/axios-instance') +const axios = useAxiosInstance() +/* echo the Authorization header instead of sending the request */ +axios.defaults.adapter = async (config) => + ({ data: config.headers.Authorization ?? null, status: 200, statusText: 'OK', headers: {}, config }) + +const now = () => Math.trunc(Date.now() / 1000) +const authHeaders = async (count: number) => + (await Promise.all(Array.from({ length: count }, () => axios.get('/api/v1/database')))).map(r => r.data) + +beforeEach(() => { + fetch.mockReset() + toastError.mockReset() +}) + +test('valid token is sent without refresh', async () => { + session.value = { expireAt: now() + 300, canRefresh: true, accessToken: 'valid' } + expect(await authHeaders(1)).toEqual(['Bearer valid']) + expect(fetch).not.toHaveBeenCalled() +}) + +test('expired token is refreshed once for concurrent requests', async () => { + session.value = { expireAt: now() - 10, canRefresh: true, accessToken: 'expired' } + fetch.mockImplementation(async () => { + await Bun.sleep(20) + session.value = { expireAt: now() + 300, canRefresh: true, accessToken: 'refreshed' } + }) + expect(await authHeaders(3)).toEqual(['Bearer refreshed', 'Bearer refreshed', 'Bearer refreshed']) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test('token inside the expiry margin is refreshed', async () => { + session.value = { expireAt: now() + 10, canRefresh: true, accessToken: 'expiring' } + fetch.mockImplementation(async () => { + session.value = { expireAt: now() + 300, canRefresh: true, accessToken: 'refreshed' } + }) + expect(await authHeaders(1)).toEqual(['Bearer refreshed']) + expect(fetch).toHaveBeenCalledTimes(1) +}) + +test('failed refresh drops the stale token and asks to sign in once', async () => { + session.value = { expireAt: now() - 10, canRefresh: true, accessToken: 'expired' } + fetch.mockImplementation(async () => { + session.value = {} + }) + expect(await authHeaders(2)).toEqual([null, null]) + expect(fetch).toHaveBeenCalledTimes(1) + expect(toastError).toHaveBeenCalledTimes(1) + expect(toastError).toHaveBeenCalledWith('error.auth.expired') +}) + +test('signed-out requests are sent without token or refresh', async () => { + session.value = {} + expect(await authHeaders(1)).toEqual([null]) + expect(fetch).not.toHaveBeenCalled() +})