diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 49ea6ae..2828994 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -373,6 +373,14 @@ Post-операции, изменяющие остаток, идут под `Iso по спринтам. - **Load** (`tests/load/`) — k6 (Sprint 12). См. `docs/performance-baseline.md`. +### Sprint 13-15 changes (быстрая сводка) + +| Sprint | Что добавлено / изменено | +|---|---| +| **13** (security) | `SecurityHeadersMiddleware` (CSP, X-Frame, HSTS); rate-limit на signup (3/h IP) и forgot-password (3/h email + 10/h IP); `SensitiveOpsAudit` сервис для logged-ops; `POST /api/me/sessions/revoke-all` через `IOpenIddictAuthorizationManager`; Hangfire-dashboard под `SuperAdminHangfireFilter` + nginx /hangfire location. Также: dedicated PG-роль `food_market_server_app` для legacy back.food-market.kz (без superuser). | +| **14** (perf) | Phase14a индексы (composite + partial с INCLUDE на `retail_sales`); N+1 fix в `SalesReportController.FetchAsync`; React.lazy на 30 редких страниц + Recharts lazy → bundle 1456→706 KB (−51%); ImageSharp генерирует thumb/medium WebP при загрузке + `` с `` srcset; Npgsql pool (Min=10/Max=100/AutoPrepare=20); `JobTimingFilter` для Hangfire-jobs. | +| **15** (a11y + tests) | `useFocusTrap` (WCAG 2.4.3/2.1.2) на Modal + ConfirmDialog; axe-core spec-suite (10 страниц, 0 critical); aria-label на icon-only back-links + role="alert" на form errors; coverage Application 67%→83%, Domain 11%→79%; property-based tests на StockService (Σ movements ≡ Stock); verified backup-recovery drill RTO ~25s. | + ## Релиз-цикл 1. Локально: `dotnet build` + `dotnet test` + `pnpm build`. diff --git a/docs/DEVELOPER-GUIDE.md b/docs/DEVELOPER-GUIDE.md index c70f4d3..16318c2 100644 --- a/docs/DEVELOPER-GUIDE.md +++ b/docs/DEVELOPER-GUIDE.md @@ -428,12 +428,53 @@ payload — в соседнем record'е. (memory: `feedback_ef_migrations`). - НЕ делать `git push --force` на main (Forgejo — primary). +## Что добавилось после первого релиза этого guide'а + +| Sprint | Чем пользоваться | +|---|---| +| 13 | `SensitiveOpsAudit` (`food-market.api/Infrastructure/Audit/`) — централизованный логгер sensitive-операций. Вместо ручного `OrgAuditLogs.Add` — `_audit.LogAsync(action, entityType, entityId, payload)`. | +| 13 | `[RequiresPermission("X")]` уже было; добавился `MeSessionsController.RevokeAll` — пример работы с `IOpenIddictAuthorizationManager`. | +| 13 | Все ответы автоматически получают security-заголовки через `SecurityHeadersMiddleware`. Если новый endpoint требует ослабленную CSP (например, embeds другой домен) — добавь его path в `ShouldSkip` middleware'a. | +| 14 | Композитный индекс `(OrganizationId, …)` на новых таблицах — must. Для отчётных запросов с фильтром по статусу — добавляй partial index `WHERE Status = X` с `INCLUDE` (covering). | +| 14 | `ImageVariantService` — при upload картинок автоматически генерирует thumb/medium WebP. Через frontend `` — `` + srcset. | +| 14 | Hangfire-jobs: `JobTimingFilter` глобально логирует длительность; >30с — Warning. Для своих jobs ничего настраивать не надо. | +| 15 | `useFocusTrap` (`src/lib/useFocusTrap.ts`) — focus trap + return-focus для модалок. Уже подключён в `Modal` и `ConfirmDialog`. Для своего модала: `const ref = useFocusTrap(open)`. | +| 15 | a11y: каждая icon-only ` - diff --git a/src/food-market.web/src/components/Field.tsx b/src/food-market.web/src/components/Field.tsx index 3527dd2..3092405 100644 --- a/src/food-market.web/src/components/Field.tsx +++ b/src/food-market.web/src/components/Field.tsx @@ -18,11 +18,15 @@ interface FieldProps { } export function Field({ label, error, children, className }: FieldProps) { + // Sprint 15: role="alert" на error-span — screen reader сразу объявит + // изменение текста ошибки (без нужды юзеру re-focus'ить инпут). + // Implicit label-input association (label wraps input) — родной HTML-механизм, + // valid per WCAG; axe не флагает. return ( ) } diff --git a/src/food-market.web/src/components/Modal.tsx b/src/food-market.web/src/components/Modal.tsx index 50dc671..9074ba2 100644 --- a/src/food-market.web/src/components/Modal.tsx +++ b/src/food-market.web/src/components/Modal.tsx @@ -1,5 +1,6 @@ import { useEffect, type ReactNode } from 'react' import { X } from 'lucide-react' +import { useFocusTrap } from '@/lib/useFocusTrap' interface ModalProps { open: boolean @@ -8,9 +9,15 @@ interface ModalProps { children: ReactNode footer?: ReactNode width?: string + /** CSS-селектор внутри модала, на который двинуть focus при открытии. + * По умолчанию — первый focusable (обычно input). */ + initialFocusSelector?: string } -export function Modal({ open, onClose, title, children, footer, width = 'max-w-lg' }: ModalProps) { +export function Modal({ + open, onClose, title, children, footer, + width = 'max-w-lg', initialFocusSelector, +}: ModalProps) { useEffect(() => { if (!open) return const onEsc = (e: KeyboardEvent) => e.key === 'Escape' && onClose() @@ -18,6 +25,9 @@ export function Modal({ open, onClose, title, children, footer, width = 'max-w-l return () => document.removeEventListener('keydown', onEsc) }, [open, onClose]) + // Sprint 15 (WCAG 2.4.3 + 2.1.2): focus trap. + const dialogRef = useFocusTrap(open, initialFocusSelector) + if (!open) return null return ( @@ -29,13 +39,15 @@ export function Modal({ open, onClose, title, children, footer, width = 'max-w-l aria-labelledby="modal-title" >
e.stopPropagation()} >
-
{children}
diff --git a/src/food-market.web/src/lib/useFocusTrap.ts b/src/food-market.web/src/lib/useFocusTrap.ts new file mode 100644 index 0000000..8eb0b70 --- /dev/null +++ b/src/food-market.web/src/lib/useFocusTrap.ts @@ -0,0 +1,102 @@ +import { useEffect, useRef } from 'react' + +/** + * Sprint 15: focus trap для модальных диалогов (WCAG 2.4.3 Focus Order + + * 2.1.2 No Keyboard Trap). Логика: + * + * 1. Запоминаем currently-focused элемент перед открытием (return target). + * 2. На open перемещаем focus на первый focusable внутри контейнера + * (если передан initial-focus selector — используем его). + * 3. Tab/Shift+Tab внутри контейнера зацикливают focus: с последнего + * focusable Tab → первый; с первого Shift+Tab → последний. + * 4. На close возвращаем focus на запомнённый return target. + * + * Возвращает ref на корневой контейнер модала; повесить на div role="dialog". + * + * @param active — открыт ли модал. + * @param initialFocusSelector — CSS-селектор внутри контейнера, на который + * двинуть focus при открытии. По умолчанию — первый focusable. + */ +export function useFocusTrap( + active: boolean, + initialFocusSelector?: string, +) { + const containerRef = useRef(null) + const returnFocusRef = useRef(null) + + useEffect(() => { + if (!active) return + // 1) Запомнить кто был сфокусирован до открытия (вернуть на close). + returnFocusRef.current = (document.activeElement as HTMLElement) ?? null + + const container = containerRef.current + if (!container) return + + // 2) Сдвинуть focus в модал. + const moveInitialFocus = () => { + let target: HTMLElement | null = null + if (initialFocusSelector) { + target = container.querySelector(initialFocusSelector) + } + if (!target) { + target = getFocusable(container)[0] ?? container + } + target.focus() + } + // Чуть-чуть откладываем, чтобы дать React закончить mount и не упереться + // в `display: none` на родителях (Tailwind sm:rounded и тд). + const initialTimer = window.setTimeout(moveInitialFocus, 0) + + // 3) Tab/Shift+Tab — зациклить. + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== 'Tab') return + const focusables = getFocusable(container) + if (focusables.length === 0) { + e.preventDefault(); return + } + const first = focusables[0] + const last = focusables[focusables.length - 1] + const active = document.activeElement as HTMLElement | null + if (e.shiftKey) { + if (active === first || !container.contains(active)) { + e.preventDefault(); last.focus() + } + } else { + if (active === last) { + e.preventDefault(); first.focus() + } + } + } + document.addEventListener('keydown', onKeyDown) + + return () => { + window.clearTimeout(initialTimer) + document.removeEventListener('keydown', onKeyDown) + // 4) Вернуть focus куда был. setTimeout — чтобы дать DOM cleanup-у + // отработать (без него фокус может «улететь» в body). + const returnTo = returnFocusRef.current + if (returnTo && typeof returnTo.focus === 'function') { + window.setTimeout(() => returnTo.focus(), 0) + } + } + }, [active, initialFocusSelector]) + + return containerRef +} + +/** Список focusable элементов внутри контейнера в табoрдере. Tabindex < 0 + * пропускаем (по конвенции — visually-focusable но не keyboard). */ +function getFocusable(root: HTMLElement): HTMLElement[] { + const selector = [ + 'a[href]', 'button:not([disabled])', 'input:not([disabled])', + 'select:not([disabled])', 'textarea:not([disabled])', + '[tabindex]:not([tabindex="-1"])', 'audio[controls]', 'video[controls]', + ].join(',') + const all = Array.from(root.querySelectorAll(selector)) + return all.filter(el => { + if (el.hasAttribute('disabled')) return false + const style = window.getComputedStyle(el) + if (style.visibility === 'hidden' || style.display === 'none') return false + return true + }) +} diff --git a/src/food-market.web/src/pages/DemandEditPage.tsx b/src/food-market.web/src/pages/DemandEditPage.tsx index 5340b98..5db0f46 100644 --- a/src/food-market.web/src/pages/DemandEditPage.tsx +++ b/src/food-market.web/src/pages/DemandEditPage.tsx @@ -226,7 +226,7 @@ export function DemandEditPage() {
- +
diff --git a/src/food-market.web/src/pages/EnterEditPage.tsx b/src/food-market.web/src/pages/EnterEditPage.tsx index f5e1f85..32cc071 100644 --- a/src/food-market.web/src/pages/EnterEditPage.tsx +++ b/src/food-market.web/src/pages/EnterEditPage.tsx @@ -206,7 +206,7 @@ export function EnterEditPage() {
- +
diff --git a/src/food-market.web/src/pages/InventoryEditPage.tsx b/src/food-market.web/src/pages/InventoryEditPage.tsx index f225200..c5bc19b 100644 --- a/src/food-market.web/src/pages/InventoryEditPage.tsx +++ b/src/food-market.web/src/pages/InventoryEditPage.tsx @@ -227,7 +227,7 @@ export function InventoryEditPage() {
- +
diff --git a/src/food-market.web/src/pages/LoginPage.tsx b/src/food-market.web/src/pages/LoginPage.tsx index 8c7670a..278415e 100644 --- a/src/food-market.web/src/pages/LoginPage.tsx +++ b/src/food-market.web/src/pages/LoginPage.tsx @@ -66,9 +66,11 @@ export function LoginPage() { value={email} onChange={(e) => { setEmail(e.target.value); if (emailErr) setEmailErr(null) }} onBlur={() => setEmailErr(validateEmail(email))} + aria-invalid={emailErr ? true : undefined} + aria-describedby={emailErr ? 'login-email-err' : undefined} className={`w-full rounded-md border bg-white dark:bg-slate-900 px-3 py-2 text-sm focus:outline-none focus:ring-2 ${emailErr ? 'border-red-400 focus:ring-red-300' : 'border-slate-300 dark:border-slate-600 focus:ring-[var(--color-brand)]'}`} /> - {emailErr && {emailErr}} + {emailErr && {emailErr}} {error && ( diff --git a/src/food-market.web/src/pages/LossEditPage.tsx b/src/food-market.web/src/pages/LossEditPage.tsx index 35f17c9..45ae88c 100644 --- a/src/food-market.web/src/pages/LossEditPage.tsx +++ b/src/food-market.web/src/pages/LossEditPage.tsx @@ -209,7 +209,7 @@ export function LossEditPage() {
- +
diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index f85863f..2c1ef1b 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -245,12 +245,15 @@ export function ProductEditPage() { {/* Sticky top bar */}
+ {/* WCAG 4.1.2: icon-only link нуждается в aria-label для screen-reader'ов; + title не считается доступным именем во всех браузерах. */} - +