# 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» в отчёте/реестре.
