docs(plan): Sprint 5A — Auth polish (A1/A4/A5/A6/A8)

План portal-audit Sprint 5 под-план A: 5 P2 UX-debt эпиков подсистемы
Auth — A1 (Yandex SSO disabled+tooltip), A4 (ResetPassword confirm
mismatch error), A5 (ForgotPassword fallback regression-тест),
A6 (TwoFactor реальный TOTP-отсчёт), A8 (DemoSeeder demo:seed + README).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-17 02:20:50 +03:00
parent 72c8cad963
commit fef9499e1a
@@ -0,0 +1,558 @@
# Sprint 5A — Auth polish Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Закрыть 5 P2-эпиков подсистемы Auth из portal-wide аудита (A1, A4, A5, A6, A8) — Sprint 5, под-план A.
**Architecture:** Точечные правки 4 auth-view'ов (Vue 3 + Vuetify 3) + DemoSeeder-тулинг. Каждый эпик — отдельная атомарная задача с TDD-циклом (failing test → impl → green → commit). A5 — задача-характеризация (regression-тест), т.к. находка аудита не воспроизводится против текущего кода.
**Tech Stack:** Vue 3.5 + Vuetify 3.12 + TypeScript, Vitest 4 (frontend), Pest 4 (backend), Laravel 13.
**Источник:** [docs/superpowers/specs/2026-05-15-portal-audit-design.md](../specs/2026-05-15-portal-audit-design.md) §3 Sprint 5 + §4 раздел A.
**Исполнять в изолированном worktree** off `origin/main` (`c64be74`): текущая ветка `feat/sprint3f-api-middleware` отстала от origin/main и содержит несвязанный staged WIP (`automation-graph.html`, `dev-indices.json`) — его НЕ трогать. Ветка спринта: `feat/sprint5a-auth-polish`.
---
## Эпики (из аудита §4.A)
| ID | Объект | Находка | Решение |
|---|---|---|---|
| A1 | `LoginView.vue` Yandex 360 SSO | dead stub без `:disabled` | disabled + tooltip «после Б-1» |
| A4 | `ResetPasswordView.vue` поле подтверждения | нет `:error-messages` на несовпадение | computed-ошибка «Пароли не совпадают» |
| A5 | `ForgotPasswordView.vue` catch | аудит: «fallback недостижим» | **не воспроизводится** → regression-тест, фиксирующий достижимость |
| A6 | `TwoFactorView.vue` таймер | хардкод «02:34» | реальный обратный отсчёт TOTP-окна (30 с) |
| A8 | DemoSeeder | «422 при логине» (не пере-сидирован) | `composer demo:seed` + раздел в README + idempotency-тест |
## File Structure
| Файл | Ответственность | Задача |
|---|---|---|
| `app/resources/js/views/auth/LoginView.vue` | экран входа — SSO-кнопка в disabled-tooltip обёртке | T1 (A1) |
| `app/resources/js/views/auth/ResetPasswordView.vue` | экран нового пароля — computed-ошибка подтверждения | T2 (A4) |
| `app/resources/js/views/auth/ForgotPasswordView.vue` | без правок — только regression-тест | T3 (A5) |
| `app/resources/js/views/auth/TwoFactorView.vue` | экран 2FA — таймер TOTP-окна на `setInterval` | T4 (A6) |
| `app/composer.json` | +script `demo:seed` | T5 (A8) |
| `app/README.md` | +раздел «Демо-данные» | T5 (A8) |
| `app/tests/Feature/DemoSeederTest.php` | новый — idempotency DemoSeeder | T5 (A8) |
| `app/tests/Frontend/{LoginView,ResetPasswordView,ForgotPasswordView,TwoFactorView}.spec.ts` | +по 1 тесту на эпик | T1-T4 |
**Команды (запускать из `app/`):**
- Один Vitest-файл: `npx vitest run tests/Frontend/<File>.spec.ts`
- Один Vitest-тест: `npx vitest run tests/Frontend/<File>.spec.ts -t "<имя>"`
- Pest-файл: `php artisan test tests/Feature/DemoSeederTest.php`
- Полная регрессия: `npm run test:vue`, `php artisan test`, `npm run type-check`, `npm run lint:vue`, `composer pint`
---
## Task 1: A1 — LoginView Yandex 360 SSO disabled + tooltip
**Files:**
- Modify: `app/resources/js/views/auth/LoginView.vue:107` (SSO-кнопка) + `<style>` блок
- Test: `app/tests/Frontend/LoginView.spec.ts` (+1 тест)
- [ ] **Step 1: Написать падающий тест**
Добавить в `app/tests/Frontend/LoginView.spec.ts` внутрь `describe('LoginView.vue', ...)` после последнего `it`:
```ts
it('A1: SSO Yandex 360 — кнопка disabled до подключения Б-1', async () => {
const wrapper = await mountLoginView();
const ssoBtn = wrapper.findAll('button').find((b) => b.text().includes('Yandex 360'));
expect(ssoBtn).toBeDefined();
expect(ssoBtn!.classes()).toContain('v-btn--disabled');
});
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
Run: `npx vitest run tests/Frontend/LoginView.spec.ts -t "A1"`
Expected: FAIL — кнопка не disabled, класс `v-btn--disabled` отсутствует.
- [ ] **Step 3: Реализация**
В `app/resources/js/views/auth/LoginView.vue` заменить строку 107:
```html
<v-btn block size="large" variant="outlined"> Войти через Yandex 360 </v-btn>
```
на (disabled-кнопка не ловит hover — tooltip вешаем на обёртку-`div`):
```html
<v-tooltip
text="Вход через Yandex 360 станет доступен после регистрации юр. лица (Б-1)."
location="top"
>
<template #activator="{ props }">
<div v-bind="props" class="yandex-sso-wrap">
<v-btn block size="large" variant="outlined" disabled>
Войти через Yandex 360
</v-btn>
</div>
</template>
</v-tooltip>
```
В `<style scoped>` добавить после блока `.login-form { ... }`:
```css
.yandex-sso-wrap {
width: 100%;
}
```
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
Run: `npx vitest run tests/Frontend/LoginView.spec.ts`
Expected: PASS — все тесты файла (старые 6 + новый A1). Старый тест «содержит ... Yandex 360 SSO» проходит — текст кнопки сохранён.
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/views/auth/LoginView.vue app/tests/Frontend/LoginView.spec.ts
git commit -m "feat(auth): A1 — Yandex 360 SSO disabled + tooltip (Sprint 5A)"
```
---
## Task 2: A4 — ResetPasswordView ошибка несовпадения паролей
**Files:**
- Modify: `app/resources/js/views/auth/ResetPasswordView.vue` (script + поле подтверждения, строки 110-118)
- Test: `app/tests/Frontend/ResetPasswordView.spec.ts` (+1 тест)
- [ ] **Step 1: Написать падающий тест**
Добавить в `app/tests/Frontend/ResetPasswordView.spec.ts` внутрь `describe('ResetPasswordView.vue', ...)` после последнего `it`:
```ts
it('A4: показывает ошибку при несовпадении пароля и подтверждения', async () => {
const wrapper = await mountReset();
const pwInputs = wrapper.findAll('input[type="password"]');
await pwInputs[0].setValue('new-strong-pass-1234');
await pwInputs[1].setValue('different-pass-9999');
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Пароли не совпадают');
});
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts -t "A4"`
Expected: FAIL — текст «Пароли не совпадают» отсутствует (у поля подтверждения нет `:error-messages`).
- [ ] **Step 3: Реализация**
В `app/resources/js/views/auth/ResetPasswordView.vue` в `<script setup>` добавить после объявления `canSubmit` (после строки 38, перед `async function handleSubmit`):
```ts
/**
* Ошибка поля подтверждения: client-side проверка совпадения +
* проброс backend-ошибки `password_confirmation` если придёт с 422.
*/
const confirmationError = computed<string[]>(() => {
if (passwordConfirmation.value.length > 0 && password.value !== passwordConfirmation.value) {
return ['Пароли не совпадают'];
}
return errors.value.password_confirmation ?? [];
});
```
В `<template>` заменить блок поля «Повторите пароль» (строки 110-118):
```html
<v-text-field
v-model="passwordConfirmation"
label="Повторите пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
required
/>
```
на:
```html
<v-text-field
v-model="passwordConfirmation"
label="Повторите пароль"
:type="showPassword ? 'text' : 'password'"
autocomplete="new-password"
variant="outlined"
density="comfortable"
required
:error-messages="confirmationError"
/>
```
(`computed` уже импортирован — строка 17: `import { computed, ref } from 'vue';`.)
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
Run: `npx vitest run tests/Frontend/ResetPasswordView.spec.ts`
Expected: PASS — старые 5 тестов + новый A4. Тест «успешный submit» проходит: при совпадающих паролях `confirmationError` пустой.
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/views/auth/ResetPasswordView.vue app/tests/Frontend/ResetPasswordView.spec.ts
git commit -m "feat(auth): A4 — ResetPassword ошибка несовпадения паролей (Sprint 5A)"
```
---
## Task 3: A5 — ForgotPasswordView regression-тест generic fallback
> **NB:** Находка аудита A5 «fallback недостижим» **не воспроизводится** против текущего кода (`extractValidationErrors` возвращает строго `Record|null`; store сбрасывает `lockoutSeconds=null` в начале запроса). Эта задача — не TDD-фикс, а **характеризационный regression-тест**, фиксирующий, что generic-fallback показывается на не-валидационной/не-429 ошибке. Код view НЕ меняется.
**Files:**
- Test: `app/tests/Frontend/ForgotPasswordView.spec.ts` (+1 тест)
- (правок в `ForgotPasswordView.vue` не предполагается)
- [ ] **Step 1: Написать характеризационный тест**
Добавить в `app/tests/Frontend/ForgotPasswordView.spec.ts` внутрь `describe('ForgotPasswordView.vue', ...)` после последнего `it`:
```ts
it('A5: при не-валидационной ошибке (500/network) показывает generic fallback', async () => {
// forgotPassword отклоняется обычной ошибкой; extractValidationErrors и
// extractRateLimitRetry замоканы → null (см. vi.mock в шапке файла).
vi.mocked(authApi.forgotPassword).mockRejectedValue(new Error('Network Error'));
const wrapper = await mountForgot();
await wrapper.find('input[type="email"]').setValue('user@example.ru');
await wrapper.find('form').trigger('submit.prevent');
await wrapper.vm.$nextTick();
await wrapper.vm.$nextTick();
expect(wrapper.text()).toContain('Произошла ошибка. Попробуйте позже.');
});
```
- [ ] **Step 2: Запустить тест**
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts -t "A5"`
Expected: **PASS** — поведение fallback корректно, находка не воспроизводится.
- [ ] **Step 3: Развилка по результату Step 2**
- **Если PASS** (ожидаемо) → A5 классифицируется как «verified — not reproduced»; код view не меняется; переходим к Step 4.
- **Если FAIL** (неожиданно — реальный баг) → применить фикс в `app/resources/js/views/auth/ForgotPasswordView.vue`, заменив блок `catch` (строки 32-39):
```ts
} catch (error: unknown) {
const validationErrors = extractValidationErrors(error);
if (validationErrors && Object.keys(validationErrors).length > 0) {
errors.value = validationErrors;
} else if (auth.lockoutSeconds === null) {
errors.value = { email: ['Произошла ошибка. Попробуйте позже.'] };
}
}
```
Перезапустить Step 2 — добиться PASS, и `git add` также файл view.
- [ ] **Step 4: Запустить весь файл — убедиться, что все проходят**
Run: `npx vitest run tests/Frontend/ForgotPasswordView.spec.ts`
Expected: PASS — старые 5 тестов + новый A5.
- [ ] **Step 5: Commit**
```bash
git add app/tests/Frontend/ForgotPasswordView.spec.ts
git commit -m "test(auth): A5 — regression generic fallback ForgotPassword (Sprint 5A)"
```
(При срабатывании развилки FAIL — добавить в `git add` также `app/resources/js/views/auth/ForgotPasswordView.vue` и заменить тип коммита на `fix(auth): A5 — ...`.)
---
## Task 4: A6 — TwoFactorView реальный обратный отсчёт TOTP-окна
**Files:**
- Modify: `app/resources/js/views/auth/TwoFactorView.vue` (script: import + countdown-логика + onMounted/onUnmounted; template строка 129)
- Test: `app/tests/Frontend/TwoFactorView.spec.ts` (+1 тест с fake timers)
- [ ] **Step 1: Написать падающий тест**
Добавить в `app/tests/Frontend/TwoFactorView.spec.ts` внутрь `describe('TwoFactorView.vue', ...)` после последнего `it`:
```ts
it('A6: показывает реальный обратный отсчёт TOTP-окна (30 с)', async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(10_000)); // epoch 10 c → 30 - (10 % 30) = 20
try {
const wrapper = await mountTwoFactor();
const el = wrapper.find('[data-testid="totp-countdown"]');
expect(el.exists()).toBe(true);
expect(el.text()).toBe('00:20');
vi.setSystemTime(new Date(15_000)); // epoch 15 c → 30 - 15 = 15
vi.advanceTimersByTime(1000);
await wrapper.vm.$nextTick();
expect(el.text()).toBe('00:15');
} finally {
vi.useRealTimers();
}
});
```
В первой строке файла дополнить импорт vitest — `vi` сейчас не импортируется:
```ts
import { describe, it, expect } from 'vitest';
```
заменить на:
```ts
import { describe, it, expect, vi } from 'vitest';
```
- [ ] **Step 2: Запустить тест — убедиться, что падает**
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts -t "A6"`
Expected: FAIL — элемент `[data-testid="totp-countdown"]` не существует (сейчас хардкод `02:34` без testid).
- [ ] **Step 3: Реализация**
В `app/resources/js/views/auth/TwoFactorView.vue` заменить строку 14 (импорт):
```ts
import { computed, nextTick, onMounted, ref, useTemplateRef } from 'vue';
```
на:
```ts
import { computed, nextTick, onMounted, onUnmounted, ref, useTemplateRef } from 'vue';
```
Заменить блок (строки 28-36) — `userEmail` + `onMounted`:
```ts
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
// прямой URL без login → отправляем на /login.
onMounted(() => {
if (!auth.requires2fa && !auth.isAuthenticated) {
router.replace('/login');
}
});
```
на:
```ts
const userEmail = computed(() => auth.user?.email ?? 'аккаунт');
/**
* TOTP-окно: код в приложении-аутентификаторе меняется каждые 30 секунд.
* Показываем честный обратный отсчёт до смены кода (заменяет хардкод «02:34»).
* Значение 30..1 секунд, формат «00:NN».
*/
function totpWindowLeft(): number {
return 30 - (Math.floor(Date.now() / 1000) % 30);
}
const totpSecondsLeft = ref(totpWindowLeft());
const totpCountdown = computed(() => `00:${String(totpSecondsLeft.value).padStart(2, '0')}`);
let totpTimer: ReturnType<typeof setInterval> | undefined;
// Если попали на /2fa без pending state (requires2fa=false и не залогинен) —
// прямой URL без login → отправляем на /login.
onMounted(() => {
if (!auth.requires2fa && !auth.isAuthenticated) {
router.replace('/login');
}
totpTimer = setInterval(() => {
totpSecondsLeft.value = totpWindowLeft();
}, 1000);
});
onUnmounted(() => {
if (totpTimer) clearInterval(totpTimer);
});
```
В `<template>` заменить строку 129:
```html
<span class="text-caption text-medium-emphasis font-mono">02:34</span>
```
на:
```html
<span
class="text-caption text-medium-emphasis font-mono"
:title="`До смены кода в приложении: ${totpCountdown}`"
data-testid="totp-countdown"
>{{ totpCountdown }}</span
>
```
- [ ] **Step 4: Запустить тест — убедиться, что проходит**
Run: `npx vitest run tests/Frontend/TwoFactorView.spec.ts`
Expected: PASS — старые 3 теста + новый A6. Старые тесты используют реальные таймеры; `setInterval` чистится через `onUnmounted` при teardown.
- [ ] **Step 5: Commit**
```bash
git add app/resources/js/views/auth/TwoFactorView.vue app/tests/Frontend/TwoFactorView.spec.ts
git commit -m "feat(auth): A6 — реальный обратный отсчёт TOTP-окна в 2FA (Sprint 5A)"
```
---
## Task 5: A8 — DemoSeeder re-seed script + README + idempotency-тест
**Files:**
- Create: `app/tests/Feature/DemoSeederTest.php`
- Modify: `app/composer.json` (блок `scripts`)
- Modify: `app/README.md` (+раздел «Демо-данные»)
- [ ] **Step 1: Написать падающий тест**
Создать `app/tests/Feature/DemoSeederTest.php`:
```php
<?php
declare(strict_types=1);
use App\Models\Tenant;
use App\Models\User;
use Database\Seeders\DemoSeeder;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
it('DemoSeeder идемпотентен — повторный запуск не дублирует demo-tenant и admin', function () {
$this->seed(DemoSeeder::class);
$this->seed(DemoSeeder::class);
expect(Tenant::query()->where('subdomain', 'demo')->count())->toBe(1)
->and(User::query()->where('email', 'admin@demo.local')->count())->toBe(1);
});
```
- [ ] **Step 2: Запустить тест — убедиться, что он есть и проходит/падает осознанно**
Run: `php artisan test tests/Feature/DemoSeederTest.php`
Expected: **PASS** — DemoSeeder уже идемпотентен (`updateOrCreate`/`updateOrInsert`). Тест фиксирует это как регрессионную защиту «re-seed скрипта».
Если FAIL — значит сидер не идемпотентен; это реальный баг, исправить `DemoSeeder.php` (привести вставки к `updateOrInsert` с ключом `tenant_id`+`name`) и добиться PASS.
- [ ] **Step 3: Добавить composer-script `demo:seed`**
В `app/composer.json` в блоке `"scripts"` добавить строку после `"audit-offline"`:
Заменить:
```json
"audit-offline": "@composer audit --locked",
"ide-helper": [
```
на:
```json
"audit-offline": "@composer audit --locked",
"demo:seed": "@php artisan db:seed --class=DemoSeeder --force",
"ide-helper": [
```
- [ ] **Step 4: Добавить раздел в `app/README.md`**
Дописать в конец файла `app/README.md` раздел:
```markdown
## Демо-данные (dev)
Демо-tenant создаётся `DemoSeeder` автоматически при `composer setup` /
`php artisan migrate --seed` в окружениях `local` и `testing`
(см. `DatabaseSeeder` — в `production` DemoSeeder не запускается).
**Учётные данные демо-входа:**
- URL: `/login`
- Email: `admin@demo.local`
- Пароль: `password`
Что создаётся: demo-tenant (`subdomain=demo`, баланс 1000 ₽ / 100 лидов),
admin-пользователь, 3 проекта (сайт/звонок/СМС) и ~14 демо-сделок.
**Пере-сидировать демо-данные** (идемпотентно, существующие записи обновляются,
дублей не создаётся):
```bash
composer demo:seed
```
Эквивалент: `php artisan db:seed --class=DemoSeeder --force`.
Если при логине демо-аккаунта возвращается 422 — демо-данные не засеяны
на текущей dev-БД (например, после `migrate:fresh`); запустите `composer demo:seed`.
```
- [ ] **Step 5: Проверить `demo:seed` вручную + запустить тест**
Run: `composer demo:seed`
Expected: вывод содержит `Demo tenant id=... subdomain=demo` и `Login: admin@demo.local / password`.
Run: `php artisan test tests/Feature/DemoSeederTest.php`
Expected: PASS.
- [ ] **Step 6: Commit**
```bash
git add app/tests/Feature/DemoSeederTest.php app/composer.json app/README.md
git commit -m "feat(dev): A8 — composer demo:seed + README демо-данные + idempotency-тест (Sprint 5A)"
```
---
## Task 6: Регрессия Sprint 5A
**Files:** нет правок — финальный gate.
- [ ] **Step 1: Полный Vitest**
Run (из `app/`): `npm run test:vue`
Expected: все файлы зелёные, 0 failed (4 новых теста A1/A4/A5/A6 + дельта).
- [ ] **Step 2: Полный Pest**
Run (из `app/`): `php artisan test`
Expected: 0 failed (новый `DemoSeederTest` зелёный).
- [ ] **Step 3: Type-check + lint + формат**
Run (из `app/`):
```
npm run type-check
npm run lint:vue
composer pint
```
Expected: vue-tsc 0 ошибок; ESLint 0 ошибок; Pint без изменений (или авто-формат закоммитить отдельным `style:`-коммитом).
- [ ] **Step 4: Зафиксировать результат**
Выписать в финальный отчёт фактические числа Pest/Vitest (passed/failed/skipped) с указанием дельты. Если что-то красное — НЕ заявлять Sprint 5A закрытым; чинить по `superpowers:systematic-debugging`.
---
## Self-Review
**1. Spec coverage:** A1 ✅ T1 / A4 ✅ T2 / A5 ✅ T3 (re-classified verify) / A6 ✅ T4 / A8 ✅ T5. Все 5 эпиков раздела A из аудита §3 Sprint 5 покрыты.
**2. Placeholder scan:** код приведён полностью в каждом шаге; команды и Expected — конкретны. Развилка T3 Step 3 содержит готовый фикс-код, не «TODO». Раздел README — полный текст.
**3. Type consistency:** `confirmationError` (T2) — `computed<string[]>`, совместим с `:error-messages`. `totpWindowLeft()`/`totpSecondsLeft`/`totpCountdown`/`totpTimer` (T4) — имена консистентны между script и template (`data-testid="totp-countdown"` ↔ тест T4 Step 1). `mountLoginView`/`mountReset`/`mountForgot`/`mountTwoFactor` — существующие хелперы spec-файлов, не переопределяются.
**Известное ограничение:** A5 — задача-характеризация, не фикс (находка аудита не воспроизводится). При закрытии Sprint 5A зафиксировать статус A5 как «verified — not reproduced» в отчёте/реестре.