diff --git a/client/components/app/ConfigSideNav.vue b/client/components/app/ConfigSideNav.vue
index 50fa7a06..32e7e694 100644
--- a/client/components/app/ConfigSideNav.vue
+++ b/client/components/app/ConfigSideNav.vue
@@ -70,6 +70,11 @@ export default {
title: this.$strings.HeaderUsers,
path: '/config/users'
},
+ {
+ id: 'config-api-keys',
+ title: this.$strings.HeaderApiKeys,
+ path: '/config/api-keys'
+ },
{
id: 'config-sessions',
title: this.$strings.HeaderListeningSessions,
diff --git a/client/components/app/LazyBookshelf.vue b/client/components/app/LazyBookshelf.vue
index 61331fb9..854b61b2 100644
--- a/client/components/app/LazyBookshelf.vue
+++ b/client/components/app/LazyBookshelf.vue
@@ -778,10 +778,6 @@ export default {
windowResize() {
this.executeRebuild()
},
- socketInit() {
- // Server settings are set on socket init
- this.executeRebuild()
- },
initListeners() {
window.addEventListener('resize', this.windowResize)
@@ -794,7 +790,6 @@ export default {
})
this.$eventBus.$on('bookshelf_clear_selection', this.clearSelectedEntities)
- this.$eventBus.$on('socket_init', this.socketInit)
this.$eventBus.$on('user-settings', this.settingsUpdated)
if (this.$root.socket) {
@@ -826,7 +821,6 @@ export default {
}
this.$eventBus.$off('bookshelf_clear_selection', this.clearSelectedEntities)
- this.$eventBus.$off('socket_init', this.socketInit)
this.$eventBus.$off('user-settings', this.settingsUpdated)
if (this.$root.socket) {
diff --git a/client/components/cards/AuthorCard.vue b/client/components/cards/AuthorCard.vue
index 82645c57..05347393 100644
--- a/client/components/cards/AuthorCard.vue
+++ b/client/components/cards/AuthorCard.vue
@@ -71,9 +71,6 @@ export default {
coverHeight() {
return this.cardHeight
},
- userToken() {
- return this.store.getters['user/getToken']
- },
_author() {
return this.author || {}
},
diff --git a/client/components/covers/AuthorImage.vue b/client/components/covers/AuthorImage.vue
index e320e552..084492b0 100644
--- a/client/components/covers/AuthorImage.vue
+++ b/client/components/covers/AuthorImage.vue
@@ -39,9 +39,6 @@ export default {
}
},
computed: {
- userToken() {
- return this.$store.getters['user/getToken']
- },
_author() {
return this.author || {}
},
diff --git a/client/components/modals/AccountModal.vue b/client/components/modals/AccountModal.vue
index 71ac8155..6f4b7b67 100644
--- a/client/components/modals/AccountModal.vue
+++ b/client/components/modals/AccountModal.vue
@@ -309,9 +309,9 @@ export default {
} else {
console.log('Account updated', data.user)
- if (data.user.id === this.user.id && data.user.token !== this.user.token) {
- console.log('Current user token was updated')
- this.$store.commit('user/setUserToken', data.user.token)
+ if (data.user.id === this.user.id && data.user.accessToken !== this.user.accessToken) {
+ console.log('Current user access token was updated')
+ this.$store.commit('user/setAccessToken', data.user.accessToken)
}
this.$toast.success(this.$strings.ToastAccountUpdateSuccess)
@@ -351,9 +351,6 @@ export default {
this.$toast.error(errMsg || 'Failed to create account')
})
},
- toggleActive() {
- this.newUser.isActive = !this.newUser.isActive
- },
userTypeUpdated(type) {
this.newUser.permissions = {
download: type !== 'guest',
diff --git a/client/components/modals/ApiKeyCreatedModal.vue b/client/components/modals/ApiKeyCreatedModal.vue
new file mode 100644
index 00000000..96442a17
--- /dev/null
+++ b/client/components/modals/ApiKeyCreatedModal.vue
@@ -0,0 +1,60 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/client/components/modals/ApiKeyModal.vue b/client/components/modals/ApiKeyModal.vue
new file mode 100644
index 00000000..b347abd0
--- /dev/null
+++ b/client/components/modals/ApiKeyModal.vue
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
diff --git a/client/components/modals/Modal.vue b/client/components/modals/Modal.vue
index a7d9c0ae..31ea1e61 100644
--- a/client/components/modals/Modal.vue
+++ b/client/components/modals/Modal.vue
@@ -23,7 +23,7 @@ export default {
processing: Boolean,
persistent: {
type: Boolean,
- default: true
+ default: false
},
width: {
type: [String, Number],
@@ -99,7 +99,7 @@ export default {
this.preventClickoutside = false
return
}
- if (this.processing && this.persistent) return
+ if (this.processing || this.persistent) return
if (ev.srcElement && ev.srcElement.classList.contains('modal-bg')) {
this.show = false
}
diff --git a/client/components/modals/item/tabs/Files.vue b/client/components/modals/item/tabs/Files.vue
index 7be286fe..15c44261 100644
--- a/client/components/modals/item/tabs/Files.vue
+++ b/client/components/modals/item/tabs/Files.vue
@@ -29,9 +29,6 @@ export default {
media() {
return this.libraryItem.media || {}
},
- userToken() {
- return this.$store.getters['user/getToken']
- },
userCanUpdate() {
return this.$store.getters['user/getUserCanUpdate']
},
diff --git a/client/components/player/PlayerUi.vue b/client/components/player/PlayerUi.vue
index 82d53552..f929943c 100644
--- a/client/components/player/PlayerUi.vue
+++ b/client/components/player/PlayerUi.vue
@@ -129,9 +129,6 @@ export default {
return `${hoursRounded}h`
}
},
- token() {
- return this.$store.getters['user/getToken']
- },
timeRemaining() {
if (this.useChapterTrack && this.currentChapter) {
var currChapTime = this.currentTime - this.currentChapter.start
diff --git a/client/components/readers/ComicReader.vue b/client/components/readers/ComicReader.vue
index 28d79bf2..fce26939 100644
--- a/client/components/readers/ComicReader.vue
+++ b/client/components/readers/ComicReader.vue
@@ -104,9 +104,6 @@ export default {
}
},
computed: {
- userToken() {
- return this.$store.getters['user/getToken']
- },
libraryItemId() {
return this.libraryItem?.id
},
@@ -234,10 +231,7 @@ export default {
async extract() {
this.loading = true
var buff = await this.$axios.$get(this.ebookUrl, {
- responseType: 'blob',
- headers: {
- Authorization: `Bearer ${this.userToken}`
- }
+ responseType: 'blob'
})
const archive = await Archive.open(buff)
const originalFilesObject = await archive.getFilesObject()
diff --git a/client/components/readers/EpubReader.vue b/client/components/readers/EpubReader.vue
index 350d8596..ac8e3397 100644
--- a/client/components/readers/EpubReader.vue
+++ b/client/components/readers/EpubReader.vue
@@ -57,9 +57,6 @@ export default {
}
},
computed: {
- userToken() {
- return this.$store.getters['user/getToken']
- },
/** @returns {string} */
libraryItemId() {
return this.libraryItem?.id
@@ -97,9 +94,9 @@ export default {
},
ebookUrl() {
if (this.fileId) {
- return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook/${this.fileId}`
+ return `/api/items/${this.libraryItemId}/ebook/${this.fileId}`
}
- return `${this.$config.routerBasePath}/api/items/${this.libraryItemId}/ebook`
+ return `/api/items/${this.libraryItemId}/ebook`
},
themeRules() {
const isDark = this.ereaderSettings.theme === 'dark'
@@ -309,14 +306,24 @@ export default {
/** @type {EpubReader} */
const reader = this
+ // Use axios to make request because we have token refresh logic in interceptor
+ const customRequest = async (url) => {
+ try {
+ return this.$axios.$get(url, {
+ responseType: 'arraybuffer'
+ })
+ } catch (error) {
+ console.error('EpubReader.initEpub customRequest failed:', error)
+ throw error
+ }
+ }
+
/** @type {ePub.Book} */
reader.book = new ePub(reader.ebookUrl, {
width: this.readerWidth,
height: this.readerHeight - 50,
openAs: 'epub',
- requestHeaders: {
- Authorization: `Bearer ${this.userToken}`
- }
+ requestMethod: customRequest
})
/** @type {ePub.Rendition} */
@@ -337,29 +344,33 @@ export default {
this.applyTheme()
})
- reader.book.ready.then(() => {
- // set up event listeners
- reader.rendition.on('relocated', reader.relocated)
- reader.rendition.on('keydown', reader.keyUp)
+ reader.book.ready
+ .then(() => {
+ // set up event listeners
+ reader.rendition.on('relocated', reader.relocated)
+ reader.rendition.on('keydown', reader.keyUp)
- reader.rendition.on('touchstart', (event) => {
- this.$emit('touchstart', event)
- })
- reader.rendition.on('touchend', (event) => {
- this.$emit('touchend', event)
- })
-
- // load ebook cfi locations
- const savedLocations = this.loadLocations()
- if (savedLocations) {
- reader.book.locations.load(savedLocations)
- } else {
- reader.book.locations.generate().then(() => {
- this.checkSaveLocations(reader.book.locations.save())
+ reader.rendition.on('touchstart', (event) => {
+ this.$emit('touchstart', event)
})
- }
- this.getChapters()
- })
+ reader.rendition.on('touchend', (event) => {
+ this.$emit('touchend', event)
+ })
+
+ // load ebook cfi locations
+ const savedLocations = this.loadLocations()
+ if (savedLocations) {
+ reader.book.locations.load(savedLocations)
+ } else {
+ reader.book.locations.generate().then(() => {
+ this.checkSaveLocations(reader.book.locations.save())
+ })
+ }
+ this.getChapters()
+ })
+ .catch((error) => {
+ console.error('EpubReader.initEpub failed:', error)
+ })
},
getChapters() {
// Load the list of chapters in the book. See https://github.com/futurepress/epub.js/issues/759
diff --git a/client/components/readers/MobiReader.vue b/client/components/readers/MobiReader.vue
index 3e784f77..459ae55b 100644
--- a/client/components/readers/MobiReader.vue
+++ b/client/components/readers/MobiReader.vue
@@ -26,9 +26,6 @@ export default {
return {}
},
computed: {
- userToken() {
- return this.$store.getters['user/getToken']
- },
libraryItemId() {
return this.libraryItem?.id
},
@@ -96,11 +93,8 @@ export default {
},
async initMobi() {
// Fetch mobi file as blob
- var buff = await this.$axios.$get(this.ebookUrl, {
- responseType: 'blob',
- headers: {
- Authorization: `Bearer ${this.userToken}`
- }
+ const buff = await this.$axios.$get(this.ebookUrl, {
+ responseType: 'blob'
})
var reader = new FileReader()
reader.onload = async (event) => {
diff --git a/client/components/readers/PdfReader.vue b/client/components/readers/PdfReader.vue
index c05f459c..d9459d76 100644
--- a/client/components/readers/PdfReader.vue
+++ b/client/components/readers/PdfReader.vue
@@ -55,7 +55,8 @@ export default {
loadedRatio: 0,
page: 1,
numPages: 0,
- pdfDocInitParams: null
+ pdfDocInitParams: null,
+ isRefreshing: false
}
},
computed: {
@@ -152,7 +153,34 @@ export default {
this.page++
this.updateProgress()
},
- error(err) {
+ async refreshToken() {
+ if (this.isRefreshing) return
+ this.isRefreshing = true
+ const newAccessToken = await this.$store.dispatch('user/refreshToken').catch((error) => {
+ console.error('Failed to refresh token', error)
+ return null
+ })
+ if (!newAccessToken) {
+ // Redirect to login on failed refresh
+ this.$router.push('/login')
+ return
+ }
+
+ // Force Vue to re-render the PDF component by creating a new object
+ this.pdfDocInitParams = {
+ url: this.ebookUrl,
+ httpHeaders: {
+ Authorization: `Bearer ${newAccessToken}`
+ }
+ }
+ this.isRefreshing = false
+ },
+ async error(err) {
+ if (err && err.status === 401) {
+ console.log('Received 401 error, refreshing token')
+ await this.refreshToken()
+ return
+ }
console.error(err)
},
resize() {
diff --git a/client/components/readers/Reader.vue b/client/components/readers/Reader.vue
index c2e5986e..a7a5ac3d 100644
--- a/client/components/readers/Reader.vue
+++ b/client/components/readers/Reader.vue
@@ -266,9 +266,6 @@ export default {
isComic() {
return this.ebookFormat == 'cbz' || this.ebookFormat == 'cbr'
},
- userToken() {
- return this.$store.getters['user/getToken']
- },
keepProgress() {
return this.$store.state.ereaderKeepProgress
},
diff --git a/client/components/tables/ApiKeysTable.vue b/client/components/tables/ApiKeysTable.vue
new file mode 100644
index 00000000..feab4e68
--- /dev/null
+++ b/client/components/tables/ApiKeysTable.vue
@@ -0,0 +1,177 @@
+
+
+
+
+
+ {{ $strings.LabelName }} |
+ {{ $strings.LabelApiKeyUser }} |
+ {{ $strings.LabelExpiresAt }} |
+ {{ $strings.LabelCreatedAt }} |
+ |
+
+
+
+
+ |
+
+
+ {{ apiKey.user.username }}
+
+ Error
+ |
+
+ {{ getExpiresAtText(apiKey) }}
+ {{ $strings.LabelExpiresNever }}
+ |
+
+
+ {{ $formatJsDate(new Date(apiKey.createdAt), dateFormat) }}
+
+ |
+
+
+
+
+
+
+
+
+
+ |
+
+
+
{{ $strings.LabelNoApiKeys }}
+
+
+
+
+
+
+
diff --git a/client/components/tables/EbookFilesTable.vue b/client/components/tables/EbookFilesTable.vue
index cc968acd..3ce9d30f 100644
--- a/client/components/tables/EbookFilesTable.vue
+++ b/client/components/tables/EbookFilesTable.vue
@@ -49,9 +49,6 @@ export default {
libraryItemId() {
return this.libraryItem.id
},
- userToken() {
- return this.$store.getters['user/getToken']
- },
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
diff --git a/client/components/tables/LibraryFilesTable.vue b/client/components/tables/LibraryFilesTable.vue
index 9be7e249..6f6e74b8 100644
--- a/client/components/tables/LibraryFilesTable.vue
+++ b/client/components/tables/LibraryFilesTable.vue
@@ -53,9 +53,6 @@ export default {
libraryItemId() {
return this.libraryItem.id
},
- userToken() {
- return this.$store.getters['user/getToken']
- },
userCanDownload() {
return this.$store.getters['user/getUserCanDownload']
},
diff --git a/client/components/ui/MultiSelectQueryInput.vue b/client/components/ui/MultiSelectQueryInput.vue
index 6de16bf2..18abc66e 100644
--- a/client/components/ui/MultiSelectQueryInput.vue
+++ b/client/components/ui/MultiSelectQueryInput.vue
@@ -85,9 +85,6 @@ export default {
this.$emit('input', val)
}
},
- userToken() {
- return this.$store.getters['user/getToken']
- },
wrapperClass() {
var classes = []
if (this.disabled) classes.push('bg-black-300')
diff --git a/client/components/ui/SelectInput.vue b/client/components/ui/SelectInput.vue
index 9e0961c1..f38414ac 100644
--- a/client/components/ui/SelectInput.vue
+++ b/client/components/ui/SelectInput.vue
@@ -1,9 +1,9 @@
-
{{ label }}
+
{{ label }}
@@ -21,6 +21,7 @@ export default {
type: String,
default: 'text'
},
+ min: [String, Number],
readonly: Boolean,
disabled: Boolean,
inputClass: String,
diff --git a/client/layouts/default.vue b/client/layouts/default.vue
index 33e7aa15..9f15af67 100644
--- a/client/layouts/default.vue
+++ b/client/layouts/default.vue
@@ -33,6 +33,7 @@ export default {
return {
socket: null,
isSocketConnected: false,
+ isSocketAuthenticated: false,
isFirstSocketConnection: true,
socketConnectionToastId: null,
currentLang: null,
@@ -81,9 +82,28 @@ export default {
document.body.classList.add('app-bar')
}
},
+ tokenRefreshed(newAccessToken) {
+ if (this.isSocketConnected && !this.isSocketAuthenticated) {
+ console.log('[SOCKET] Re-authenticating socket after token refresh')
+ this.socket.emit('auth', newAccessToken)
+ }
+ },
updateSocketConnectionToast(content, type, timeout) {
if (this.socketConnectionToastId !== null && this.socketConnectionToastId !== undefined) {
- this.$toast.update(this.socketConnectionToastId, { content: content, options: { timeout: timeout, type: type, closeButton: false, position: 'bottom-center', onClose: () => null, closeOnClick: timeout !== null } }, false)
+ const toastUpdateOptions = {
+ content: content,
+ options: {
+ timeout: timeout,
+ type: type,
+ closeButton: false,
+ position: 'bottom-center',
+ onClose: () => {
+ this.socketConnectionToastId = null
+ },
+ closeOnClick: timeout !== null
+ }
+ }
+ this.$toast.update(this.socketConnectionToastId, toastUpdateOptions, false)
} else {
this.socketConnectionToastId = this.$toast[type](content, { position: 'bottom-center', timeout: timeout, closeButton: false, closeOnClick: timeout !== null })
}
@@ -109,7 +129,7 @@ export default {
this.updateSocketConnectionToast(this.$strings.ToastSocketDisconnected, 'error', null)
},
reconnect() {
- console.error('[SOCKET] reconnected')
+ console.log('[SOCKET] reconnected')
},
reconnectAttempt(val) {
console.log(`[SOCKET] reconnect attempt ${val}`)
@@ -120,6 +140,10 @@ export default {
reconnectFailed() {
console.error('[SOCKET] reconnect failed')
},
+ authFailed(payload) {
+ console.error('[SOCKET] auth failed', payload.message)
+ this.isSocketAuthenticated = false
+ },
init(payload) {
console.log('Init Payload', payload)
@@ -127,7 +151,7 @@ export default {
this.$store.commit('users/setUsersOnline', payload.usersOnline)
}
- this.$eventBus.$emit('socket_init')
+ this.isSocketAuthenticated = true
},
streamOpen(stream) {
if (this.$refs.mediaPlayerContainer) this.$refs.mediaPlayerContainer.streamOpen(stream)
@@ -354,6 +378,15 @@ export default {
this.$store.commit('scanners/removeCustomMetadataProvider', provider)
},
initializeSocket() {
+ if (this.$root.socket) {
+ // Can happen in dev due to hot reload
+ console.warn('Socket already initialized')
+ this.socket = this.$root.socket
+ this.isSocketConnected = this.$root.socket?.connected
+ this.isFirstSocketConnection = false
+ this.socketConnectionToastId = null
+ return
+ }
this.socket = this.$nuxtSocket({
name: process.env.NODE_ENV === 'development' ? 'dev' : 'prod',
persist: 'main',
@@ -364,6 +397,7 @@ export default {
path: `${this.$config.routerBasePath}/socket.io`
})
this.$root.socket = this.socket
+ this.isSocketAuthenticated = false
console.log('Socket initialized')
// Pre-defined socket events
@@ -377,6 +411,7 @@ export default {
// Event received after authorizing socket
this.socket.on('init', this.init)
+ this.socket.on('auth_failed', this.authFailed)
// Stream Listeners
this.socket.on('stream_open', this.streamOpen)
@@ -571,6 +606,7 @@ export default {
this.updateBodyClass()
this.resize()
this.$eventBus.$on('change-lang', this.changeLanguage)
+ this.$eventBus.$on('token_refreshed', this.tokenRefreshed)
window.addEventListener('resize', this.resize)
window.addEventListener('keydown', this.keyDown)
@@ -594,6 +630,7 @@ export default {
},
beforeDestroy() {
this.$eventBus.$off('change-lang', this.changeLanguage)
+ this.$eventBus.$off('token_refreshed', this.tokenRefreshed)
window.removeEventListener('resize', this.resize)
window.removeEventListener('keydown', this.keyDown)
}
diff --git a/client/nuxt.config.js b/client/nuxt.config.js
index f54d1cf4..7219c784 100644
--- a/client/nuxt.config.js
+++ b/client/nuxt.config.js
@@ -73,7 +73,8 @@ module.exports = {
// Axios module configuration: https://go.nuxtjs.dev/config-axios
axios: {
- baseURL: routerBasePath
+ baseURL: routerBasePath,
+ progress: false
},
// nuxt/pwa https://pwa.nuxtjs.org
diff --git a/client/pages/account.vue b/client/pages/account.vue
index b157f570..e9b5da3c 100644
--- a/client/pages/account.vue
+++ b/client/pages/account.vue
@@ -182,18 +182,19 @@ export default {
password: this.password,
newPassword: this.newPassword
})
- .then((res) => {
- if (res.success) {
- this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
- this.resetForm()
- } else {
- this.$toast.error(res.error || this.$strings.ToastUnknownError)
- }
- this.changingPassword = false
+ .then(() => {
+ this.$toast.success(this.$strings.ToastUserPasswordChangeSuccess)
+ this.resetForm()
})
.catch((error) => {
- console.error(error)
- this.$toast.error(this.$strings.ToastUnknownError)
+ console.error('Failed to change password', error)
+ let errorMessage = this.$strings.ToastUnknownError
+ if (error.response?.data && typeof error.response.data === 'string') {
+ errorMessage = error.response.data
+ }
+ this.$toast.error(errorMessage)
+ })
+ .finally(() => {
this.changingPassword = false
})
},
diff --git a/client/pages/config.vue b/client/pages/config.vue
index 5fa145e5..c4fe2446 100644
--- a/client/pages/config.vue
+++ b/client/pages/config.vue
@@ -53,6 +53,7 @@ export default {
else if (pageName === 'sessions') return this.$strings.HeaderListeningSessions
else if (pageName === 'stats') return this.$strings.HeaderYourStats
else if (pageName === 'users') return this.$strings.HeaderUsers
+ else if (pageName === 'api-keys') return this.$strings.HeaderApiKeys
else if (pageName === 'item-metadata-utils') return this.$strings.HeaderItemMetadataUtils
else if (pageName === 'rss-feeds') return this.$strings.HeaderRSSFeeds
else if (pageName === 'email') return this.$strings.HeaderEmail
diff --git a/client/pages/config/api-keys/index.vue b/client/pages/config/api-keys/index.vue
new file mode 100644
index 00000000..2523feed
--- /dev/null
+++ b/client/pages/config/api-keys/index.vue
@@ -0,0 +1,84 @@
+
+
+
+
+
+ {{ numApiKeys }}
+
+
+
+
+ help_outline
+
+
+
+
+
+ {{ $strings.ButtonAddApiKey }}
+
+
+ (numApiKeys = count)" />
+
+
+
+
+
+
+
diff --git a/client/pages/config/users/_id/index.vue b/client/pages/config/users/_id/index.vue
index f0e6647f..b48147d3 100644
--- a/client/pages/config/users/_id/index.vue
+++ b/client/pages/config/users/_id/index.vue
@@ -13,8 +13,8 @@
{{ username }}
-
-
+
+
@@ -100,9 +100,12 @@ export default {
}
},
computed: {
- userToken() {
+ legacyToken() {
return this.user.token
},
+ userToken() {
+ return this.user.accessToken
+ },
bookCoverAspectRatio() {
return this.$store.getters['libraries/getBookCoverAspectRatio']
},
diff --git a/client/pages/login.vue b/client/pages/login.vue
index 3f48509f..01adadcd 100644
--- a/client/pages/login.vue
+++ b/client/pages/login.vue
@@ -40,6 +40,15 @@
{{ error }}
+
+