2225a8487e
UI-аудит: вкладка Безопасность показывала фейк-сессии с мёртвой кнопкой.
Теперь — реальные активные сессии + рабочий отзыв.
- UserSessionTracker (новый): запись сессии при входе (login + 2FA verify +
recovery-use) в существующую таблицу user_sessions; отзыв = удаление строки
+ удаление сессии из Redis по session_id (реальный выход с устройства);
logout снимает текущую сессию из списка. Best-effort (не ломает вход/выход).
- AccountController: GET /api/account/security отдаёт реальные сессии;
DELETE /api/account/sessions/{id} — отзыв (только свои; чужая → 404).
- Фронт SessionsTable: список + кнопка «Завершить» (кроме текущей).
- phpstan-baseline обновлён (Pest-$this нового теста).
Pest: 10/10. Верификация: Playwright (2 сессии → «Завершить» → исчезла).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
137 lines
4.3 KiB
Vue
137 lines
4.3 KiB
Vue
<script setup lang="ts">
|
|
/**
|
|
* SessionsTable — активные сессии пользователя (UI-аудит 21.06.2026, Security).
|
|
*
|
|
* Реальные данные: GET /api/account/security → sessions (из user_sessions).
|
|
* «Завершить» (DELETE /api/account/sessions/{id}) реально убивает сессию
|
|
* (удаление из Redis). Заменяет прежний статичный mock из 3 фейк-сессий.
|
|
*/
|
|
import { onMounted, ref } from 'vue';
|
|
import { getAccountSecurity, revokeSession, type ActiveSession } from '../../../api/account';
|
|
|
|
const sessions = ref<ActiveSession[]>([]);
|
|
const loading = ref(true);
|
|
const fetchError = ref(false);
|
|
const revokingId = ref<number | null>(null);
|
|
const toastOpen = ref(false);
|
|
const toastText = ref('');
|
|
|
|
function formatRelative(iso: string | null): string {
|
|
if (!iso) return '';
|
|
const diffMs = Date.now() - new Date(iso).getTime();
|
|
const min = Math.floor(diffMs / 60000);
|
|
if (min < 1) return 'только что';
|
|
if (min < 60) return `${min} мин назад`;
|
|
const h = Math.floor(min / 60);
|
|
if (h < 24) return `${h} ч назад`;
|
|
const d = Math.floor(h / 24);
|
|
if (d < 30) return `${d} дн назад`;
|
|
return new Date(iso).toLocaleDateString('ru-RU');
|
|
}
|
|
|
|
async function load(): Promise<void> {
|
|
loading.value = true;
|
|
fetchError.value = false;
|
|
try {
|
|
const data = await getAccountSecurity();
|
|
sessions.value = data.sessions;
|
|
} catch {
|
|
fetchError.value = true;
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function revoke(id: number): Promise<void> {
|
|
revokingId.value = id;
|
|
try {
|
|
await revokeSession(id);
|
|
sessions.value = sessions.value.filter((s) => s.id !== id);
|
|
toastText.value = 'Сессия завершена.';
|
|
toastOpen.value = true;
|
|
} catch {
|
|
toastText.value = 'Не удалось завершить сессию.';
|
|
toastOpen.value = true;
|
|
} finally {
|
|
revokingId.value = null;
|
|
}
|
|
}
|
|
|
|
onMounted(load);
|
|
</script>
|
|
|
|
<template>
|
|
<v-card variant="outlined" class="pa-4">
|
|
<h3 class="text-subtitle-2 mb-3">Активные сессии</h3>
|
|
|
|
<v-alert
|
|
v-if="fetchError"
|
|
type="warning"
|
|
variant="tonal"
|
|
density="compact"
|
|
data-testid="sessions-error"
|
|
>
|
|
Не удалось загрузить сессии.
|
|
</v-alert>
|
|
|
|
<div v-else-if="loading" class="text-body-2 text-medium-emphasis">Загрузка…</div>
|
|
|
|
<p v-else-if="sessions.length === 0" class="text-body-2 text-medium-emphasis ma-0">
|
|
Активных сессий не найдено.
|
|
</p>
|
|
|
|
<ul v-else class="sessions-list">
|
|
<li v-for="s in sessions" :key="s.id" class="session-row">
|
|
<div class="session-info">
|
|
<div class="session-device">
|
|
{{ s.device }}
|
|
<v-chip v-if="s.current" color="primary" size="x-small" variant="tonal" class="ml-2">
|
|
эта сессия
|
|
</v-chip>
|
|
</div>
|
|
<div class="session-meta text-caption text-medium-emphasis">
|
|
{{ s.ip || 'IP неизвестен' }} · {{ formatRelative(s.at) }}
|
|
</div>
|
|
</div>
|
|
<v-btn
|
|
v-if="!s.current"
|
|
variant="text"
|
|
size="small"
|
|
color="error"
|
|
:loading="revokingId === s.id"
|
|
:data-testid="`revoke-session-${s.id}`"
|
|
@click="revoke(s.id)"
|
|
>
|
|
Завершить
|
|
</v-btn>
|
|
</li>
|
|
</ul>
|
|
|
|
<v-snackbar v-model="toastOpen" :timeout="3000" location="bottom right">
|
|
{{ toastText }}
|
|
</v-snackbar>
|
|
</v-card>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.sessions-list {
|
|
list-style: none;
|
|
padding: 0;
|
|
margin: 0;
|
|
}
|
|
.session-row {
|
|
display: flex;
|
|
justify-content: space-between;
|
|
align-items: center;
|
|
padding: 10px 0;
|
|
border-bottom: 1px solid #f0ede4;
|
|
}
|
|
.session-row:last-child {
|
|
border-bottom: none;
|
|
}
|
|
.session-device {
|
|
font-weight: 500;
|
|
color: #081319;
|
|
}
|
|
</style>
|