mirror of
https://github.com/advplyr/audiobookshelf.git
synced 2026-07-26 19:21:39 +00:00
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
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:
commit
e9ea511c90
5 changed files with 131 additions and 2 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
28
server/utils/parsers/parseUserAgent.js
Normal file
28
server/utils/parsers/parseUserAgent.js
Normal 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
|
||||
Loading…
Add table
Add a link
Reference in a new issue