# G1/SP3a — фронт входа: регистрация + подтверждение почты (Vue 3 / Vuetify 3)

**Дата:** 18.06.2026. **Спринт:** G1 (самозапись клиента), под-спринт SP3a (первый кусок фронта SP3).
**Предшественник:** SP1 (backend самозаписи, коммит `53fb7b77`), SP2 (backend реквизитов).
**Слой:** frontend (Vue 3 + Vuetify 3 + Pinia + vue-router). **Проверка — Playwright** (vitest сломан, находка G8).

## Цель {#goal}

Подключить фронт к новому backend-потоку самозаписи SP1: пользователь регистрируется, получает
6-значный код на почту, вводит его на экране подтверждения, после чего автоматически входит в
систему. Текущий `RegisterView.vue` устарел (зовёт старый контракт `auth.register` и сразу уходит
на `/2fa`/`/dashboard`) — переделать под новый поток; добавить экран подтверждения почты.

## Границы {#scope}

**Входит (SP3a):**
- Переработка `api/auth.ts` (`register` под новый контракт, новые `confirmEmail`, `resendCode`).
- Переработка `stores/auth.ts` (`register` без установки user; новые `confirmEmail`, `resendCode`,
  поля `pendingEmail`/`pendingDevCode`).
- Переработка `RegisterView.vue` (шов капчи; переход на подтверждение вместо `/2fa`/`/dashboard`).
- Новый `ConfirmEmailView.vue` (ввод кода, повторная отправка, отсчёт, ошибки).
- Роут `/confirm-email` (публичный, под `AuthLayout`).

**НЕ входит (отложено):**
- Реальная Yandex SmartCaptcha — встаёт в шов позже (ops). SP3a — видимая заглушка.
- Форма реквизитов, гейт первого проекта, личный кабинет — SP3b/SP3c. После подтверждения
  ведём на `/dashboard`, не на реквизиты.
- vitest-тесты (G8 сломан). Проверка — Playwright.
- Реальная отправка письма — на dev backend отдаёт код в ответе (`_dev_plain_code`).

## Контракты backend (SP1, фиксируем) {#contracts}

- **`POST /api/auth/register`** body `{email, password, accept_offer, accept_pdn, captcha_token}`
  (все обязательны; `accept_*` — `accepted`; `captcha_token` — `required string`).
  - `201` → `{status, email, expires_at, _dev_plain_code?}` (`_dev_plain_code` только на dev).
  - `422` → `{message, errors:{<field>:[...]}}` (`captcha_token` / `email` — `email_taken`).
- **`POST /api/auth/confirm-email`** body `{email, code}` (`code` — 6 цифр).
  - `200` → `{user, requires_2fa:false}` (backend логинит через сессию).
  - `422` → `{message, reason, attempts_remaining?}`.
- **`POST /api/auth/resend-code`** body `{email}`.
  - `200` → `{message, _dev_plain_code?}` (всегда 200 — не раскрываем существование аккаунта).
- Троттлинг `auth-register` (20/мин на IP) — фронт показывает retry при 429.

## Компоненты и данные {#components}

### `api/auth.ts`
- `register(payload: RegisterPayload): Promise<RegisterResult>` где
  `RegisterPayload = {email, password, accept_offer, accept_pdn, captcha_token}`,
  `RegisterResult = {status: string, email: string, expires_at: string, _dev_plain_code?: string}`.
- `confirmEmail(payload: {email: string, code: string}): Promise<{user: AuthUser}>`.
- `resendCode(email: string): Promise<{message: string, _dev_plain_code?: string}>`.
- Удалить старый возврат `{user, requires_2fa}` из `register` (тип меняется).

### `stores/auth.ts`
- `register(payload)` — **не** ставит `user`; сохраняет `pendingEmail = result.email`,
  `pendingDevCode = result._dev_plain_code ?? null`; возвращает `result`.
- `confirmEmail({email, code})` — при успехе ставит `user`, чистит `pendingEmail`/`pendingDevCode`.
- `resendCode(email)` — обновляет `pendingDevCode` из ответа (если есть).
- Новые ref-поля: `pendingEmail: string | null`, `pendingDevCode: string | null`.
- `register` использует `extractRateLimitRetry` (как `login`) → `lockoutSeconds` при 429.

### Шов капчи (frontend) {#captcha}
Видимый чекбокс-заглушка (решение владельца). Компонент-слот в `RegisterView`:
- На dev (нет реального провайдера) — `v-checkbox` «Подтвердите, что вы не робот»; при включении
  выставляет `captchaToken = 'dev-captcha-stub'` (бэкенд `NullCaptchaVerifier` принимает любой).
- Слот остаётся видимым — позже реальная Yandex SmartCaptcha заменит заглушку (выдаёт реальный
  токен виджетом). Зеркалит backend-шов (`CaptchaVerifier` ↔ `NullCaptchaVerifier`).
- `canSubmit` дополнительно требует непустой `captchaToken`.

### `RegisterView.vue`
- Поля: email, password (+индикатор силы), 2 чекбокса (оферта, ПДн) — уже есть; добавить капча-слот.
- `handleSubmit`: `auth.register({email, password, accept_offer, accept_pdn, captcha_token})`;
  при успехе `router.push('/confirm-email')` (email уже в `auth.pendingEmail`). Ветку
  `requires_2fa` убрать. Ошибки 422 → `errors` по полям (email/captcha_token).

### `ConfirmEmailView.vue` (новый)
- Читает `auth.pendingEmail`; если пусто (перезагрузка страницы) — показывает поле email + просит
  ввести (fallback). Показывает email, на который отправлен код.
- Поле кода: одно `v-text-field` (6 цифр, `inputmode="numeric"`, маска/валидация 6 цифр).
- Кнопка «Подтвердить» → `auth.confirmEmail({email, code})`; успех → `router.push('/dashboard')`.
- Кнопка «Отправить код повторно» → `auth.resendCode(email)`; кулдаун 30 сек (локальный таймер,
  кнопка disabled с обратным отсчётом); тост/строка «Новый код отправлен».
- Обратный отсчёт жизни кода по `expires_at` (если истёк — подсказка «Запросите новый код»).
- Ошибка 422: показать `message`; если есть `attempts_remaining` — «Осталось попыток: N».
- **Dev-подсказка:** если `auth.pendingDevCode` задан — строка «Код (dev): NNNNNN» (только когда
  бэкенд вернул `_dev_plain_code`; на проде поле отсутствует → подсказки нет).

### `router/index.ts`
- Добавить публичный роут `{ path: '/confirm-email', name: 'confirm-email', component: ConfirmEmailView }`
  под `AuthLayout` (как `/register`/`/login`); без auth-guard (пользователь ещё не залогинен).

## Поток данных {#flow}

```
RegisterView → auth.register() → 201 {email, expires_at, _dev_code?}
   → auth.pendingEmail/pendingDevCode заполнены → router.push('/confirm-email')
ConfirmEmailView → читает auth.pendingEmail → ввод кода
   → auth.confirmEmail({email, code}) → 200 {user} → session-login → router.push('/dashboard')
   (resend → auth.resendCode(email) → новый код; cooldown 30s)
```

## Обработка ошибок {#errors}

- register 422 → `errors` по полям (email «уже существует», captcha «не пройдена»).
- register 429 → `lockoutSeconds` (как в login) — строка «Слишком много попыток, подождите N сек».
- confirm 422 → `message` + `attempts_remaining` (если есть).
- resend → всегда успех (200) → тост; не раскрываем существование аккаунта.
- Сетевая/прочая ошибка → общая строка «Произошла ошибка, попробуйте позже».

## Проверка (Playwright) {#verify}

vitest сломан (G8) — фронт проверяем живым прогоном:
1. Собрать фронт (`npm run build` или dev-сервер), backend на `127.0.0.1:8000`.
2. Открыть `/register`, заполнить новый email + пароль + оба чекбокса + капча-заглушка → submit.
3. Перейти на `/confirm-email`, взять код из dev-подсказки (`_dev_plain_code`), ввести → submit.
4. Убедиться: редирект на `/dashboard`, `auth.isAuthenticated` true.
5. Негатив: неверный код → 422 + `attempts_remaining`; «отправить повторно» → новый код, кулдаун.

## Файлы (карта реализации) {#files}

- Изменить: `app/resources/js/api/auth.ts`, `app/resources/js/stores/auth.ts`,
  `app/resources/js/views/auth/RegisterView.vue`, `app/resources/js/router/index.ts`.
- Создать: `app/resources/js/views/auth/ConfirmEmailView.vue`
  (опц. `app/resources/js/components/CaptchaStub.vue` — выделенный капча-слот, если так чище).

## Открытые допущения (умолчания) {#assumptions}

- После подтверждения — на `/dashboard` (гейт реквизитов — SP3b).
- Кулдаун повторной отправки — 30 сек (клиентский).
- Капча на dev — `'dev-captcha-stub'`; реальная Yandex SmartCaptcha — позже.
- Email между экранами — через store (`pendingEmail`), не через URL (не светим email в адресе);
  fallback на ручной ввод email при перезагрузке `/confirm-email`.
