Identify sessions by session key - a hash that can be safely shared.

This commit is contained in:
Leo Vasanko
2026-02-18 00:46:37 +00:00
parent 51f90d4c0d
commit 99893fbb62
12 changed files with 65 additions and 66 deletions
+6 -6
View File
@@ -57,11 +57,11 @@ async function handleDelete(credential) {
}
async function handleTerminateSession(session) {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try {
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionId}`, { method: 'DELETE' })
const data = await apiJson(`/auth/api/admin/users/${props.selectedUser.uuid}/sessions/${sessionKey}`, { method: 'DELETE' })
if (data.status === 'ok') {
if (data.current_session_terminated) {
sessionStorage.clear()
@@ -78,7 +78,7 @@ async function handleTerminateSession(session) {
authStore.showMessage(err.message || 'Failed to terminate session', 'error')
} finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
@@ -232,7 +232,7 @@ defineExpose({ focusFirstElement })
</section>
<SessionList
ref="sessionListRef"
:sessions="userDetail.sessions || []"
:sessions="userDetail.sessions || {}"
:terminating-sessions="terminatingSessions"
:hovered-credential-uuid="hoveredCredentialUuid"
:navigation-disabled="hasActiveModal"
+9 -9
View File
@@ -341,22 +341,22 @@ const handleDelete = async (credential) => {
const rpName = computed(() => authStore.settings?.rp_name || 'this service')
const paskiaVersion = computed(() => authStore.settings?.version || '')
const sessions = computed(() => authStore.userInfo?.sessions || [])
const sessions = computed(() => authStore.userInfo?.sessions || {})
const currentSessionHost = computed(() => {
const currentSession = sessions.value.find(session => session.is_current)
const currentSession = Object.values(sessions.value).find(session => session.is_current)
return currentSession?.host || 'this host'
})
const terminatingSessions = ref({})
const terminateSession = async (session) => {
const sessionId = session?.id
if (!sessionId) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionId]: true }
try { await authStore.terminateSession(sessionId) }
const sessionKey = session?.key
if (!sessionKey) return
terminatingSessions.value = { ...terminatingSessions.value, [sessionKey]: true }
try { await authStore.terminateSession(sessionKey) }
catch (error) { authStore.showMessage(error.message || 'Failed to terminate session', 'error', 5000) }
finally {
const next = { ...terminatingSessions.value }
delete next[sessionId]
delete next[sessionKey]
terminatingSessions.value = next
}
}
@@ -368,12 +368,12 @@ const isAdmin = computed(() => {
const perms = authStore.ctx?.permissions
return perms?.includes('auth:admin') || perms?.includes('auth:org:admin')
})
const hasMultipleSessions = computed(() => sessions.value.length > 1)
const hasMultipleSessions = computed(() => Object.keys(sessions.value).length > 1)
const credentials = computed(() => authStore.userInfo?.credentials ? Object.values(authStore.userInfo.credentials) : [])
const useWideLayout = computed(() => {
// Check if any single site has more than 8 sessions
const groups = {}
for (const session of sessions.value) {
for (const session of Object.values(sessions.value)) {
const host = session.host || ''
if (!groups[host]) groups[host] = []
groups[host].push(session)
+22 -17
View File
@@ -6,7 +6,7 @@
</div>
<div class="section-body">
<div>
<template v-if="Array.isArray(sessions) && sessions.length">
<template v-if="sessionsArray.length">
<div v-for="(group, key) in groupedSessions" :key="key" class="session-group" tabindex="0" @keydown="handleGroupKeydown($event, key)">
<span :class="['session-group-host', { 'is-current-site': group.isCurrentSite }]">
<span class="session-group-icon">{{ group.isOIDC ? '🪪' : '🌐' }}</span>
@@ -17,10 +17,10 @@
<div class="session-list">
<div
v-for="session in group.sessions"
:key="session.id"
:key="session.key"
:class="['session-item', {
'is-current': session.is_current && !hoveredIp && !hoveredCredentialUuid,
'is-hovered': hoveredSession?.id === session.id,
'is-hovered': hoveredSession?.key === session.key,
'is-linked-credential': hoveredCredentialUuid === session.credential
}]"
tabindex="-1"
@@ -34,14 +34,14 @@
<h4 class="item-title">{{ session.user_agent || '—' }}</h4>
<div class="item-actions">
<span v-if="session.is_current && !hoveredIp && !hoveredCredentialUuid" class="badge badge-current">Current</span>
<span v-else-if="hoveredSession?.id === session.id" class="badge badge-current">Selected</span>
<span v-else-if="hoveredSession?.key === session.key" class="badge badge-current">Selected</span>
<span v-else-if="hoveredCredentialUuid === session.credential" class="badge badge-current">Linked</span>
<span v-else-if="!hoveredCredentialUuid && isSameHost(session.ip)" class="badge">Same IP</span>
<button
@click="$emit('terminate', session)"
class="btn-card-delete"
:disabled="isTerminating(session.id)"
:title="isTerminating(session.id) ? 'Terminating...' : 'Terminate session'"
:disabled="isTerminating(session.key)"
:title="isTerminating(session.key) ? 'Terminating...' : 'Terminate session'"
tabindex="-1"
></button>
</div>
@@ -70,7 +70,7 @@ import { hostIP } from '@/utils/helpers'
import { navigateGrid, handleDeleteKey, handleEscape, getDirection } from '@/utils/keynav'
const props = defineProps({
sessions: { type: Array, default: () => [] },
sessions: { type: Object, default: () => ({}) },
emptyMessage: { type: String, default: 'You currently have no other active sessions.' },
sectionDescription: { type: String, default: "Review where you're signed in and end any sessions you no longer recognize." },
terminatingSessions: { type: Object, default: () => ({}) },
@@ -108,7 +108,7 @@ const handleCardClick = (event) => {
}
}
const isTerminating = (sessionId) => !!props.terminatingSessions[sessionId]
const isTerminating = (sessionKey) => !!props.terminatingSessions[sessionKey]
const handleGroupKeydown = (event, host) => {
const group = event.currentTarget
@@ -151,7 +151,7 @@ const handleGroupKeydown = (event, host) => {
const handleItemKeydown = (event, session) => {
// Handle delete (always allowed even with modal)
handleDeleteKey(event, () => {
if (!isTerminating(session.id)) emit('terminate', session)
if (!isTerminating(session.key)) emit('terminate', session)
})
if (event.defaultPrevented) return
@@ -210,9 +210,14 @@ const copyIp = async (ip) => {
const displayIp = ip => hostIP(ip) ?? ip
// Convert sessions dict to array with key attached
const sessionsArray = computed(() =>
Object.entries(props.sessions || {}).map(([key, session]) => ({ ...session, key }))
)
const currentHostIP = computed(() => {
if (hoveredIp.value) return hostIP(hoveredIp.value)
const current = props.sessions.find(s => s.is_current)
const current = sessionsArray.value.find(s => s.is_current)
return current ? hostIP(current.ip) : null
})
@@ -220,15 +225,15 @@ const isSameHost = ip => currentHostIP.value && hostIP(ip) === currentHostIP.val
const groupedSessions = computed(() => {
const groups = {}
for (const session of props.sessions) {
const key = session.client || session.host || ''
if (!groups[key]) {
groups[key] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || key }
for (const session of sessionsArray.value) {
const groupKey = session.client || session.host || ''
if (!groups[groupKey]) {
groups[groupKey] = { sessions: [], isCurrentSite: false, isOIDC: !!session.client, displayName: session.client_name || groupKey }
}
groups[key].sessions.push(session)
if (session.is_current_host) groups[key].isCurrentSite = true
groups[groupKey].sessions.push(session)
if (session.is_current_host) groups[groupKey].isCurrentSite = true
}
for (const key in groups) groups[key].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
for (const groupKey in groups) groups[groupKey].sessions.sort((a, b) => new Date(b.last_renewed) - new Date(a.last_renewed))
const collator = new Intl.Collator(undefined, { numeric: true, sensitivity: 'base' })
const sorted = Object.entries(groups).sort(([, a], [, b]) => {
if (a.isOIDC !== b.isOIDC) return a.isOIDC ? 1 : -1
+2 -2
View File
@@ -104,9 +104,9 @@ export const useAuthStore = defineStore('auth', {
await apiJson(`/auth/api/user/credential/${uuid}`, { method: 'DELETE' })
await this.loadUserInfo()
},
async terminateSession(sessionId) {
async terminateSession(sessionKey) {
try {
const payload = await apiJson(`/auth/api/user/session/${sessionId}`, { method: 'DELETE' })
const payload = await apiJson(`/auth/api/user/session/${sessionKey}`, { method: 'DELETE' })
if (payload?.current_session_terminated) {
sessionStorage.clear()
location.reload()