Merge pull request #5400 from advplyr/account_sessions_table
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

Account sessions table
This commit is contained in:
advplyr 2026-07-25 18:43:26 -04:00 committed by GitHub
commit e9ea511c90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 131 additions and 2 deletions

View file

@ -1,6 +1,6 @@
<template>
<div id="page-wrapper" class="page p-6 overflow-y-auto relative" :class="streamLibraryItem ? 'streaming' : ''">
<div class="w-full max-w-xl mx-auto">
<div class="w-full max-w-2xl mx-auto">
<h1 class="text-2xl">{{ $strings.HeaderAccount }}</h1>
<div class="my-4">
@ -69,9 +69,41 @@
</app-settings-content>
</div>
<div v-if="!isGuest">
<div class="w-full h-px bg-white/10 my-4" />
<app-settings-content :header-text="$strings.HeaderSessions">
<table v-if="authSessions.length" class="tracksTable mt-4 table-fixed w-full">
<tr>
<th class="text-left">{{ $strings.LabelDeviceInfo }}</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>
</tr>
<tr v-for="session in authSessions" :key="session.id">
<td class="max-w-0">
<div class="flex items-center gap-0.5 min-w-0">
<span class="text-sm text-gray-100 truncate min-w-0" :title="session.userAgent">{{ getSessionDeviceLabel(session) }}</span>
<ui-tooltip v-if="session.current" direction="top" :text="$strings.LabelCurrent" class="inline-flex shrink-0">
<span class="material-symbols text-success text-sm leading-none">check</span>
</ui-tooltip>
</div>
</td>
<td class="hidden sm:table-cell w-36">
<p class="text-sm text-gray-100 truncate" :title="session.ipAddress">{{ session.ipAddress || '-' }}</p>
</td>
<td class="w-32 sm:w-40">
<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>
</ui-tooltip>
</td>
</tr>
</table>
</app-settings-content>
</div>
<div class="py-4 mt-8 flex flex-wrap gap-2">
<ui-btn v-if="!isGuest" color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(true)"> <span class="material-symbols mr-4 icon-text">devices</span>{{ $strings.ButtonLogoutAllDevices }} </ui-btn>
<ui-btn color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
<ui-btn color="bg-primary flex items-center text-lg" :disabled="loggingOut" @click="logout(false)"><span class="material-symbols mr-4 icon-text">logout</span>{{ $strings.ButtonLogout }}</ui-btn>
</div>
<modals-emails-user-e-reader-device-modal v-model="showEReaderDeviceModal" :existing-devices="revisedEreaderDevices" :ereader-device="selectedEReaderDevice" @update="ereaderDevicesUpdated" />
@ -89,6 +121,7 @@ export default {
confirmPassword: null,
changingPassword: false,
loggingOut: false,
authSessions: [],
selectedLanguage: '',
newEReaderDevice: {
name: '',
@ -131,6 +164,12 @@ export default {
},
revisedEreaderDevices() {
return this.ereaderDevices.filter((device) => device.users?.length === 1)
},
dateFormat() {
return this.$store.getters['getServerSetting']('dateFormat')
},
timeFormat() {
return this.$store.getters['getServerSetting']('timeFormat')
}
},
methods: {
@ -245,11 +284,34 @@ export default {
},
ereaderDevicesUpdated(ereaderDevices) {
this.ereaderDevices = ereaderDevices
},
loadAuthSessions() {
this.$axios
.$get('/api/me/sessions')
.then((data) => {
this.authSessions = data.sessions || []
})
.catch((error) => {
console.error('Failed to load sessions', error)
})
},
getSessionDeviceLabel(session) {
const deviceInfo = session.deviceInfo
if (!deviceInfo) return session.userAgent || '-'
const parts = []
if (deviceInfo.model) parts.push(deviceInfo.model)
if (deviceInfo.osName) parts.push(`${deviceInfo.osName}${deviceInfo.osVersion ? ` ${deviceInfo.osVersion}` : ''}`)
if (deviceInfo.browserName) parts.push(deviceInfo.browserName)
return parts.join(' / ') || session.userAgent || '-'
}
},
mounted() {
this.selectedLanguage = this.$languageCodes.current
this.ereaderDevices = this.$store.state.libraries.ereaderDevices || []
if (!this.isGuest) {
this.loadAuthSessions()
}
}
}
</script>

View file

@ -195,6 +195,7 @@
"HeaderScheduleEpisodeDownloads": "Schedule Automatic Episode Downloads",
"HeaderScheduleLibraryScans": "Schedule Automatic Library Scans",
"HeaderSession": "Session",
"HeaderSessions": "Sessions",
"HeaderSetBackupSchedule": "Set Backup Schedule",
"HeaderSettings": "Settings",
"HeaderSettingsDisplay": "Display",
@ -416,6 +417,7 @@
"LabelIntervalEveryHour": "Every hour",
"LabelIntervalEveryMinute": "Every minute",
"LabelInvert": "Invert",
"LabelIpAddress": "IP Address",
"LabelItem": "Item",
"LabelJumpBackwardAmount": "Jump backward amount",
"LabelJumpForwardAmount": "Jump forward amount",

View file

@ -1,10 +1,12 @@
const { Request, Response } = require('express')
const { Op } = require('sequelize')
const Logger = require('../Logger')
const SocketAuthority = require('../SocketAuthority')
const Database = require('../Database')
const { sort } = require('../libs/fastSort')
const { toNumber, isNullOrNaN } = require('../utils/index')
const userStats = require('../utils/queries/userStats')
const parseUserAgent = require('../utils/parsers/parseUserAgent')
/**
* @typedef RequestUserObject
@ -26,6 +28,40 @@ class MeController {
res.json(req.user.toOldJSONForBrowser())
}
/**
* GET: /api/me/sessions
*
* @param {RequestWithUser} req
* @param {Response} res
*/
async getSessions(req, res) {
if (req.user.isGuest) {
return res.json({ sessions: [] })
}
const refreshToken = req.cookies.refresh_token || req.headers['x-refresh-token']
const sessions = await Database.sessionModel.findAll({
where: {
userId: req.user.id,
expiresAt: { [Op.gt]: new Date() }
},
order: [['updatedAt', 'DESC']]
})
res.json({
sessions: sessions.map((session) => ({
id: session.id,
ipAddress: session.ipAddress,
userAgent: session.userAgent,
// For display convenience
deviceInfo: parseUserAgent(session.userAgent),
createdAt: session.createdAt?.valueOf() ?? null,
updatedAt: session.updatedAt?.valueOf() ?? null,
current: !!refreshToken && (session.refreshToken === refreshToken || session.lastRefreshToken === refreshToken)
}))
})
}
/**
* GET: /api/me/progress
*

View file

@ -171,6 +171,7 @@ class ApiRouter {
// Current User Routes (Me)
//
this.router.get('/me', MeController.getCurrentUser.bind(this))
this.router.get('/me/sessions', MeController.getSessions.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))

View file

@ -0,0 +1,28 @@
const uaParserJs = require('../../libs/uaParser')
/**
* @param {string|null|undefined} userAgent
* @returns {{ browserName?:string, browserVersion?:string, osName?:string, osVersion?:string, deviceType?:string, model?:string, vendor?:string }|null}
*/
function parseUserAgent(userAgent) {
if (!userAgent) return null
const ua = uaParserJs(userAgent)
const deviceInfo = {
browserName: ua?.browser?.name || undefined,
browserVersion: ua?.browser?.version || undefined,
osName: ua?.os?.name || undefined,
osVersion: ua?.os?.version || undefined,
deviceType: ua?.device?.type || undefined,
model: ua?.device?.model || undefined,
vendor: ua?.device?.vendor || undefined
}
for (const key in deviceInfo) {
if (deviceInfo[key] === undefined) delete deviceInfo[key]
}
return Object.keys(deviceInfo).length ? deviceInfo : null
}
module.exports = parseUserAgent