Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions dbrepo-ui/components/dialogs/EditTuple.vue
Original file line number Diff line number Diff line change
Expand Up @@ -316,6 +316,7 @@ export default {
mounted () {
this.fetchContainer()
this.oldTuple = Object.assign({}, this.tuple)
this.restoreDraft()
this.validate()
},
computed: {
Expand All @@ -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')
},
Expand All @@ -352,6 +357,7 @@ export default {
watch: {
tuple: {
handler () {
this.saveDraft()
this.validate()
},
deep: true
Expand All @@ -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) {
Expand Down Expand Up @@ -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
})
Expand Down Expand Up @@ -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
})
Expand Down
42 changes: 19 additions & 23 deletions dbrepo-ui/composables/axios-instance.ts
Original file line number Diff line number Diff line change
@@ -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>(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<void> | null = null;

export const useAxiosInstance = () => {
const config = useRuntimeConfig()
Expand All @@ -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
}
Expand Down
3 changes: 2 additions & 1 deletion dbrepo-ui/locales/en-US.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
72 changes: 72 additions & 0 deletions dbrepo-ui/tests/axios-instance.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
Loading