From a7b380cd7aaf5386d87097d3ddbe7e33f714dcad Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 26 Jul 2026 17:11:03 -0500 Subject: [PATCH 1/3] Add pagination to /me/sessions endpoint & sessions account page table --- client/pages/account.vue | 31 ++++++++++++++++++++++++++++-- server/controllers/MeController.js | 17 ++++++++++++---- 2 files changed, 42 insertions(+), 6 deletions(-) diff --git a/client/pages/account.vue b/client/pages/account.vue index 8323772a5..398a21c88 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -98,6 +98,11 @@ +
+ +

{{ $getString('LabelPaginationPageXOfY', [authSessionsPage + 1, authSessionsNumPages]) }}

+ +
@@ -122,6 +127,10 @@ export default { changingPassword: false, loggingOut: false, authSessions: [], + authSessionsPage: 0, + authSessionsNumPages: 0, + authSessionsItemsPerPage: 10, + loadingAuthSessions: false, selectedLanguage: '', newEReaderDevice: { name: '', @@ -285,15 +294,33 @@ export default { ereaderDevicesUpdated(ereaderDevices) { this.ereaderDevices = ereaderDevices }, - loadAuthSessions() { + loadAuthSessions(page = 0) { + if (this.loadingAuthSessions) return + if (page < 0) return + if (this.authSessionsNumPages > 0 && page > this.authSessionsNumPages - 1) return + + this.loadingAuthSessions = true this.$axios - .$get('/api/me/sessions') + .$get(`/api/me/sessions?page=${page}&itemsPerPage=${this.authSessionsItemsPerPage}`) .then((data) => { this.authSessions = data.sessions || [] + this.authSessionsPage = data.page ?? page + this.authSessionsNumPages = data.numPages ?? 0 }) .catch((error) => { console.error('Failed to load sessions', error) }) + .finally(() => { + this.loadingAuthSessions = false + }) + }, + prevAuthSessionsPage() { + if (this.authSessionsPage <= 0) return + this.loadAuthSessions(this.authSessionsPage - 1) + }, + nextAuthSessionsPage() { + if (this.authSessionsPage >= this.authSessionsNumPages - 1) return + this.loadAuthSessions(this.authSessionsPage + 1) }, getSessionDeviceLabel(session) { const deviceInfo = session.deviceInfo diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index b8f378bfa..cdf3034a1 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -35,21 +35,30 @@ class MeController { * @param {Response} res */ async getSessions(req, res) { + const page = Math.max(0, toNumber(req.query.page, 0)) + const itemsPerPage = Math.max(1, toNumber(req.query.itemsPerPage, 10)) + if (req.user.isGuest) { - return res.json({ sessions: [] }) + return res.json({ sessions: [], total: 0, numPages: 0, page, itemsPerPage }) } const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token'] - const sessions = await Database.sessionModel.findAll({ + const { rows, count } = await Database.sessionModel.findAndCountAll({ where: { userId: req.user.id, expiresAt: { [Op.gt]: new Date() } }, - order: [['updatedAt', 'DESC']] + order: [['updatedAt', 'DESC']], + limit: itemsPerPage, + offset: itemsPerPage * page }) res.json({ - sessions: sessions.map((session) => ({ + total: count, + numPages: Math.ceil(count / itemsPerPage), + page, + itemsPerPage, + sessions: rows.map((session) => ({ id: session.id, ipAddress: session.ipAddress, userAgent: session.userAgent, From 1876bfbfe74a9de5d8d99b7c7899c2d83ea1e299 Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 26 Jul 2026 17:36:36 -0500 Subject: [PATCH 2/3] Add delete auth session endpoint to logout individual session --- client/pages/account.vue | 45 ++++++++++++++++++++++++++++-- client/strings/en-us.json | 1 + server/controllers/MeController.js | 34 +++++++++++++++++++++- server/routers/ApiRouter.js | 1 + 4 files changed, 78 insertions(+), 3 deletions(-) diff --git a/client/pages/account.vue b/client/pages/account.vue index 398a21c88..c371a969d 100644 --- a/client/pages/account.vue +++ b/client/pages/account.vue @@ -77,7 +77,8 @@ {{ $strings.LabelDeviceInfo }} {{ $strings.LabelIpAddress }} - {{ $strings.LabelLastUpdate }} + {{ $strings.LabelLastUpdate }} + @@ -91,11 +92,16 @@

{{ session.ipAddress || '-' }}

- +

{{ $dateDistanceFromNow(session.updatedAt) }}

+ +
+ +
+
@@ -131,6 +137,7 @@ export default { authSessionsNumPages: 0, authSessionsItemsPerPage: 10, loadingAuthSessions: false, + deletingSessionId: null, selectedLanguage: '', newEReaderDevice: { name: '', @@ -322,6 +329,40 @@ export default { if (this.authSessionsPage >= this.authSessionsNumPages - 1) return this.loadAuthSessions(this.authSessionsPage + 1) }, + deleteAuthSessionClick(session) { + this.$store.commit('globals/setConfirmPrompt', { + message: this.$getString('MessageConfirmLogoutDevice', [this.getSessionDeviceLabel(session)]), + callback: (confirmed) => { + if (confirmed) this.deleteAuthSession(session) + }, + type: 'yesNo' + }) + }, + deleteAuthSession(session) { + // Call logout instead for current session + if (session.current) { + this.logout(false) + return + } + + this.deletingSessionId = session.id + this.$axios + .$delete(`/api/me/sessions/${session.id}`) + .then(() => { + if (this.authSessions.length === 1 && this.authSessionsPage > 0) { + this.loadAuthSessions(this.authSessionsPage - 1) + } else { + this.loadAuthSessions(this.authSessionsPage) + } + }) + .catch((error) => { + console.error('Failed to delete session', error) + this.$toast.error(this.$strings.ToastFailedToDelete) + }) + .finally(() => { + this.deletingSessionId = null + }) + }, getSessionDeviceLabel(session) { const deviceInfo = session.deviceInfo if (!deviceInfo) return session.userAgent || '-' diff --git a/client/strings/en-us.json b/client/strings/en-us.json index 684927124..d9d6325e4 100644 --- a/client/strings/en-us.json +++ b/client/strings/en-us.json @@ -777,6 +777,7 @@ "MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?", "MessageConfirmForceReScan": "Are you sure you want to force re-scan?", + "MessageConfirmLogoutDevice": "Are you sure you want to log out of \"{0}\"?", "MessageConfirmMarkAllEpisodesFinished": "Are you sure you want to mark all episodes as finished?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?", "MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?", diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index cdf3034a1..73e9fec53 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -4,7 +4,7 @@ const Logger = require('../Logger') const SocketAuthority = require('../SocketAuthority') const Database = require('../Database') const { sort } = require('../libs/fastSort') -const { toNumber, isNullOrNaN } = require('../utils/index') +const { toNumber, isNullOrNaN, isUUID } = require('../utils/index') const userStats = require('../utils/queries/userStats') const parseUserAgent = require('../utils/parsers/parseUserAgent') @@ -71,6 +71,38 @@ class MeController { }) } + /** + * DELETE: /api/me/sessions/:id + * + * @param {RequestWithUser} req + * @param {Response} res + */ + async deleteSession(req, res) { + if (req.user.isGuest) { + return res.sendStatus(403) + } + + if (!isUUID(req.params.id)) { + return res.sendStatus(400) + } + + const session = await Database.sessionModel.findOne({ + where: { + id: req.params.id, + userId: req.user.id + } + }) + + if (!session) { + return res.sendStatus(404) + } + + await Database.sessionModel.destroy({ where: { id: session.id } }) + Logger.info(`[MeController] User ${req.user.username} deleted auth session ${session.id}`) + + res.json({ success: true }) + } + /** * GET: /api/me/progress * diff --git a/server/routers/ApiRouter.js b/server/routers/ApiRouter.js index 3ade851bb..7c89126b2 100644 --- a/server/routers/ApiRouter.js +++ b/server/routers/ApiRouter.js @@ -172,6 +172,7 @@ class ApiRouter { // this.router.get('/me', MeController.getCurrentUser.bind(this)) this.router.get('/me/sessions', MeController.getSessions.bind(this)) + this.router.delete('/me/sessions/:id', MeController.deleteSession.bind(this)) this.router.get('/me/progress', MeController.getAllMediaProgress.bind(this)) this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this)) this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this)) From c86f3365360d6ef41fdb73fbc92a23803d0a16bc Mon Sep 17 00:00:00 2001 From: advplyr Date: Sun, 26 Jul 2026 17:40:14 -0500 Subject: [PATCH 3/3] Update delete session to return status 200 instead of json --- server/controllers/MeController.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/server/controllers/MeController.js b/server/controllers/MeController.js index 73e9fec53..ca600d373 100644 --- a/server/controllers/MeController.js +++ b/server/controllers/MeController.js @@ -100,7 +100,7 @@ class MeController { await Database.sessionModel.destroy({ where: { id: session.id } }) Logger.info(`[MeController] User ${req.user.username} deleted auth session ${session.id}`) - res.json({ success: true }) + res.sendStatus(200) } /**