feat(admin): ImpersonationBanner — глобальный индикатор активных сессий (audit B5)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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" />
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user