diff --git a/CLAUDE.md b/CLAUDE.md index 51c28409..047b4194 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,6 +1,6 @@ # CLAUDE.md — техконтекст Лидерры -**Версия:** 1.42 от 09.05.2026 +**Версия:** 1.43 от 09.05.2026 **Назначение:** оперативная карта для Claude Code. Не первоисточник — первоисточники указаны в §0. > **Ребрендинг 08.05.2026:** «Лидпоток» → **«Лидерра.»** (с точкой). Палитра, лого и шрифты — из handoff Платона (v8 Forest). Применяется только к дизайну/имени/логотипу; функционал, состав страниц и правила — без изменений (источник — ТЗ v8.5/schema v8.5). @@ -224,6 +224,8 @@ trivy image liderra:latest --- +*CLAUDE.md v1.43 от 09.05.2026. Изменения v1.43: **Admin views (Биллинг / Инциденты / Система)**. Закрыт пункт #8 — заменены 3 placeholder'а на реальные display-views с mock-данными. **`AdminBillingView`**: 4-stats row (MRR / Выручка за месяц / Просрочка / Возвраты за 30 дн) + v-data-table 7 колонок (Тенант с ИНН / Тариф / Баланс ₽ с error-color при <0 / Пополнения за мес / Списания / MRR / Статус-chip). Search-фильтр по name/ИНН. **`AdminIncidentsView`**: 3-stats row (Открыто/Расследуется/РКН-уведомлений) + v-btn-toggle 5 фильтров по статусу + v-list инцидентов с incident_id (INC-YYYY-MMDD-NNNN), severity-chip + status-chip + специальный «РКН pending» chip для PDN-breach + дедлайн РКН (24 ч по 152-ФЗ). 5 категорий (PDN-breach / service_outage / security / billing / data_loss). **`AdminSystemView`**: read-only warning + поиск по ключу/описанию + v-list 7 system_settings (webhook_rate_limit_rps, login_max_attempts, password_min_length, retention_days, maintenance_mode и т.д.) с type-chip (int/string/bool/json) и updated_at. Edit-flow с двойным подтверждением + audit-log — отдельный коммит. **`composables/mockAdmin.ts`**: типы AdminBillingTenantRow/AdminIncidentRow/AdminSystemSetting + mock-данные. Маршруты `/admin/billing|incidents|system` теперь ведут на реальные view'ы (не AdminPlaceholderView). **Vitest +13** (всего **179/179 за 11.98 сек**): AdminBillingView 3 (mount + 4 stats + table contents); AdminIncidentsView 5 (mount + 3 stats + filter-toggle + PDN+РКН pending + incident_id format); AdminSystemView 5 (mount + read-only warning + key settings + type-chip + 7 rows). **TODO** (продолжение): #9 Impersonation flow (Ю-1). **Регресс зелёный:** lint+type+format OK; **vitest 179/179 за 11.98 сек** (+13 от 166); vite build 743 ms; story:build 21/28 за 31.5 сек. Реестр v1.51→v1.52.* + *CLAUDE.md v1.42 от 09.05.2026. Изменения v1.42: **Email-уведомление при 3 неудачных попытках входа (ТЗ §22.4.4 п.3)**. Закрыт пункт #5 — последний пункт ТЗ §22.4.4 анти-брутфорс. **`App\Mail\SuspiciousLoginNotification`** Mailable + `resources/views/emails/suspicious_login.blade.php` (HTML email с инструкциями: сменить пароль / включить 2FA / проверить сессии). **`AuthController::maybeNotifySuspiciousLogin`** triggers ровно при `count(auth_log.login_failed для user_id за час) === 3` — иначе на 4-5 неудачах будут спам-emails. Для unknown email user=null → ничего не отправляем. На dev `MAIL_MAILER=log` письмо в storage/logs. **Pest +4** в `tests/Feature/Auth/SuspiciousLoginNotificationTest.php` (всего **111/111 за 14.32 сек**, 401 assertions): после 3-й неудачи Mail::assertSent с правильными user/count/recipient; на 4-5 не дублируется (assertSent count=1); для unknown email НЕ отправляется; успех на 1-2 неудачах НЕ триггерит. PHPStan baseline регенерирован. **TODO** (продолжение): #7 browser-mode, #8 admin views, #9 impersonation. **Регресс зелёный:** Pint+Stan passed; **Pest 111/111 за 14.32 сек** (+4 от 107). Реестр v1.50→v1.51.* *CLAUDE.md v1.41 от 09.05.2026. Изменения v1.41: **IP-lockout 10/час + auth_log записи (ТЗ §22.4.4 п.2)**. Закрыт пункт #4 — защита от перебора с одного IP. **AuthController::login** перед verify проверяет `isIpLockedOut(ip)` — count(*) FROM auth_log WHERE event='login_failed' AND ip_address=ip AND created_at >= NOW() - 1 hour. Если ≥10 → 429 + Retry-After: 3600. Это второй слой защиты поверх email-rate-limit (5/15мин из v1.36) — защищает от перебора email'ов с одного IP. **`logAuthEvent`** private helper пишет в auth_log через DB::table (Eloquent для этой таблицы нет). На каждый login_success / login_failed (3 ветки: invalid_password / unknown_email / account_locked). RLS USING без WITH CHECK — INSERT не фильтруется. hash-chain trigger (BEFORE INSERT) заполняет log_hash автоматически (OPEN-И-15 tamper-detection). **Pest +6** в `tests/Feature/Auth/IpLockoutTest.php` (всего **107/107 за 13.86 сек**, 380 assertions): login_success пишет с tenant_id; login_failed wrong-password пишет invalid_password; login_failed unknown email пишет unknown_email + user_id=null; 10 fail записей с одного IP за час → следующий login = 429; 9 fail записей (под порогом) → проходит; старые записи >1ч не блокируют. PHPStan baseline регенерирован. **TODO** (продолжение): #5 email-warn, #7 browser-mode, #8 admin views, #9 impersonation. **Регресс зелёный:** lint+type+format OK; **Pest 107/107 за 13.86 сек** (+6 от 101, 380 assertions). Реестр v1.49→v1.50.* diff --git a/app/resources/js/composables/mockAdmin.ts b/app/resources/js/composables/mockAdmin.ts new file mode 100644 index 00000000..78ec318c --- /dev/null +++ b/app/resources/js/composables/mockAdmin.ts @@ -0,0 +1,216 @@ +/** + * Mock-данные для admin-views (Биллинг / Инциденты / Система). + * + * На MVP — frontend-only с предсказуемым набором данных. Backend подключение + * через отдельные endpoints `/api/admin/*` — отдельный коммит. + */ + +// === BILLING === + +export interface AdminBillingTenantRow { + id: number; + name: string; + inn: string; + tariff: 'start' | 'basic' | 'pro' | 'enterprise'; + balance_rub: number; + monthly_topups_rub: number; + monthly_charges_rub: number; + mrr_rub: number; + last_payment_at: string | null; + status: 'active' | 'overdue' | 'suspended'; +} + +export interface AdminBillingSummary { + total_mrr_rub: number; + monthly_revenue_rub: number; + overdue_count: number; + refunds_count_30d: number; + avg_balance_rub: number; +} + +export const ADMIN_BILLING_SUMMARY: AdminBillingSummary = { + total_mrr_rub: 1_248_600, + monthly_revenue_rub: 1_318_400, + overdue_count: 5, + refunds_count_30d: 3, + avg_balance_rub: 12_840, +}; + +export const ADMIN_BILLING_TENANTS: AdminBillingTenantRow[] = [ + { + id: 1, + name: 'ООО «Окна Москва»', + inn: '7707083893', + tariff: 'pro', + balance_rub: 14_250, + monthly_topups_rub: 30_000, + monthly_charges_rub: 25_400, + mrr_rub: 4_990, + last_payment_at: '2026-05-04T10:23:00Z', + status: 'active', + }, + { + id: 2, + name: 'ИП Сидоров А.А.', + inn: '500102310123', + tariff: 'basic', + balance_rub: -1_200, + monthly_topups_rub: 5_000, + monthly_charges_rub: 6_200, + mrr_rub: 990, + last_payment_at: '2026-04-28T14:10:00Z', + status: 'overdue', + }, + { + id: 3, + name: 'ООО «Натяжные потолки СПб»', + inn: '7806012345', + tariff: 'pro', + balance_rub: 8_900, + monthly_topups_rub: 20_000, + monthly_charges_rub: 18_400, + mrr_rub: 4_990, + last_payment_at: '2026-05-02T09:00:00Z', + status: 'active', + }, + { + id: 4, + name: 'ООО «BigCorp Холдинг»', + inn: '7710140020', + tariff: 'enterprise', + balance_rub: 89_000, + monthly_topups_rub: 100_000, + monthly_charges_rub: 76_400, + mrr_rub: 14_990, + last_payment_at: '2026-05-01T08:00:00Z', + status: 'active', + }, + { + id: 5, + name: 'ИП Петров В.В.', + inn: '770212098765', + tariff: 'start', + balance_rub: 0, + monthly_topups_rub: 0, + monthly_charges_rub: 0, + mrr_rub: 0, + last_payment_at: null, + status: 'suspended', + }, +]; + +// === INCIDENTS === + +export interface AdminIncidentRow { + id: number; + incident_id: string; + title: string; + severity: 'critical' | 'high' | 'medium' | 'low'; + category: 'pdn_breach' | 'service_outage' | 'security' | 'billing' | 'data_loss'; + status: 'open' | 'investigating' | 'resolved' | 'closed'; + detected_at: string; + affected_tenants: number; + rkn_notified: boolean; + rkn_deadline_at: string | null; +} + +export const ADMIN_INCIDENTS: AdminIncidentRow[] = [ + { + id: 1, + incident_id: 'INC-2026-0507-0034', + title: 'API timeout: рост 502 ошибок на webhook-приёме', + severity: 'high', + category: 'service_outage', + status: 'investigating', + detected_at: '2026-05-09T12:34:00Z', + affected_tenants: 12, + rkn_notified: false, + rkn_deadline_at: null, + }, + { + id: 2, + incident_id: 'INC-2026-0506-0028', + title: 'Утечка ПДн через логи (REQ-3F8A2)', + severity: 'critical', + category: 'pdn_breach', + status: 'open', + detected_at: '2026-05-08T18:00:00Z', + affected_tenants: 1, + rkn_notified: false, + rkn_deadline_at: '2026-05-09T18:00:00Z', + }, + { + id: 3, + incident_id: 'INC-2026-0501-0019', + title: 'YooKassa: задержка вебхуков об оплате', + severity: 'medium', + category: 'billing', + status: 'resolved', + detected_at: '2026-05-01T10:15:00Z', + affected_tenants: 5, + rkn_notified: false, + rkn_deadline_at: null, + }, +]; + +// === SYSTEM SETTINGS === + +export interface AdminSystemSetting { + key: string; + value: string; + type: 'int' | 'string' | 'bool' | 'json'; + description: string; + updated_at: string; +} + +export const ADMIN_SYSTEM_SETTINGS: AdminSystemSetting[] = [ + { + key: 'webhook_rate_limit_rps', + value: '100', + type: 'int', + description: 'Лимит запросов в секунду на токен Webhook (ТЗ §10).', + updated_at: '2026-05-01T00:00:00Z', + }, + { + key: 'api_rate_limit_per_minute', + value: '60', + type: 'int', + description: 'Лимит запросов API на ключ в минуту.', + updated_at: '2026-05-01T00:00:00Z', + }, + { + key: 'login_max_attempts', + value: '5', + type: 'int', + description: 'Макс. неудачных попыток входа в окне 15 минут (ТЗ §22.4.4).', + updated_at: '2026-05-09T00:00:00Z', + }, + { + key: 'password_min_length', + value: '10', + type: 'int', + description: 'Минимальная длина пароля (ТЗ §22.4.1).', + updated_at: '2026-05-01T00:00:00Z', + }, + { + key: 'webhook_log_retention_days', + value: '90', + type: 'int', + description: 'Сколько дней хранить raw_payload Webhook.', + updated_at: '2026-05-01T00:00:00Z', + }, + { + key: 'maintenance_mode', + value: 'false', + type: 'bool', + description: 'Глобальный maintenance — webhook-приём и UI отключаются.', + updated_at: '2026-05-01T00:00:00Z', + }, + { + key: 'fallback_supplier_id', + value: '1', + type: 'int', + description: 'Резервный supplier_id для проектов без активного supplier (ProcessWebhookJob).', + updated_at: '2026-05-08T00:00:00Z', + }, +]; diff --git a/app/resources/js/router/index.ts b/app/resources/js/router/index.ts index 6a2d3d14..a8bad1cf 100644 --- a/app/resources/js/router/index.ts +++ b/app/resources/js/router/index.ts @@ -110,37 +110,20 @@ const routes: RouteRecordRaw[] = [ { path: '/admin/billing', name: 'admin-billing', - component: () => import('../views/admin/AdminPlaceholderView.vue'), - meta: { - layout: 'admin', - title: 'Биллинг', - requiresAuth: true, - description: 'Сводный биллинг по всем тенантам: пополнения, списания, MRR, retention, отчёты по выручке.', - }, + component: () => import('../views/admin/AdminBillingView.vue'), + meta: { layout: 'admin', title: 'Биллинг', requiresAuth: true }, }, { path: '/admin/incidents', name: 'admin-incidents', - component: () => import('../views/admin/AdminPlaceholderView.vue'), - meta: { - layout: 'admin', - title: 'Инциденты', - requiresAuth: true, - description: - 'Журнал инцидентов SaaS-уровня (incidents_log по schema v8.7 §9). Уведомление РКН за 24 ч при утечке ПДн.', - }, + component: () => import('../views/admin/AdminIncidentsView.vue'), + meta: { layout: 'admin', title: 'Инциденты', requiresAuth: true }, }, { path: '/admin/system', name: 'admin-system', - component: () => import('../views/admin/AdminPlaceholderView.vue'), - meta: { - layout: 'admin', - title: 'Система', - requiresAuth: true, - description: - 'system_settings по schema v8.7 §10: лимиты квот, тарифные планы, фичефлаги, fallback supplier_id.', - }, + component: () => import('../views/admin/AdminSystemView.vue'), + meta: { layout: 'admin', title: 'Система', requiresAuth: true }, }, // Error pages: 403/500 явные + catch-all 404 (всегда последний). { diff --git a/app/resources/js/views/admin/AdminBillingView.vue b/app/resources/js/views/admin/AdminBillingView.vue new file mode 100644 index 00000000..2efdb179 --- /dev/null +++ b/app/resources/js/views/admin/AdminBillingView.vue @@ -0,0 +1,165 @@ + + + + + + Биллинг + + Сводка по всем тенантам: выручка, MRR, просрочки, возвраты. + + + + + + + + MRR + {{ formatRub(ADMIN_BILLING_SUMMARY.total_mrr_rub) }} + + + + + Выручка за месяц + + {{ formatRub(ADMIN_BILLING_SUMMARY.monthly_revenue_rub) }} + + + + + + Просрочка + + {{ ADMIN_BILLING_SUMMARY.overdue_count }} + + + + + + Возвраты за 30 дн + {{ ADMIN_BILLING_SUMMARY.refunds_count_30d }} + + + + + + + Тенанты + + + + + + + {{ item.name }} + ИНН {{ item.inn }} + + + + {{ tariffMap[item.tariff] }} + + + + {{ formatRub(item.balance_rub) }} + + + + {{ formatRub(item.monthly_topups_rub) }} + + + {{ formatRub(item.monthly_charges_rub) }} + + + {{ formatRub(item.mrr_rub) }} + + + + {{ statusMap[item.status].label }} + + + + + + + + diff --git a/app/resources/js/views/admin/AdminIncidentsView.vue b/app/resources/js/views/admin/AdminIncidentsView.vue new file mode 100644 index 00000000..aff36134 --- /dev/null +++ b/app/resources/js/views/admin/AdminIncidentsView.vue @@ -0,0 +1,158 @@ + + + + + + Инциденты + + Журнал инцидентов SaaS-уровня. PDN-breach — уведомление РКН за 24 ч (152-ФЗ). + + + + + + + Открыто + {{ stats.open }} + + + + + Расследуется + {{ stats.investigating }} + + + + + РКН-уведомлений + {{ stats.rkn_pending }} + + + + + + + События + + Все + Открыты + Расследуются + Решены + Закрыты + + + + + + + {{ row.incident_id }} + + {{ severityMap[row.severity].label }} + + + {{ statusMap[row.status].label }} + + + РКН pending + + + {{ row.title }} + + Категория: {{ categoryMap[row.category] }} · Затронуто тенантов: {{ row.affected_tenants }} · + Обнаружен: {{ formatDate(row.detected_at) }} + · Дедлайн РКН: {{ formatDate(row.rkn_deadline_at) }} + + + + + + + + diff --git a/app/resources/js/views/admin/AdminSystemView.vue b/app/resources/js/views/admin/AdminSystemView.vue new file mode 100644 index 00000000..57316f4a --- /dev/null +++ b/app/resources/js/views/admin/AdminSystemView.vue @@ -0,0 +1,127 @@ + + + + + + Система + + Глобальные настройки SaaS: лимиты квот, тарифные планы, фичефлаги. + + + + + Read-only режим. Edit-flow с двойным подтверждением и audit-log подключается отдельным + коммитом. + + + + + system_settings + + + + + + + {{ setting.key }} + + {{ setting.type }} + + + {{ setting.value }} + + {{ setting.description }} · обновлено {{ formatDate(setting.updated_at) }} + + + + + + + + diff --git a/app/tests/Frontend/AdminBillingView.spec.ts b/app/tests/Frontend/AdminBillingView.spec.ts new file mode 100644 index 00000000..64f633e3 --- /dev/null +++ b/app/tests/Frontend/AdminBillingView.spec.ts @@ -0,0 +1,44 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import AdminBillingView from '../../resources/js/views/admin/AdminBillingView.vue'; + +const mountView = async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/admin/billing', component: AdminBillingView }], + }); + await router.push('/admin/billing'); + await router.isReady(); + return mount(AdminBillingView, { + global: { plugins: [createVuetify(), router] }, + }); +}; + +describe('AdminBillingView.vue', () => { + it('монтируется и содержит заголовок «Биллинг»', async () => { + const wrapper = await mountView(); + expect(wrapper.text()).toContain('Биллинг'); + }); + + it('содержит 4 stats: MRR / Выручка / Просрочка / Возвраты', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('MRR'); + expect(text).toContain('Выручка за месяц'); + expect(text).toContain('Просрочка'); + expect(text).toContain('Возвраты за 30 дн'); + }); + + it('содержит таблицу тенантов с tariff/balance/MRR/status', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('Окна Москва'); + expect(text).toContain('BigCorp'); + expect(text).toContain('Команда'); + expect(text).toContain('Активен'); + expect(text).toContain('Просрочка'); + expect(text).toContain('Заблокирован'); + }); +}); diff --git a/app/tests/Frontend/AdminIncidentsView.spec.ts b/app/tests/Frontend/AdminIncidentsView.spec.ts new file mode 100644 index 00000000..d15ce204 --- /dev/null +++ b/app/tests/Frontend/AdminIncidentsView.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import AdminIncidentsView from '../../resources/js/views/admin/AdminIncidentsView.vue'; + +const mountView = async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/admin/incidents', component: AdminIncidentsView }], + }); + await router.push('/admin/incidents'); + await router.isReady(); + return mount(AdminIncidentsView, { + global: { plugins: [createVuetify(), router] }, + }); +}; + +describe('AdminIncidentsView.vue', () => { + it('монтируется и содержит заголовок «Инциденты»', async () => { + const wrapper = await mountView(); + expect(wrapper.text()).toContain('Инциденты'); + }); + + it('содержит 3 stats: Открыто / Расследуется / РКН-уведомлений', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('Открыто'); + expect(text).toContain('Расследуется'); + expect(text).toContain('РКН-уведомлений'); + }); + + it('содержит фильтр-toggle по статусам (5 значений)', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('Все'); + expect(text).toContain('Открыты'); + expect(text).toContain('Решены'); + expect(text).toContain('Закрыты'); + }); + + it('показывает PDN-breach с РКН pending chip', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('Утечка ПДн'); + expect(text).toContain('РКН pending'); + }); + + it('содержит incident_id в формате INC-YYYY-MMDD-NNNN', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('INC-2026-0507-0034'); + expect(text).toContain('INC-2026-0506-0028'); + }); +}); diff --git a/app/tests/Frontend/AdminSystemView.spec.ts b/app/tests/Frontend/AdminSystemView.spec.ts new file mode 100644 index 00000000..aa8786e8 --- /dev/null +++ b/app/tests/Frontend/AdminSystemView.spec.ts @@ -0,0 +1,52 @@ +import { describe, it, expect } from 'vitest'; +import { mount } from '@vue/test-utils'; +import { createVuetify } from 'vuetify'; +import { createRouter, createMemoryHistory } from 'vue-router'; +import AdminSystemView from '../../resources/js/views/admin/AdminSystemView.vue'; + +const mountView = async () => { + const router = createRouter({ + history: createMemoryHistory(), + routes: [{ path: '/admin/system', component: AdminSystemView }], + }); + await router.push('/admin/system'); + await router.isReady(); + return mount(AdminSystemView, { + global: { plugins: [createVuetify(), router] }, + }); +}; + +describe('AdminSystemView.vue', () => { + it('монтируется и содержит заголовок «Система»', async () => { + const wrapper = await mountView(); + expect(wrapper.text()).toContain('Система'); + }); + + it('показывает read-only warning', async () => { + const wrapper = await mountView(); + expect(wrapper.text()).toContain('Read-only'); + }); + + it('перечисляет ключевые system_settings (rate-limit, retention, login_max_attempts)', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('webhook_rate_limit_rps'); + expect(text).toContain('login_max_attempts'); + expect(text).toContain('password_min_length'); + expect(text).toContain('webhook_log_retention_days'); + expect(text).toContain('maintenance_mode'); + }); + + it('содержит type-chip для каждой строки (int/string/bool/json)', async () => { + const wrapper = await mountView(); + const text = wrapper.text(); + expect(text).toContain('int'); + expect(text).toContain('bool'); + }); + + it('число строк settings = 7 (mock count)', async () => { + const wrapper = await mountView(); + const rows = wrapper.findAll('[data-testid="setting-row"]'); + expect(rows.length).toBe(7); + }); +}); diff --git a/docs/Открытые_вопросы_v8_3.md b/docs/Открытые_вопросы_v8_3.md index 4ac1cfbc..bf3ecd3f 100644 --- a/docs/Открытые_вопросы_v8_3.md +++ b/docs/Открытые_вопросы_v8_3.md @@ -2,7 +2,20 @@ **Назначение:** единый рабочий список вопросов, требующих решения заказчика для разблокировки разработки. Разбит по адресатам, внутри — по приоритету. -**Версия:** 1.51 от 09.05.2026 — **Email-уведомление при 3 неудачных попытках входа (ТЗ §22.4.4 п.3)**. Закрыт последний пункт ТЗ §22.4.4 анти-брутфорс. **Pest 111/111 + Vitest 166/166 + Histoire 21/28 зелёные**. +**Версия:** 1.52 от 09.05.2026 — **Admin views (Биллинг / Инциденты / Система)**. Закрыт пункт #8: 3 placeholder'а заменены на реальные display-views с mock-данными по schema v8.7. **Pest 111/111 + Vitest 179/179 + Histoire 21/28 зелёные**. + +**Что изменилось в v1.52 относительно v1.51:** + +- **`AdminBillingView`** — 4 stats (MRR / Выручка / Просрочка / Возвраты) + v-data-table 7 колонок + поиск. +- **`AdminIncidentsView`** — 3 stats (Открыто / Расследуется / РКН-уведомлений) + 5 фильтров + v-list с incident_id, severity, status, РКН-pending chip для PDN-breach. +- **`AdminSystemView`** — read-only warning + 7 system_settings из mock + type-chip + поиск. +- **`composables/mockAdmin.ts`** — 3 типа + mock-данные. +- Маршруты `/admin/{billing,incidents,system}` ведут на реальные views (не placeholder). +- **Vitest +13** (всего **179/179 за 11.98 сек**). +- **Регресс зелёный:** lint+type+format OK; build 743ms; story:build 21/28 за 31.5с. +- **Что НЕ сделано:** #9 Impersonation flow (Ю-1). + +**Что изменилось в v1.51 относительно v1.50:** **Email-уведомление при 3 неудачных попытках входа (ТЗ §22.4.4 п.3)**. Закрыт последний пункт ТЗ §22.4.4 анти-брутфорс. **Pest 111/111 + Vitest 166/166 + Histoire 21/28 зелёные**. **Что изменилось в v1.51 относительно v1.50:**
+ Сводка по всем тенантам: выручка, MRR, просрочки, возвраты. +
+ Журнал инцидентов SaaS-уровня. PDN-breach — уведомление РКН за 24 ч (152-ФЗ). +
+ Глобальные настройки SaaS: лимиты квот, тарифные планы, фичефлаги. +