feat(admin): ImpersonationBanner — глобальный индикатор активных сессий (audit B5)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-16 09:59:43 +03:00
parent ba865ac733
commit e708825e1a
4 changed files with 205 additions and 1 deletions
@@ -0,0 +1,84 @@
<script setup lang="ts">
/**
* Глобальный индикатор активных impersonation-сессий (audit B5 / Ю-1).
*
* Размещён в AdminLayout над <RouterView> — виден на всех /admin/* страницах.
* На MVP saas-admin auth нет и реального переключения сессии нет, поэтому
* показываем счётчик ВСЕХ активных сессий (impersonationActive() =
* used_at != null AND session_ended_at == null). Polling 30 c — сессия может
* стартовать/завершиться, пока админ остаётся в админке (AdminLayout
* persistent, перемонтируется только <RouterView>).
*
* Если активных сессий 0 — компонент не рендерит ничего.
*/
import { computed, onMounted, ref } from 'vue';
import { impersonationActive, type ImpersonationActiveSession } from '../../api/admin';
import { usePolling } from '../../composables/usePolling';
const sessions = ref<ImpersonationActiveSession[]>([]);
async function load(): Promise<void> {
try {
sessions.value = await impersonationActive();
} catch {
// Баннер не критичен — ошибку детально покажет AdminImpersonationView.
// Сохраняем прежнее значение sessions, не падаем.
}
}
const count = computed(() => sessions.value.length);
const label = computed(() => {
if (count.value === 1) {
const s = sessions.value[0];
return `Активна impersonation-сессия: ${s.tenant_name ?? `тенант #${s.tenant_id}`}`;
}
return `Активны impersonation-сессии: ${count.value}`;
});
onMounted(load);
usePolling(load, { intervalMs: 30_000 });
defineExpose({ sessions, load });
</script>
<template>
<div v-if="count > 0" class="impersonation-banner" role="status" data-testid="impersonation-banner">
<v-icon size="16" class="impersonation-banner__icon">mdi-account-switch</v-icon>
<span class="impersonation-banner__label">{{ label }}</span>
<RouterLink
to="/admin/impersonation"
class="impersonation-banner__link"
data-testid="impersonation-banner-link"
>
Открыть
</RouterLink>
</div>
</template>
<style scoped>
.impersonation-banner {
display: flex;
align-items: center;
gap: 8px;
background: #fff4e0;
border-bottom: 1px solid #f0d8a8;
color: #8a5a00;
font-size: 13px;
padding: 8px 24px;
}
.impersonation-banner__icon {
color: #b87400;
}
.impersonation-banner__label {
flex: 1;
}
.impersonation-banner__link {
color: #0f6e56;
font-weight: 600;
text-decoration: none;
}
.impersonation-banner__link:hover {
text-decoration: underline;
}
</style>
+2
View File
@@ -16,6 +16,7 @@ import { useAuthStore } from '../stores/auth';
import { computed } from 'vue';
import { RouterView, useRoute, useRouter } from 'vue-router';
import DevIndexBadge from '../components/DevIndexBadge.vue';
import ImpersonationBanner from '../components/admin/ImpersonationBanner.vue';
interface NavItem {
title: string;
@@ -131,6 +132,7 @@ const currentPageTitle = computed(() => {
</v-app-bar>
<v-main class="admin-main">
<ImpersonationBanner />
<RouterView />
</v-main>
<DevIndexBadge :index="route.meta.devIndex" :label="route.meta.devLabel" />
+1 -1
View File
@@ -53,7 +53,7 @@ const mountAdminLayout = async (path = '/admin/tenants', user: AuthUser | null =
const wrapper = mount(AdminLayout, {
global: {
plugins: [createVuetify(), router],
stubs: { DevIndexBadge: true },
stubs: { DevIndexBadge: true, ImpersonationBanner: true },
},
});
return { wrapper, auth, router };
@@ -0,0 +1,118 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount, flushPromises } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import ImpersonationBanner from '../../resources/js/components/admin/ImpersonationBanner.vue';
import type { ImpersonationActiveSession } from '../../resources/js/api/admin';
vi.mock('../../resources/js/api/admin', async (importOriginal) => {
const orig = await importOriginal<typeof import('../../resources/js/api/admin')>();
return { ...orig, impersonationActive: vi.fn() };
});
const adminApi = await import('../../resources/js/api/admin');
function makeSession(overrides: Partial<ImpersonationActiveSession> = {}): ImpersonationActiveSession {
return {
token_id: 1,
tenant_id: 10,
tenant_name: 'ООО Ромашка',
requested_by: 7,
reason: 'Диагностика проблемы с балансом по обращению клиента',
sent_to_email: 'client@romashka.ru',
used_at: '2026-05-16T08:00:00Z',
expires_at: '2026-05-16T08:15:00Z',
...overrides,
};
}
const mountBanner = () =>
mount(ImpersonationBanner, {
global: {
plugins: [createVuetify()],
stubs: {
RouterLink: {
props: ['to'],
inheritAttrs: false,
template: '<a v-bind="$attrs" :href="to"><slot /></a>',
},
},
},
});
beforeEach(() => vi.clearAllMocks());
afterEach(() => vi.useRealTimers());
describe('ImpersonationBanner', () => {
it('вызывает impersonationActive на mount', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]);
const wrapper = mountBanner();
await flushPromises();
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1);
wrapper.unmount();
});
it('0 активных сессий — баннер не рендерится', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([]);
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false);
wrapper.unmount();
});
it('1 активная сессия — баннер с именем тенанта + ссылка на /admin/impersonation', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([makeSession({ tenant_name: 'ООО Ромашка' })]);
const wrapper = mountBanner();
await flushPromises();
const banner = wrapper.find('[data-testid="impersonation-banner"]');
expect(banner.exists()).toBe(true);
expect(banner.text()).toContain('Активна impersonation-сессия');
expect(banner.text()).toContain('ООО Ромашка');
expect(wrapper.find('[data-testid="impersonation-banner-link"]').attributes('href')).toBe(
'/admin/impersonation',
);
wrapper.unmount();
});
it('несколько активных сессий — баннер показывает счётчик', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([
makeSession({ token_id: 1 }),
makeSession({ token_id: 2 }),
makeSession({ token_id: 3 }),
]);
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain(
'Активны impersonation-сессии: 3',
);
wrapper.unmount();
});
it('tenant_name=null — fallback на «тенант #id»', async () => {
vi.mocked(adminApi.impersonationActive).mockResolvedValueOnce([
makeSession({ tenant_name: null, tenant_id: 42 }),
]);
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').text()).toContain('тенант #42');
wrapper.unmount();
});
it('ошибка impersonationActive — баннер не падает и остаётся скрыт', async () => {
vi.mocked(adminApi.impersonationActive).mockRejectedValueOnce(new Error('500'));
const wrapper = mountBanner();
await flushPromises();
expect(wrapper.find('[data-testid="impersonation-banner"]').exists()).toBe(false);
wrapper.unmount();
});
it('polling — impersonationActive вызывается повторно через 30 с', async () => {
vi.useFakeTimers();
vi.mocked(adminApi.impersonationActive).mockResolvedValue([]);
const wrapper = mountBanner();
await vi.advanceTimersByTimeAsync(0);
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(1);
await vi.advanceTimersByTimeAsync(30_000);
expect(adminApi.impersonationActive).toHaveBeenCalledTimes(2);
wrapper.unmount();
});
});