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:
@@ -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 |
|
||||
Reference in New Issue
Block a user