docs(plan): Sprint 2 Plan A — Auth (A2/A3 orphan delete + A7 legal pages)

Sprint 2 (P1 wave 1) split into 3 sub-plans per writing-plans
scope-check (Auth / Settings / Billing — independent subsystems).
Plan A covers the Auth subsystem:
- A2/A3: delete orphaned /recovery RecoveryCodesView (real flow lives
  in Settings -> Безопасность; user-approved deletion 2026-05-15).
- A7: /legal/offer + /legal/privacy stub pages via one DRY LegalDocView.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Дмитрий
2026-05-15 19:21:30 +03:00
parent b5092f091c
commit 50dbee8a97
@@ -0,0 +1,424 @@
# Sprint 2 Plan A — Auth subsystem (A2/A3 + A7) 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:** Remove the orphaned `/recovery` RecoveryCodesView page (dead code) and make the `/legal/offer` & `/legal/privacy` footer links resolve to real stub pages instead of a 404.
**Architecture:** Two independent, atomic changes. (1) Delete the `/recovery` route + `RecoveryCodesView.vue` + its story + spec — the real 2FA recovery-codes flow lives entirely in Settings → Безопасность (`TwoFactorCard.vue` setup wizard shows codes inline; `RecoveryCodesCard.vue` regenerates them), and nothing in the UI navigates to `/recovery`. (2) Add a single DRY `LegalDocView.vue` served by a `/legal/:doc(offer|privacy)` route, rendering an honest "document being finalized" stub (real legal text needs юр. редактура — реестр K3 / blocker Б-1). Upgrade the AuthLayout footer links from raw `<a href>` to `<RouterLink>`.
**Tech Stack:** Vue 3 `<script setup>`, Vuetify 3, vue-router 4 (`createWebHistory`, lazy imports), Vitest + `@vue/test-utils`, Laravel 13 (`routes/web.php` SPA `Route::view`).
---
## Context for the implementer
- **Working directory:** `c:\моя\проекты\портал crm\Документация`. The Laravel app is under `app/`. Frontend = `app/resources/js/`, frontend tests = `app/tests/Frontend/`.
- **Branch:** `main` (consistent with Sprint 1 — implementers commit atomically directly to `main`). Do **not** push (user pushes manually).
- **Commands** (run from `app/`):
- Vitest: `npm run test:vue`
- Type-check: `npm run type-check`
- ESLint: `npm run lint:vue`
- **Baseline (fresh, 2026-05-15):** Pest `742/739/3sk/0`, Vitest `92 files / 774 passed / 3 skipped / 0 failed`, vue-tsc `0`, ESLint `0`.
- **Lefthook pre-commit** runs gitleaks / markdownlint / cspell / eslint-vue / pint / larastan on staged files — must stay green. Do **not** bypass hooks (`--no-verify` forbidden).
- **`app/dev-indices.json`** is a pre-existing **uncommitted** dev artifact (audit I2 — Sprint 6 cleanup). Do **NOT** stage or modify it. Stage only the exact files each task names.
- **Why `/recovery` is orphaned (verified):** grep across `app/resources/js` finds **no** `router.push('/recovery')` and **no** `<RouterLink to="/recovery">`. The only references are the route definition itself, the Histoire stub, the `.story.vue`, and the view file. `TwoFactorView.vue:126` links `/recovery-use` (a different page — consuming a code to log in), which is **kept**. The real codes-display is `TwoFactorCard.vue` setup wizard step `'codes'` and `RecoveryCodesCard.vue` regeneration — both already use the real API (`twoFactorConfirm` / `twoFactorRegenerateRecoveryCodes`).
---
## File Structure
| File | Action | Responsibility |
|---|---|---|
| `app/resources/js/views/auth/RecoveryCodesView.vue` | **Delete** | Orphaned mock recovery-codes page |
| `app/resources/js/views/auth/RecoveryCodesView.story.vue` | **Delete** | Histoire story for the orphan |
| `app/tests/Frontend/RecoveryCodesView.spec.ts` | **Delete** | Spec for the orphan (4 tests) |
| `app/resources/js/router/index.ts` | Modify | Remove `/recovery` route; add `/legal/:doc` route |
| `app/routes/web.php` | Modify | Remove `Route::view('/recovery')`; add 2 legal `Route::view` |
| `app/resources/js/histoire.setup.ts` | Modify | Remove `/recovery` Histoire stub route |
| `app/resources/js/views/legal/LegalDocView.vue` | **Create** | One DRY view for offer + privacy stub docs |
| `app/tests/Frontend/LegalDocView.spec.ts` | **Create** | Spec for the legal view (4 tests) |
| `app/resources/js/layouts/AuthLayout.vue` | Modify | Footer links `<a href>``<RouterLink>` |
---
## Task 1: Delete orphaned RecoveryCodesView (closes A2 + A3)
**Files:**
- Delete: `app/resources/js/views/auth/RecoveryCodesView.vue`
- Delete: `app/resources/js/views/auth/RecoveryCodesView.story.vue`
- Delete: `app/tests/Frontend/RecoveryCodesView.spec.ts`
- Modify: `app/resources/js/router/index.ts`
- Modify: `app/routes/web.php`
- Modify: `app/resources/js/histoire.setup.ts`
This task has no "failing test first" — it is a verified dead-code removal. Discipline = confirm-then-delete-then-verify-suite-green.
- [ ] **Step 1: Confirm orphan status**
Run (from repo root):
```bash
grep -rn "RecoveryCodesView\|'/recovery'\|\"/recovery\"\|to=\"/recovery\"\|push('/recovery')" app/resources/js app/routes app/tests
```
Expected: matches **only** in `router/index.ts` (the `/recovery` route), `web.php` (`Route::view('/recovery', ...)`), `histoire.setup.ts` (the stub), `RecoveryCodesView.vue` itself, and `RecoveryCodesView.story.vue`. There must be **no** `router.push('/recovery')` and **no** `<RouterLink to="/recovery">`. (`/recovery-use` matches are a different, kept page — ignore them.) If a real navigation to `/recovery` is found, **STOP** and report — the orphan assumption is wrong.
- [ ] **Step 2: Delete the three orphan files**
```bash
rm "app/resources/js/views/auth/RecoveryCodesView.vue"
rm "app/resources/js/views/auth/RecoveryCodesView.story.vue"
rm "app/tests/Frontend/RecoveryCodesView.spec.ts"
```
- [ ] **Step 3: Remove the `/recovery` route from `app/resources/js/router/index.ts`**
Delete this exact route object from the `routes` array (it sits between the `/forgot` route and the `/recovery-use` route):
```ts
{
path: '/recovery',
name: 'recovery',
component: () => import('../views/auth/RecoveryCodesView.vue'),
meta: { layout: 'auth', title: 'Резервные коды', devIndex: 6, devLabel: 'Recovery codes' },
},
```
Keep the `/recovery-use` route (`UseRecoveryCodeView.vue`) untouched. Leaving a gap in `devIndex` numbering (5 → 7) is acceptable — `devIndex` is a temporary feature (audit I1/I3, removed in a later sprint); do **not** renumber other routes.
- [ ] **Step 4: Remove the SPA route from `app/routes/web.php`**
Delete the line:
```php
Route::view('/recovery', 'welcome');
```
Keep `Route::view('/recovery-use', 'welcome');`.
- [ ] **Step 5: Remove the Histoire stub from `app/resources/js/histoire.setup.ts`**
Delete the line:
```ts
{ path: '/recovery', component: { template: '<div />' } },
```
Keep the `/recovery-use` stub line.
- [ ] **Step 6: Verify no dangling references**
```bash
grep -rn "RecoveryCodesView" app/resources/js app/tests
```
Expected: **no output** (zero matches).
- [ ] **Step 7: Run frontend verification**
```bash
cd app && npm run test:vue
```
Expected: `91 files / 770 passed / 3 skipped / 0 failed` (baseline `92/774` minus the deleted `RecoveryCodesView.spec.ts` = 1 file / 4 tests). **0 failures.**
```bash
cd app && npm run type-check
```
Expected: `0 errors`.
```bash
cd app && npm run lint:vue
```
Expected: `0 errors`.
- [ ] **Step 8: Commit**
```bash
git add "app/resources/js/views/auth/RecoveryCodesView.vue" \
"app/resources/js/views/auth/RecoveryCodesView.story.vue" \
"app/tests/Frontend/RecoveryCodesView.spec.ts" \
app/resources/js/router/index.ts \
app/routes/web.php \
app/resources/js/histoire.setup.ts
git commit -m "refactor(auth): remove orphaned /recovery RecoveryCodesView page (closes A2, A3)
Audit A2/A3: RecoveryCodesView (route /recovery) had a TODO no-op
continue handler and 8 hardcoded mock codes. Recon found the page is
orphaned — nothing in the UI navigates to /recovery. The real 2FA
recovery-codes flow lives entirely in Settings -> Безопасность
(TwoFactorCard setup wizard + RecoveryCodesCard regeneration), both
already wired to the real API. Per user decision (2026-05-15) the
orphan is deleted rather than polished.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
(`git add` only the 6 named paths — `git rm`-tracked deletions are picked up by `git add` on the path. Do **not** stage `app/dev-indices.json`.)
---
## Task 2: Legal stub pages — `/legal/offer` + `/legal/privacy` (closes A7)
**Files:**
- Create: `app/resources/js/views/legal/LegalDocView.vue`
- Create: `app/tests/Frontend/LegalDocView.spec.ts`
- Modify: `app/resources/js/router/index.ts`
- Modify: `app/routes/web.php`
- Modify: `app/resources/js/layouts/AuthLayout.vue`
- [ ] **Step 1: Write the failing test**
Create `app/tests/Frontend/LegalDocView.spec.ts` with exactly:
```ts
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import { createVuetify } from 'vuetify';
import { createRouter, createMemoryHistory, type Router } from 'vue-router';
import LegalDocView from '../../resources/js/views/legal/LegalDocView.vue';
const vuetify = createVuetify();
const buildRouter = (): Router =>
createRouter({
history: createMemoryHistory(),
routes: [
{ path: '/legal/:doc(offer|privacy)', name: 'legal', component: LegalDocView },
{ path: '/login', name: 'login', component: { template: '<div>login</div>' } },
],
});
const mountAt = async (path: string) => {
const router = buildRouter();
await router.push(path);
await router.isReady();
return mount(LegalDocView, { global: { plugins: [vuetify, router] } });
};
describe('LegalDocView.vue', () => {
it('рендерит «Договор-оферта» на /legal/offer', async () => {
const wrapper = await mountAt('/legal/offer');
expect(wrapper.text()).toContain('Договор-оферта');
});
it('рендерит «Политика конфиденциальности» на /legal/privacy', async () => {
const wrapper = await mountAt('/legal/privacy');
expect(wrapper.text()).toContain('Политика конфиденциальности');
});
it('показывает честную заглушку «документ готовится», а не фейк-текст', async () => {
const wrapper = await mountAt('/legal/offer');
const notice = wrapper.find('[data-testid="legal-stub-notice"]');
expect(notice.exists()).toBe(true);
expect(notice.text()).toContain('готовится');
});
it('содержит ссылку возврата ко входу', async () => {
const wrapper = await mountAt('/legal/privacy');
const back = wrapper.find('a.legal-back');
expect(back.exists()).toBe(true);
expect(back.attributes('href')).toBe('/login');
});
});
```
- [ ] **Step 2: Run the test to verify it fails**
```bash
cd app && npx vitest run tests/Frontend/LegalDocView.spec.ts
```
Expected: **FAIL**`Failed to resolve import "../../resources/js/views/legal/LegalDocView.vue"` (the view does not exist yet).
- [ ] **Step 3: Create the view `app/resources/js/views/legal/LegalDocView.vue`**
```vue
<script setup lang="ts">
/**
* Правовые документы — заглушки оферты и политики конфиденциальности.
*
* Audit A7: ссылки /legal/offer и /legal/privacy в подвале AuthLayout вели
* на 404 (catch-all). Финальные тексты документов требуют юридической
* редактуры (реестр K3 / блокер Б-1) — до этого страницы показывают честную
* заглушку «документ готовится», а не фейк-текст (юридический риск).
*
* Один view на оба документа (DRY): контент выбирается по route.params.doc.
* Маршрут /legal/:doc(offer|privacy) — иные значения отсекает regex-constraint,
* уходя в catch-all 404.
*/
import { computed } from 'vue';
import { useRoute } from 'vue-router';
interface LegalDoc {
title: string;
intro: string;
}
const DOCS: Record<'offer' | 'privacy', LegalDoc> = {
offer: {
title: 'Договор-оферта',
intro: 'Публичная оферта на оказание услуг сервиса «Лидерра» — условия использования платформы, права и обязанности сторон, порядок оплаты.',
},
privacy: {
title: 'Политика конфиденциальности',
intro: 'Порядок обработки и защиты персональных данных пользователей сервиса «Лидерра» в соответствии с Федеральным законом № 152-ФЗ «О персональных данных».',
},
};
const route = useRoute();
const doc = computed<LegalDoc>(() => (String(route.params.doc) === 'privacy' ? DOCS.privacy : DOCS.offer));
</script>
<template>
<v-card variant="flat" :max-width="480" width="100%" color="transparent" class="legal-card">
<header class="legal-header">
<h1 class="text-h5 mb-1">{{ doc.title }}</h1>
<p class="text-body-2 text-medium-emphasis ma-0">{{ doc.intro }}</p>
</header>
<v-alert type="info" variant="tonal" density="compact" data-testid="legal-stub-notice">
Финальная редакция документа готовится и будет опубликована до запуска сервиса.
</v-alert>
<RouterLink to="/login" class="text-body-2 text-primary legal-back"> Вернуться ко входу </RouterLink>
</v-card>
</template>
<style scoped>
.legal-card {
display: flex;
flex-direction: column;
gap: 16px;
}
.legal-header h1 {
font-variation-settings: 'opsz' 24;
letter-spacing: -0.01em;
}
.legal-back {
text-decoration: none;
}
.legal-back:hover {
text-decoration: underline;
}
</style>
```
- [ ] **Step 4: Register the route in `app/resources/js/router/index.ts`**
Add this route object to the `routes` array **before** the catch-all `/:pathMatch(.*)*` route (placing it just after the `/recovery-use` route, with the other auth-layout routes, is fine):
```ts
{
path: '/legal/:doc(offer|privacy)',
name: 'legal',
component: () => import('../views/legal/LegalDocView.vue'),
meta: { layout: 'auth', title: 'Правовые документы' },
},
```
No `requiresAuth` / `guestOnly` — legal pages are public (a logged-in user may also read them). No `devIndex` — the page is not part of the temporary dev-index walkthrough.
- [ ] **Step 5: Run the test to verify it passes**
```bash
cd app && npx vitest run tests/Frontend/LegalDocView.spec.ts
```
Expected: **PASS** — 4/4 tests.
- [ ] **Step 6: Add SPA routes to `app/routes/web.php`**
Add these two lines next to the other auth `Route::view` entries (e.g. after `Route::view('/recovery-use', 'welcome');`):
```php
Route::view('/legal/offer', 'welcome');
Route::view('/legal/privacy', 'welcome');
```
This makes a hard browser load / refresh of `/legal/offer` serve the SPA shell (consistent with the project's explicit-route convention).
- [ ] **Step 7: Upgrade the footer links in `app/resources/js/layouts/AuthLayout.vue`**
Replace the raw anchors (currently at lines ~47-48):
```vue
<a href="/legal/offer">Оферта</a>
<a href="/legal/privacy">Политика</a>
```
with `RouterLink`s (SPA navigation, no full page reload):
```vue
<RouterLink to="/legal/offer">Оферта</RouterLink>
<RouterLink to="/legal/privacy">Политика</RouterLink>
```
Leave the `<span>v8 · Forest</span>` line and the `.bp-foot` wrapper unchanged. `RouterLink` renders a plain `<a>` element, so any existing `.bp-foot a { ... }` style rules continue to apply unchanged — no `<style>` edit needed.
- [ ] **Step 8: Run full frontend verification**
```bash
cd app && npm run test:vue
```
Expected: `92 files / 774 passed / 3 skipped / 0 failed` (Task 1 left it at `91/770`; this task adds `LegalDocView.spec.ts` = +1 file / +4 tests → `92/774`). **0 failures.**
```bash
cd app && npm run type-check
```
Expected: `0 errors`.
```bash
cd app && npm run lint:vue
```
Expected: `0 errors`.
- [ ] **Step 9: Commit**
```bash
git add app/resources/js/views/legal/LegalDocView.vue \
app/tests/Frontend/LegalDocView.spec.ts \
app/resources/js/router/index.ts \
app/routes/web.php \
app/resources/js/layouts/AuthLayout.vue
git commit -m "feat(auth): /legal/offer + /legal/privacy stub pages (closes A7)
Audit A7: the «Оферта» / «Политика» links in the AuthLayout footer were
raw <a href> pointing at unrouted paths -> 404 via the SPA catch-all.
Adds a single DRY LegalDocView served by /legal/:doc(offer|privacy),
rendering an honest «document being finalized» stub (real legal text
needs юр. редактура — реестр K3 / blocker Б-1). Footer links upgraded
to <RouterLink> for SPA navigation.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>"
```
---
## Final Acceptance — Plan A
After both tasks:
- [ ] Pest `--parallel` unchanged from baseline: `742/739/3sk/0` (no backend files touched — run `composer test:parallel` once to confirm).
- [ ] Vitest: `92 files / 774 passed / 3 skipped / 0 failed` (net: `RecoveryCodesView.spec.ts` +`LegalDocView.spec.ts` = 0 file delta, 4 +4 = 0 test delta vs baseline).
- [ ] vue-tsc: `0 errors`.
- [ ] ESLint: `0 errors`.
- [ ] `grep -rn "RecoveryCodesView" app/` → no matches.
- [ ] Lefthook pre-commit green on both commits.
- [ ] 2 atomic commits on `main`; not pushed.
## Spec coverage (audit `2026-05-15-portal-audit-design.md`)
| Audit ID | Spec line | This plan |
|---|---|---|
| A2 | RecoveryCodesView continue button → redirect | Task 1 — superseded: page deleted (orphan, per user decision) |
| A3 | RecoveryCodesView → real API | Task 1 — superseded: page deleted (real flow already in Settings → Безопасность) |
| A7 | `/legal/offer` + `/legal/privacy` routes + stub | Task 2 |