mirror of
https://github.com/go-vikunja/vikunja.git
synced 2026-03-11 17:48:44 -05:00
feat: add frontend session management with refresh tokens
- Session model, type interface, and API service - Sessions settings page showing active sessions with device info, IP address, last active time, and current session indicator - Auth store updated to use cookie-based refresh tokens for user sessions and JWT-based renewal for link shares - refreshToken() uses Web Locks API to coordinate across browser tabs — only one tab performs the refresh, others adopt the result - 401 response interceptor with automatic retry: detects expired JWT (error code 11), refreshes the token, and replays the request - Interceptor gated to user JWTs only (link shares skip refresh) - checkAuth() attempts cookie refresh when JWT is expired, allowing seamless session resumption after short TTL expiry - Proactive token refresh on page focus/visibility via composable - renewToken() tolerates refresh failures when JWT is still valid
This commit is contained in:
@@ -1,11 +1,12 @@
|
||||
import {computed} from 'vue'
|
||||
import {computed, ref, watch} from 'vue'
|
||||
import {useRouter} from 'vue-router'
|
||||
import {useEventListener} from '@vueuse/core'
|
||||
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {MILLISECONDS_A_SECOND, SECONDS_A_HOUR} from '@/constants/date'
|
||||
import {MILLISECONDS_A_SECOND} from '@/constants/date'
|
||||
|
||||
const SECONDS_TOKEN_VALID = 60 * SECONDS_A_HOUR
|
||||
// Refresh the token 60 seconds before it expires to avoid API calls hitting 401.
|
||||
const REFRESH_BUFFER_SECONDS = 60
|
||||
|
||||
export function useRenewTokenOnFocus() {
|
||||
const router = useRouter()
|
||||
@@ -13,34 +14,78 @@ export function useRenewTokenOnFocus() {
|
||||
|
||||
const userInfo = computed(() => authStore.info)
|
||||
const authenticated = computed(() => authStore.authenticated)
|
||||
const refreshTimer = ref<ReturnType<typeof setTimeout> | null>(null)
|
||||
|
||||
function clearRefreshTimer() {
|
||||
if (refreshTimer.value !== null) {
|
||||
clearTimeout(refreshTimer.value)
|
||||
refreshTimer.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule a proactive refresh based on the JWT's exp claim.
|
||||
// Called after every successful auth check or token refresh.
|
||||
function scheduleProactiveRefresh() {
|
||||
clearRefreshTimer()
|
||||
|
||||
if (!authenticated.value || !userInfo.value?.exp) {
|
||||
return
|
||||
}
|
||||
|
||||
const nowInSeconds = Date.now() / MILLISECONDS_A_SECOND
|
||||
const expiresIn = userInfo.value.exp - nowInSeconds
|
||||
const refreshIn = Math.max(expiresIn - REFRESH_BUFFER_SECONDS, 0)
|
||||
|
||||
refreshTimer.value = setTimeout(() => {
|
||||
authStore.renewToken()
|
||||
}, refreshIn * MILLISECONDS_A_SECOND)
|
||||
}
|
||||
|
||||
// Re-schedule whenever the user info (and thus exp) changes.
|
||||
watch(
|
||||
() => userInfo.value?.exp,
|
||||
() => scheduleProactiveRefresh(),
|
||||
)
|
||||
|
||||
// Also re-schedule when authentication state changes (e.g. logout clears it).
|
||||
watch(authenticated, (isAuth) => {
|
||||
if (!isAuth) {
|
||||
clearRefreshTimer()
|
||||
}
|
||||
})
|
||||
|
||||
// Try renewing the token every time vikunja is loaded initially
|
||||
// (When opening the browser the focus event is not fired)
|
||||
authStore.renewToken()
|
||||
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it
|
||||
// Check if the token is still valid if the window gets focus again to maybe renew it.
|
||||
// This handles the case where the laptop was suspended and the timer didn't fire.
|
||||
useEventListener('focus', async () => {
|
||||
if (!authenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
const nowInSeconds = new Date().getTime() / MILLISECONDS_A_SECOND
|
||||
const nowInSeconds = Date.now() / MILLISECONDS_A_SECOND
|
||||
const expiresIn = userInfo.value
|
||||
? userInfo.value.exp - nowInSeconds
|
||||
: 0
|
||||
|
||||
// If the token expiry is negative, it is already expired and we have no choice but to redirect
|
||||
// the user to the login page
|
||||
// If the token is already expired, try to refresh immediately.
|
||||
// The 401 interceptor would also handle this, but refreshing here
|
||||
// avoids a brief error flash on the first API call after focus.
|
||||
if (expiresIn <= 0) {
|
||||
await authStore.checkAuth()
|
||||
await router.push({name: 'user.login'})
|
||||
try {
|
||||
await authStore.renewToken()
|
||||
} catch {
|
||||
await authStore.checkAuth()
|
||||
await router.push({name: 'user.login'})
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Check if the token is valid for less than 60 hours and renew if thats the case
|
||||
if (expiresIn < SECONDS_TOKEN_VALID) {
|
||||
// If the token expires within the buffer window, refresh now.
|
||||
if (expiresIn < REFRESH_BUFFER_SECONDS) {
|
||||
authStore.renewToken()
|
||||
console.debug('renewed token')
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {AuthenticatedHTTPFactory} from '@/helpers/fetcher'
|
||||
import type {AxiosResponse} from 'axios'
|
||||
import {HTTPFactory} from '@/helpers/fetcher'
|
||||
|
||||
let savedToken: string | null = null
|
||||
|
||||
@@ -36,16 +35,42 @@ export const removeToken = () => {
|
||||
|
||||
/**
|
||||
* Refreshes an auth token while ensuring it is updated everywhere.
|
||||
* The refresh token is sent automatically as an HttpOnly cookie.
|
||||
* The server rotates the cookie on every call.
|
||||
*
|
||||
* Uses the Web Locks API to coordinate across browser tabs. Only one tab
|
||||
* performs the actual refresh; other tabs waiting for the lock detect that
|
||||
* the token in localStorage was already updated and adopt it directly.
|
||||
*/
|
||||
export async function refreshToken(persist: boolean): Promise<AxiosResponse> {
|
||||
const HTTP = AuthenticatedHTTPFactory()
|
||||
try {
|
||||
const response = await HTTP.post('user/token')
|
||||
saveToken(response.data.token, persist)
|
||||
return response
|
||||
export async function refreshToken(persist: boolean): Promise<void> {
|
||||
// Capture the token before waiting for the lock so we can detect
|
||||
// if another tab refreshed while we were queued.
|
||||
const tokenBeforeLock = localStorage.getItem('token')
|
||||
|
||||
} catch(e) {
|
||||
throw new Error('Error renewing token: ', { cause: e })
|
||||
const doRefresh = async () => {
|
||||
// If the token in localStorage changed while waiting for the lock,
|
||||
// another tab already refreshed. Just adopt the new token.
|
||||
const currentToken = localStorage.getItem('token')
|
||||
if (currentToken && currentToken !== tokenBeforeLock) {
|
||||
savedToken = currentToken
|
||||
return
|
||||
}
|
||||
|
||||
// We hold the lock and no one else refreshed — make the API call.
|
||||
const HTTP = HTTPFactory()
|
||||
try {
|
||||
const response = await HTTP.post('user/token/refresh')
|
||||
saveToken(response.data.token, persist)
|
||||
} catch (e) {
|
||||
throw new Error('Error renewing token: ', {cause: e})
|
||||
}
|
||||
}
|
||||
|
||||
if (navigator.locks) {
|
||||
await navigator.locks.request('vikunja-token-refresh', doRefresh)
|
||||
} else {
|
||||
// Fallback for environments without Web Locks (e.g. insecure HTTP)
|
||||
await doRefresh()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
import axios from 'axios'
|
||||
import {getToken} from '@/helpers/auth'
|
||||
import type {AxiosRequestConfig} from 'axios'
|
||||
import {getToken, refreshToken} from '@/helpers/auth'
|
||||
import {AUTH_TYPES} from '@/modelTypes/IUser'
|
||||
|
||||
export function HTTPFactory() {
|
||||
const instance = axios.create({baseURL: window.API_URL})
|
||||
const instance = axios.create({
|
||||
baseURL: window.API_URL,
|
||||
// Ensure the browser sends and accepts cookies (e.g. the HttpOnly
|
||||
// refresh token) even when the API is on a different origin.
|
||||
withCredentials: true,
|
||||
})
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
// by setting the baseURL fresh for every request
|
||||
@@ -15,7 +22,38 @@ export function HTTPFactory() {
|
||||
return instance
|
||||
}
|
||||
|
||||
export function AuthenticatedHTTPFactory() {
|
||||
// Shared state for the 401 interceptor so that concurrent requests that all
|
||||
// fail with 401 only trigger a single refresh, then all retry with the new token.
|
||||
let refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
async function doRefresh(): Promise<string | null> {
|
||||
try {
|
||||
await refreshToken(true)
|
||||
return getToken()
|
||||
} catch {
|
||||
// Refresh failed. Don't remove the token here — in a multi-tab scenario,
|
||||
// another tab may have successfully rotated the refresh token, and clearing
|
||||
// localStorage would log out that tab too. Let the caller decide.
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the `type` claim from a JWT without verifying the signature.
|
||||
* Returns null if the token is missing or malformed.
|
||||
*/
|
||||
function getTokenType(token: string | null): number | null {
|
||||
if (!token) return null
|
||||
try {
|
||||
const base64 = token.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const payload = JSON.parse(atob(base64))
|
||||
return typeof payload.type === 'number' ? payload.type : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function AuthenticatedHTTPFactory() {
|
||||
const instance = HTTPFactory()
|
||||
|
||||
instance.interceptors.request.use((config) => {
|
||||
@@ -32,5 +70,52 @@ export function AuthenticatedHTTPFactory() {
|
||||
return config
|
||||
})
|
||||
|
||||
// Response interceptor: on expired JWT 401, attempt a refresh and retry once.
|
||||
instance.interceptors.response.use(undefined, async (error) => {
|
||||
const originalRequest: AxiosRequestConfig & { _retried?: boolean } = error.config
|
||||
|
||||
// Only intercept 401s, and don't retry a request that already retried.
|
||||
if (error.response?.status !== 401 || originalRequest._retried) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Only retry when the 401 is from an expired/invalid JWT. The backend
|
||||
// returns error code 11 for this case. Other 401s (disabled account,
|
||||
// wrong API token, etc.) are genuine auth failures — retrying would loop.
|
||||
const ERROR_CODE_INVALID_TOKEN = 11
|
||||
if (error.response?.data?.code !== ERROR_CODE_INVALID_TOKEN) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Don't try to refresh if we don't have a token at all (not logged in),
|
||||
// or if the token is a link share JWT (they don't use cookie-based refresh).
|
||||
const currentToken = getToken()
|
||||
if (!currentToken || getTokenType(currentToken) !== AUTH_TYPES.USER) {
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
originalRequest._retried = true
|
||||
|
||||
// Coalesce concurrent refresh attempts into a single request.
|
||||
if (!refreshPromise) {
|
||||
refreshPromise = doRefresh().finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
}
|
||||
|
||||
const newToken = await refreshPromise
|
||||
if (!newToken) {
|
||||
// Refresh failed — reject so the UI can redirect to login.
|
||||
return Promise.reject(error)
|
||||
}
|
||||
|
||||
// Retry the original request with the new token.
|
||||
originalRequest.headers = {
|
||||
...originalRequest.headers,
|
||||
Authorization: `Bearer ${newToken}`,
|
||||
}
|
||||
return instance.request(originalRequest)
|
||||
})
|
||||
|
||||
return instance
|
||||
}
|
||||
|
||||
@@ -212,6 +212,20 @@
|
||||
"expiresAt": "Expires at",
|
||||
"permissions": "Permissions"
|
||||
}
|
||||
},
|
||||
"sessions": {
|
||||
"title": "Sessions",
|
||||
"description": "These are all the devices currently logged into your account. You can revoke any session to log that device out. It may take up to 10 minutes for the revocation to take full effect.",
|
||||
"deviceInfo": "Device",
|
||||
"ipAddress": "IP Address",
|
||||
"lastActive": "Last Active",
|
||||
"current": "Current session",
|
||||
"delete": {
|
||||
"header": "Revoke session",
|
||||
"text": "Are you sure you want to revoke this session? The device will be logged out. It may take up to 10 minutes for the session to fully expire."
|
||||
},
|
||||
"deleteSuccess": "Session revoked successfully. It may take up to 10 minutes for the session to fully expire.",
|
||||
"noOtherSessions": "No other active sessions."
|
||||
}
|
||||
},
|
||||
"deletion": {
|
||||
|
||||
10
frontend/src/modelTypes/ISession.ts
Normal file
10
frontend/src/modelTypes/ISession.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import type {IAbstract} from '@/modelTypes/IAbstract'
|
||||
|
||||
export interface ISession extends IAbstract {
|
||||
id: string
|
||||
deviceInfo: string
|
||||
ipAddress: string
|
||||
isCurrent: boolean
|
||||
lastActive: Date
|
||||
created: Date
|
||||
}
|
||||
24
frontend/src/models/session.ts
Normal file
24
frontend/src/models/session.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import AbstractModel from '@/models/abstractModel'
|
||||
import type {ISession} from '@/modelTypes/ISession'
|
||||
|
||||
export default class SessionModel extends AbstractModel<ISession> implements ISession {
|
||||
id = ''
|
||||
deviceInfo = ''
|
||||
ipAddress = ''
|
||||
isCurrent = false
|
||||
lastActive: Date = null
|
||||
created: Date = null
|
||||
|
||||
constructor(data: Partial<ISession> = {}) {
|
||||
super()
|
||||
|
||||
this.assignData(data)
|
||||
|
||||
if (this.lastActive) {
|
||||
this.lastActive = new Date(this.lastActive)
|
||||
}
|
||||
if (this.created) {
|
||||
this.created = new Date(this.created)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,11 @@ const router = createRouter({
|
||||
name: 'user.settings.apiTokens',
|
||||
component: () => import('@/views/user/settings/ApiTokens.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user/settings/sessions',
|
||||
name: 'user.settings.sessions',
|
||||
component: () => import('@/views/user/settings/Sessions.vue'),
|
||||
},
|
||||
{
|
||||
path: '/user/settings/migrate',
|
||||
name: 'migrate.start',
|
||||
|
||||
16
frontend/src/services/session.ts
Normal file
16
frontend/src/services/session.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import AbstractService from '@/services/abstractService'
|
||||
import SessionModel from '@/models/session'
|
||||
import type {ISession} from '@/modelTypes/ISession'
|
||||
|
||||
export default class SessionService extends AbstractService<ISession> {
|
||||
constructor() {
|
||||
super({
|
||||
getAll: '/user/sessions',
|
||||
delete: '/user/sessions/{id}',
|
||||
})
|
||||
}
|
||||
|
||||
modelFactory(data: Partial<ISession>) {
|
||||
return new SessionModel(data)
|
||||
}
|
||||
}
|
||||
@@ -76,6 +76,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
const avatarUrl = ref('')
|
||||
const settings = ref<IUserSettings>(new UserSettingsModel())
|
||||
|
||||
const currentSessionId = ref<string | null>(null)
|
||||
const lastUserInfoRefresh = ref<Date | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isLoadingGeneralSettings = ref(false)
|
||||
@@ -290,19 +291,46 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
.split('.')[1]
|
||||
.replace(/-/g, '+')
|
||||
.replace(/_/g, '/')
|
||||
const jwtUser = new UserModel(JSON.parse(atob(base64)))
|
||||
const payload = JSON.parse(atob(base64))
|
||||
const jwtUser = new UserModel(payload)
|
||||
const ts = Math.round((new Date()).getTime() / MILLISECONDS_A_SECOND)
|
||||
|
||||
isAuthenticated = jwtUser.exp >= ts
|
||||
// Only set user from JWT if we don't already have a fully loaded
|
||||
// user with the same ID. The JWT lacks fields like `name`, so
|
||||
// overwriting a complete user object causes a visible flash
|
||||
// where the display name briefly reverts to the username.
|
||||
if (info.value === null || info.value.id !== jwtUser.id) {
|
||||
setUser(jwtUser, false)
|
||||
} else {
|
||||
// Always keep exp in sync so token renewal checks stay accurate
|
||||
info.value.exp = jwtUser.exp
|
||||
currentSessionId.value = payload.sid ?? null
|
||||
|
||||
if (isAuthenticated) {
|
||||
// Only set user from JWT if we don't already have a fully loaded
|
||||
// user with the same ID. The JWT lacks fields like `name`, so
|
||||
// overwriting a complete user object causes a visible flash
|
||||
// where the display name briefly reverts to the username.
|
||||
if (info.value === null || info.value.id !== jwtUser.id) {
|
||||
setUser(jwtUser, false)
|
||||
} else {
|
||||
// Always keep exp in sync so token renewal checks stay accurate
|
||||
info.value.exp = jwtUser.exp
|
||||
}
|
||||
} else if (jwtUser.type === AUTH_TYPES.USER) {
|
||||
// JWT expired but this is a user session — attempt a cookie-based
|
||||
// refresh before giving up. This lets users who reopen the app
|
||||
// after the short JWT TTL seamlessly resume their session.
|
||||
try {
|
||||
await refreshToken(true)
|
||||
const freshJwt = getToken()
|
||||
if (freshJwt) {
|
||||
const b64 = freshJwt.split('.')[1].replace(/-/g, '+').replace(/_/g, '/')
|
||||
const p = JSON.parse(atob(b64))
|
||||
const freshUser = new UserModel(p)
|
||||
isAuthenticated = freshUser.exp >= ts
|
||||
currentSessionId.value = p.sid ?? null
|
||||
if (info.value === null || info.value.id !== freshUser.id) {
|
||||
setUser(freshUser, false)
|
||||
} else {
|
||||
info.value.exp = freshUser.exp
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Refresh failed — stay unauthenticated
|
||||
}
|
||||
}
|
||||
} catch (_) {
|
||||
logout()
|
||||
@@ -426,29 +454,44 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
/**
|
||||
* Renews the api token and saves it to local storage
|
||||
*/
|
||||
function renewToken() {
|
||||
// FIXME: Timeout to avoid race conditions when authenticated as a user (=auth token in localStorage) and as a
|
||||
// link share in another tab. Without the timeout both the token renew and link share auth are executed at
|
||||
// the same time and one might win over the other.
|
||||
setTimeout(async () => {
|
||||
if (!authenticated.value) {
|
||||
return
|
||||
}
|
||||
async function renewToken() {
|
||||
if (!authenticated.value) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await refreshToken(!isLinkShareAuth.value)
|
||||
await checkAuth()
|
||||
} catch (e) {
|
||||
// Don't logout on network errors as the user would then get logged out if they don't have
|
||||
// internet for a short period of time - such as when the laptop is still reconnecting
|
||||
if (e?.request?.status) {
|
||||
await logout()
|
||||
}
|
||||
try {
|
||||
if (isLinkShareAuth.value) {
|
||||
// Link shares renew via the dedicated link-share endpoint (JWT-based).
|
||||
const HTTP = AuthenticatedHTTPFactory()
|
||||
const response = await HTTP.post('user/token')
|
||||
saveToken(response.data.token, false)
|
||||
} else {
|
||||
// User sessions renew via the refresh-token cookie.
|
||||
await refreshToken(true)
|
||||
}
|
||||
}, 5000)
|
||||
await checkAuth()
|
||||
} catch (e) {
|
||||
// Only logout if the JWT has actually expired and we can't refresh.
|
||||
// If the JWT is still valid, the proactive refresh failure is harmless
|
||||
// — the 401 interceptor will handle it when the token really expires.
|
||||
const nowInSeconds = Date.now() / MILLISECONDS_A_SECOND
|
||||
const isExpired = !info.value?.exp || info.value.exp < nowInSeconds
|
||||
if (isExpired && (e?.cause?.request?.status || e?.cause?.response?.status)) {
|
||||
await logout()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function logout() {
|
||||
// Revoke the server session so the refresh token can't be reused.
|
||||
// Best-effort: if the network call fails, still clean up locally.
|
||||
try {
|
||||
const HTTP = AuthenticatedHTTPFactory()
|
||||
await HTTP.post('user/logout')
|
||||
} catch (_e) {
|
||||
// Ignore — session will expire naturally
|
||||
}
|
||||
|
||||
removeToken()
|
||||
const loggedInVia = getLoggedInVia()
|
||||
window.localStorage.clear() // Clear all settings and history we might have saved in local storage.
|
||||
@@ -472,6 +515,7 @@ export const useAuthStore = defineStore('auth', () => {
|
||||
avatarUrl: readonly(avatarUrl),
|
||||
settings: readonly(settings),
|
||||
|
||||
currentSessionId: readonly(currentSessionId),
|
||||
lastUserInfoRefresh: readonly(lastUserInfoRefresh),
|
||||
|
||||
authUser,
|
||||
|
||||
@@ -108,6 +108,10 @@ const navigationItems = computed(() => {
|
||||
title: t('user.settings.apiTokens.title'),
|
||||
routeName: 'user.settings.apiTokens',
|
||||
},
|
||||
{
|
||||
title: t('user.settings.sessions.title'),
|
||||
routeName: 'user.settings.sessions',
|
||||
},
|
||||
{
|
||||
title: t('user.deletion.title'),
|
||||
routeName: 'user.settings.deletion',
|
||||
|
||||
111
frontend/src/views/user/settings/Sessions.vue
Normal file
111
frontend/src/views/user/settings/Sessions.vue
Normal file
@@ -0,0 +1,111 @@
|
||||
<script setup lang="ts">
|
||||
import {ref, shallowReactive} from 'vue'
|
||||
import {useI18n} from 'vue-i18n'
|
||||
|
||||
import {useTitle} from '@/composables/useTitle'
|
||||
import {useAuthStore} from '@/stores/auth'
|
||||
import {success} from '@/message'
|
||||
import {formatDateSince} from '@/helpers/time/formatDate'
|
||||
import SessionService from '@/services/session'
|
||||
import type {ISession} from '@/modelTypes/ISession'
|
||||
|
||||
const {t} = useI18n({useScope: 'global'})
|
||||
useTitle(() => `${t('user.settings.sessions.title')} - ${t('user.settings.title')}`)
|
||||
|
||||
const authStore = useAuthStore()
|
||||
const service = shallowReactive(new SessionService())
|
||||
const sessions = ref<ISession[]>([])
|
||||
|
||||
const showDeleteModal = ref(false)
|
||||
const sessionToDelete = ref<ISession | null>(null)
|
||||
|
||||
service.getAll().then((result: ISession[]) => {
|
||||
sessions.value = result
|
||||
})
|
||||
|
||||
function confirmDelete(session: ISession) {
|
||||
sessionToDelete.value = session
|
||||
showDeleteModal.value = true
|
||||
}
|
||||
|
||||
async function deleteSession() {
|
||||
if (!sessionToDelete.value) return
|
||||
|
||||
await service.delete(sessionToDelete.value)
|
||||
sessions.value = sessions.value.filter(({id}) => id !== sessionToDelete.value?.id)
|
||||
showDeleteModal.value = false
|
||||
sessionToDelete.value = null
|
||||
success({message: t('user.settings.sessions.deleteSuccess')})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Card :title="$t('user.settings.sessions.title')">
|
||||
<p class="mbe-4">
|
||||
{{ $t('user.settings.sessions.description') }}
|
||||
</p>
|
||||
|
||||
<table
|
||||
v-if="sessions.length > 0"
|
||||
class="table"
|
||||
>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{{ $t('user.settings.sessions.deviceInfo') }}</th>
|
||||
<th>{{ $t('user.settings.sessions.ipAddress') }}</th>
|
||||
<th>{{ $t('user.settings.sessions.lastActive') }}</th>
|
||||
<th class="has-text-end">
|
||||
{{ $t('misc.actions') }}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="session in sessions"
|
||||
:key="session.id"
|
||||
>
|
||||
<td>
|
||||
{{ session.deviceInfo }}
|
||||
<span
|
||||
v-if="session.id === authStore.currentSessionId"
|
||||
class="tag is-primary mis-2"
|
||||
>
|
||||
{{ $t('user.settings.sessions.current') }}
|
||||
</span>
|
||||
</td>
|
||||
<td>{{ session.ipAddress }}</td>
|
||||
<td>{{ formatDateSince(session.lastActive) }}</td>
|
||||
<td class="has-text-end">
|
||||
<XButton
|
||||
v-if="session.id !== authStore.currentSessionId"
|
||||
variant="secondary"
|
||||
@click="confirmDelete(session)"
|
||||
>
|
||||
{{ $t('misc.delete') }}
|
||||
</XButton>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p v-else>
|
||||
{{ $t('user.settings.sessions.noOtherSessions') }}
|
||||
</p>
|
||||
|
||||
<Modal
|
||||
:enabled="showDeleteModal"
|
||||
@close="showDeleteModal = false"
|
||||
@submit="deleteSession()"
|
||||
>
|
||||
<template #header>
|
||||
{{ $t('user.settings.sessions.delete.header') }}
|
||||
</template>
|
||||
|
||||
<template #text>
|
||||
<p>
|
||||
{{ $t('user.settings.sessions.delete.text') }}
|
||||
</p>
|
||||
</template>
|
||||
</Modal>
|
||||
</Card>
|
||||
</template>
|
||||
Reference in New Issue
Block a user