Files
portal/app/resources/js/components/settings/security/SessionsTable.vue
T
Дмитрий 2225a8487e
Accessibility (Pa11y live) / a11y (push) Has been cancelled
SAST — Semgrep / Semgrep SAST scan (push) Has been cancelled
feat(security): рабочая «Завершить сессию» — реальный отзыв активных сессий
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>
2026-06-21 17:15:26 +03:00

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>