Merge pull request #5405 from advplyr/auth_sessions_enhancements
Some checks are pending
CodeQL / Analyze (push) Waiting to run
Run Component Tests / Run Component Tests (push) Waiting to run
Build and Push Docker Image / build (push) Waiting to run
Verify all i18n files are alphabetized / update_translations (push) Waiting to run
Integration Test / build and test (push) Waiting to run
Run Unit Tests / Run Unit Tests (push) Waiting to run

Delete auth session endpoint & paginate sessions
This commit is contained in:
advplyr 2026-07-26 18:49:25 -04:00 committed by GitHub
commit 4dfad46722
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 120 additions and 9 deletions

View file

@ -77,7 +77,8 @@
<tr> <tr>
<th class="text-left">{{ $strings.LabelDeviceInfo }}</th> <th class="text-left">{{ $strings.LabelDeviceInfo }}</th>
<th class="text-left hidden sm:table-cell w-36">{{ $strings.LabelIpAddress }}</th> <th class="text-left hidden sm:table-cell w-36">{{ $strings.LabelIpAddress }}</th>
<th class="text-left w-32 sm:w-40">{{ $strings.LabelLastUpdate }}</th> <th class="text-left w-30 sm:w-40">{{ $strings.LabelLastUpdate }}</th>
<th class="w-12"></th>
</tr> </tr>
<tr v-for="session in authSessions" :key="session.id"> <tr v-for="session in authSessions" :key="session.id">
<td class="max-w-0"> <td class="max-w-0">
@ -91,13 +92,23 @@
<td class="hidden sm:table-cell w-36"> <td class="hidden sm:table-cell w-36">
<p class="text-sm text-gray-100 truncate" :title="session.ipAddress">{{ session.ipAddress || '-' }}</p> <p class="text-sm text-gray-100 truncate" :title="session.ipAddress">{{ session.ipAddress || '-' }}</p>
</td> </td>
<td class="w-32 sm:w-40"> <td class="w-30 sm:w-40">
<ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)"> <ui-tooltip v-if="session.updatedAt" direction="top" :text="$formatDatetime(session.updatedAt, dateFormat, timeFormat)">
<p class="text-xs sm:text-sm text-gray-100">{{ $dateDistanceFromNow(session.updatedAt) }}</p> <p class="text-xs sm:text-sm text-gray-100">{{ $dateDistanceFromNow(session.updatedAt) }}</p>
</ui-tooltip> </ui-tooltip>
</td> </td>
<td class="w-12">
<div class="flex justify-end items-center h-10">
<ui-icon-btn icon="delete" borderless :size="8" icon-font-size="1.1rem" :disabled="deletingSessionId === session.id || loadingAuthSessions" @click="deleteAuthSessionClick(session)" />
</div>
</td>
</tr> </tr>
</table> </table>
<div v-if="authSessionsNumPages > 1" class="flex items-center justify-end py-1">
<ui-icon-btn icon="arrow_back_ios_new" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage === 0" @click="prevAuthSessionsPage" />
<p class="text-sm mx-1">{{ $getString('LabelPaginationPageXOfY', [authSessionsPage + 1, authSessionsNumPages]) }}</p>
<ui-icon-btn icon="arrow_forward_ios" :size="7" icon-font-size="1rem" class="mx-1" :disabled="loadingAuthSessions || authSessionsPage >= authSessionsNumPages - 1" @click="nextAuthSessionsPage" />
</div>
</app-settings-content> </app-settings-content>
</div> </div>
@ -122,6 +133,11 @@ export default {
changingPassword: false, changingPassword: false,
loggingOut: false, loggingOut: false,
authSessions: [], authSessions: [],
authSessionsPage: 0,
authSessionsNumPages: 0,
authSessionsItemsPerPage: 10,
loadingAuthSessions: false,
deletingSessionId: null,
selectedLanguage: '', selectedLanguage: '',
newEReaderDevice: { newEReaderDevice: {
name: '', name: '',
@ -285,15 +301,67 @@ export default {
ereaderDevicesUpdated(ereaderDevices) { ereaderDevicesUpdated(ereaderDevices) {
this.ereaderDevices = 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 this.$axios
.$get('/api/me/sessions') .$get(`/api/me/sessions?page=${page}&itemsPerPage=${this.authSessionsItemsPerPage}`)
.then((data) => { .then((data) => {
this.authSessions = data.sessions || [] this.authSessions = data.sessions || []
this.authSessionsPage = data.page ?? page
this.authSessionsNumPages = data.numPages ?? 0
}) })
.catch((error) => { .catch((error) => {
console.error('Failed to load sessions', 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)
},
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) { getSessionDeviceLabel(session) {
const deviceInfo = session.deviceInfo const deviceInfo = session.deviceInfo

View file

@ -777,6 +777,7 @@
"MessageConfirmDeleteSession": "Are you sure you want to delete this session?", "MessageConfirmDeleteSession": "Are you sure you want to delete this session?",
"MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?", "MessageConfirmEmbedMetadataInAudioFiles": "Are you sure you want to embed metadata in {0} audio files?",
"MessageConfirmForceReScan": "Are you sure you want to force re-scan?", "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?", "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?", "MessageConfirmMarkAllEpisodesNotFinished": "Are you sure you want to mark all episodes as not finished?",
"MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?", "MessageConfirmMarkItemFinished": "Are you sure you want to mark \"{0}\" as finished?",

View file

@ -4,7 +4,7 @@ const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority') const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database') const Database = require('../Database')
const { sort } = require('../libs/fastSort') 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 userStats = require('../utils/queries/userStats')
const parseUserAgent = require('../utils/parsers/parseUserAgent') const parseUserAgent = require('../utils/parsers/parseUserAgent')
@ -35,21 +35,30 @@ class MeController {
* @param {Response} res * @param {Response} res
*/ */
async getSessions(req, 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) { 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 refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
const sessions = await Database.sessionModel.findAll({ const { rows, count } = await Database.sessionModel.findAndCountAll({
where: { where: {
userId: req.user.id, userId: req.user.id,
expiresAt: { [Op.gt]: new Date() } expiresAt: { [Op.gt]: new Date() }
}, },
order: [['updatedAt', 'DESC']] order: [['updatedAt', 'DESC']],
limit: itemsPerPage,
offset: itemsPerPage * page
}) })
res.json({ res.json({
sessions: sessions.map((session) => ({ total: count,
numPages: Math.ceil(count / itemsPerPage),
page,
itemsPerPage,
sessions: rows.map((session) => ({
id: session.id, id: session.id,
ipAddress: session.ipAddress, ipAddress: session.ipAddress,
userAgent: session.userAgent, userAgent: session.userAgent,
@ -62,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.sendStatus(200)
}
/** /**
* GET: /api/me/progress * GET: /api/me/progress
* *

View file

@ -172,6 +172,7 @@ class ApiRouter {
// //
this.router.get('/me', MeController.getCurrentUser.bind(this)) this.router.get('/me', MeController.getCurrentUser.bind(this))
this.router.get('/me/sessions', MeController.getSessions.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/progress', MeController.getAllMediaProgress.bind(this))
this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this)) this.router.get('/me/bookmarks', MeController.getAllBookmarks.bind(this))
this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this)) this.router.get('/me/bookmarks/:libraryItemId', MeController.getBookmarksForLibraryItem.bind(this))