Files
han-app/VM1_app/documentation/module-02-frontend-test-site.md

300 lines
23 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# module-02. Проектная спецификация тестового frontend-сайта
> Статус: целевая спецификация реализации MVP; это проектирование, не код.
> Канонические источники: [`README.md`](README.md), [`arch-00-glossary.md`](../../architectory/arch-00-glossary.md), [`arch-01-system-architecture.md`](../../architectory/arch-01-system-architecture.md), [`arch-02-api-contracts.md`](../../architectory/arch-02-api-contracts.md), [`arch-03-docker-compose-blueprint.md`](../../architectory/arch-03-docker-compose-blueprint.md), [`arch-04-settings-and-content.md`](../../architectory/arch-04-settings-and-content.md), [`arch-05-agent-development-process.md`](../../architectory/arch-05-agent-development-process.md), [`arch-06-service-hosting-security.md`](../../architectory/arch-06-service-hosting-security.md), [`module-01-api-backend.md`](module-01-api-backend.md).
## 1. Назначение и границы
Сайт нужен для ручной, интеграционной и E2E-проверки всех пользовательских сценариев HAN Chat через реальные публичные API. Он остаётся простым по визуальному дизайну, но функционально покрывает guest, OTP/PKCE, bootstrap, UX-сессию, чат, файлы, профиль, Notification Center, realtime и деградации.
Сайт не реализует бизнес-решения backend, не обращается к PostgreSQL, Redis, Bitrix24 или S3 постоянными credentials и не подменяет Message Safety. Публичный вопрос отправляется обычным текстовым сообщением.
## 2. Зафиксированный стек
- Expo SDK + React Native + TypeScript, web target через Expo Router.
- React Query для server state; локальный reducer/state machine для auth и отложенной отправки.
- `expo-auth-session`/OIDC Authorization Code Flow with PKCE; парольный flow запрещён.
- SecureStore на native; для test web — защищённая browser storage adapter с явным предупреждением о риске XSS. Access token предпочтительно держать в памяти, refresh token — в доступном платформе secure storage.
- React Hook Form + schema validation (Zod либо эквивалент).
- WebSocket API браузера; REST polling как обязательный fallback.
- Playwright для web E2E, Vitest/Jest + Testing Library для unit/component.
- Никакого отдельного frontend nginx: production-статику отдаёт единственный корневой nginx.
## 3. Предлагаемая структура
```text
frontend-test-site/
app/
_layout.tsx
index.tsx
auth/callback.tsx
dialogs/index.tsx
dialogs/[dialogId].tsx
profile.tsx
diagnostics.tsx
src/
api/{client,errors,public,auth,dialogs,attachments,profile,notifications}.ts
auth/{oidc,pkce,token-store,refresh-single-flight}.ts
session/{ux-session,activity}.ts
realtime/{socket,polling,reconcile}.ts
flows/{deferred-send,bootstrap}.ts
components/
config/
accessibility/
tests/{unit,component,contract,e2e}/
app.config.ts
package.json
```
## 4. Runtime state
| Состояние | Хранение | Правило |
|---|---|---|
| access token | память | не логировать, не показывать полностью |
| refresh token | secure adapter | очищать при logout/`invalid_grant` |
| PKCE verifier/state/nonce | session storage, короткий TTL | одноразовые, проверяются callback |
| `ux_session_id`, `last_activity_at` | только память | не localStorage |
| `guest_session_id` | локально, опционально | не auth, не посылается как право доступа |
| pending text intent | session storage, до терминального send outcome | восстанавливает отправку после OIDC redirect |
| pending file intent | память | переносит выбранный `File` с главной в чат без сериализации |
| REST cursors | память по dialog | opaque, не парсить |
Auth state machine: `guest → authorizing → bootstrapping → authenticated`; при refresh failure — обратно `guest`. UX-сессия независима от Keycloak-сессии.
## 5. Экраны
### 5.1. Главная
- загрузка `GET /api/v1/public/app-config` и `/content`;
- приветствие, популярные вопросы, textarea, attach button, send;
- индикаторы загрузки/ошибки и повтор;
- ссылки на чат и профиль (при guest запускают auth только по явному действию);
- диагностический badge режима: guest/authenticated, WS/polling, без раскрытия token.
Выбор популярного вопроса сразу запускает тот же send flow, что ручной текст.
### 5.2. Согласия и OTP
Modal согласий (макет AuthConsent) показывает три блока. В блоке `personal_data` — две ссылки: `document_url` (согласие на обработку ПД) и `privacy_policy_document_url` (политика ПД из `consent.privacy_policy.document_url`). В блоках `user_agreement` и `marketing` — по одной ссылке из `document_url` (`consent.marketing.document_url` для рекламы). Обязательность берётся из `consent.*.required` (`personal_data`/`user_agreement` обычно обязательны, `marketing` — нет). После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.
OTP вводится на странице/теме Keycloak. В mock mode Keycloak сверяет secret-код; в real mode Keycloak генерирует и локально проверяет OTP, а доставку заказывает в `sms-service` по module-11. Frontend не вызывает `sms-service`/Direct, не получает provider status, service URL/token или mock secret.
Resend запускает новое Keycloak action, блокирует double click на время запроса и сообщает, что предыдущий код недействителен (`superseded`). Countdown строится из snapshot challenge (`expires_at`/`otp_ttl_sec`), без hardcoded `6` digits или `0:59`.
### 5.3. Чат
- в MVP frontend показывает пользователю один чат с компанией без истории диалогов;
- пункт навигации «Чат» вызывает `POST /dialogs`: backend возвращает текущий активный диалог или создаёт новый, после чего frontend открывает `/dialogs/{dialog_id}`;
- backend-модель диалогов и `GET /dialogs` сохраняются для будущего возврата истории;
- экран чата содержит статус, сообщения, composer и attachment;
- сообщения сортируются по `created_at asc`, дубли объединяются по `message_id`;
- `waiting_for_company`, `waiting_for_client`, `closed` отображаются русскими подписями;
- завершённая беседа readonly; CTA «Продолжить общение» повторно открывает текущий чат через `POST /dialogs`;
- internal safety `202` клиенту не показывается: send request остаётся in progress до финального ответа.
### 5.4. Профиль
Readonly блок «Личные данные» из `GET /me`; блок «Документы» из `/me/documents`, допускается пустой. Редактирование отсутствует. Для изменения данных — CTA в чат. Download URL запрашивается только после клика и не сохраняется.
### 5.5. Notification Center
Главная показывает bounded carousel активных G/P-уведомлений, центр — paginated список и состояния personal notifications. Публичные guest-кампании читаются без JWT; персональные read/action/hide/upload операции требуют JWT, ownership и idempotency по arch-02. CTA использует только allow-listed action types, external URL открывается безопасно, upload/download URL не сохраняются. WS-событие уведомления служит только сигналом обновить данные через REST; после reconnect выполняется reconciliation.
### 5.6. Diagnostics (только non-production)
Последние безопасные request id, HTTP status, WS state, cursor, время token expiry и UX-session id. Tokens, OTP, PII, тела сообщений и presigned URL не выводятся.
## 6. Startup, auth и bootstrap
1. Немедленно показать guest UI и параллельно загрузить public config/content.
2. Проверить refresh token. При наличии — выполнить silent Refresh Token Grant через single-flight.
3. При успехе определить новую UX-сессию (`cold_start` при новом page lifecycle), вызвать `session-start`, затем загрузить profile/dialogs.
4. При отсутствии/истечении refresh token оставаться guest до protected action.
5. После OTP callback проверить `state`/`nonce`, обменять code с PKCE, вызвать `POST /auth/bootstrap` с согласиями и device metadata.
6. Создать UX-сессию, если её нет, завершить auth-экран и перейти в чат; pending intent отправляется уже экраном чата. Ошибка Message Safety/Bitrix не превращается в ошибку bootstrap.
Bootstrap повторяем безопасно после неопределённого сетевого результата. Телефон в body никогда не передаётся.
## 7. UX-сессия
- `ux_session_id` и activity timestamp живут только в памяти вкладки.
- После JWT `session-start` вызывается с `first_launch`, `cold_start` либо `idle_timeout`.
- Idle timeout берётся из app-config; default UI не подменяет server config.
- Visibility/focus/user input обновляют activity; возврат после превышения timeout создаёт новую сессию.
- Refresh token grant не создаёт новую UX-сессию.
- `X-Ux-Session-Id` добавляется ко всем JWT REST-запросам, когда id уже получен.
## 8. HTTP client и заголовки
Каждый API-запрос получает `X-Request-ID` (UUID), `traceparent` при активной трассировке и `Accept: application/json`. Protected request получает Bearer token и `X-Ux-Session-Id`.
`Idempotency-Key` обязателен для `POST /dialogs` и `POST .../messages`; ключ создаётся один раз на пользовательское действие и сохраняется на retry этого действия. Новый intent получает новый key. Для attachment init/complete повтор соблюдает state/idempotency контракта backend.
Единый error envelope маппится по `error.code`, а `request_id` показывается в деталях поддержки. Тело/headers с credentials не логируются.
## 9. Token refresh single-flight
- планировать refresh за 60 секунд до `exp`;
- один Promise/mutex на refresh; все параллельные запросы ждут его;
- на первом `401 unauthorized/token_expired` — один refresh и один replay исходного запроса;
- mutating replay использует исходный `Idempotency-Key`;
- второй `401` не запускает цикл;
- `invalid_grant` очищает tokens, закрывает WS, переводит в guest;
- WS auth failure использует тот же single-flight, затем reconnect;
- logout отзывает/завершает OIDC-сессию best effort и всегда очищает local secrets.
## 10. Отправка текста
1. Валидировать непустой нормализованный текст и клиентский max length из контракта.
2. Если guest — сохранить intent, consent → OTP → bootstrap → session-start.
3. `POST /dialogs` с idempotency key, сохранить `dialog_id` и перейти на экран чата до отправки.
4. Экран чата выполняет `POST /dialogs/{id}/messages` с отдельным стабильным key.
5. Блокировать повторный click только для того же intent; другие действия не замораживать.
6. На `201` merge `MessageResponse`; на `422 message_blocked` не показывать internal reason/rule и не строить собственный текст: получить/merge backend `company`-реплику (`message.new`) с контентом мнемоники `safety.chat.blocked`; на `503/504` предложить retry с тем же key.
7. Composer показывает счётчик `n/max`, где `max` приходит как `messages.max_text_length` из public app-config; сверх лимита отправка блокируется без обрезки ввода.
## 11. Файловый flow
MVP допускает ровно один файл, только allow-list extension+MIME, до 5 МБ или значений app-config.
1. Локальная prevalidation.
2. Создать/reuse dialog.
3. `POST .../attachments/init` с filename, MIME, size.
4. Вычислить SHA-256 до upload и выполнить прямой `PUT upload_url` с **точно** выданными `upload_headers`, включая `If-None-Match: *` и checksum; заголовки являются частью подписи.
5. Вызвать `complete` с тем же checksum; backend фиксирует authoritative S3 version/ETag/checksum.
6. Отправить file message с `attachment_id` и `sha256:<hex>`.
Presigned URL не сохраняется и редактируется из диагностик. `412` означает, что immutable key уже записан: frontend не повторяет PUT в тот же key, а запрашивает новый init. Abort позволяет отменить PUT; orphan очищает backend через 48 ч. При expiry init выполняется повторно в рамках согласованного состояния. CORS S3 разрешает origin сайта, PUT и только необходимые signed headers.
## 12. Realtime и polling
Предпочтение — `WSS /api/v1/realtime`, token через согласованный subprotocol; query token допускается только для совместимости и не логируется.
- connected → subscribe актуальных dialog ids;
- события `message.new`, `message.status`, `dialog.status` merge идемпотентно;
- отвечать `pong` на `ping`;
- reconnect 1, 2, 4…30 секунд с jitter;
- после каждого reconnect делать REST gap reconciliation по последнему cursor;
- если WS недоступен более 30 секунд — polling `GET .../messages?after=...`;
- polling прекращается после устойчивого WS, но только после reconciliation;
- hidden tab снижает polling frequency без нарушения восстановления;
- неизвестные event types игнорируются с безопасной метрикой.
## 13. Ошибки и UX
| Ситуация | Поведение |
|---|---|
| offline/network | banner, сохранение intent в памяти, ручной retry |
| 400 validation | подсветить поле; не retry автоматически |
| 401 | single-flight refresh; при провале guest |
| 403 consents | обновить config, повторить consent flow |
| 404 | безопасное «ресурс недоступен», обновить список |
| 409 idempotency | остановить retry, показать request id |
| 422 blocked | нейтральное сообщение, контент не отправлен |
| 429 | countdown по `Retry-After` |
| 503/504 | зависимость недоступна; retry с тем же key |
| OTP invalid/expired/superseded/limited | показать соответствующий безопасный Keycloak UX; generic order unavailable не раскрывает provider |
| S3 PUT error | оставить attachment intent, предложить повтор |
| WS failure | polling badge, чат остаётся usable |
Skeleton/empty/error states обязательны для каждого data screen. Никаких optimistic «delivered» до `201`.
## 14. Конфигурация и сборка
Public build-time env содержит только URL/realm/client id:
```text
EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
EXPO_PUBLIC_APP_ENV=production-like
```
Redirect URI и allowed origins фиксируются в Keycloak/nginx. Service tokens, S3 keys и mock OTP code во frontend env запрещены. Бизнес-конфиг приходит через `/public/app-config`, тексты — `/public/content`.
Production: статический export монтируется в корневой nginx, `try_files $uri /index.html`; hashed assets immutable, `index.html` no-cache/revalidate. Local dev: Expo dev server, опциональный proxy корневого nginx через `FRONTEND_DEV_PROXY_ENABLED=true`.
## 15. Доступность
- WCAG 2.1 AA как цель; полная keyboard navigation и видимый focus.
- Семантические headings/landmarks, labels и error descriptions.
- Modal: focus trap, возврат focus, Escape только если не нарушает обязательный flow.
- Live region для новых сообщений и статусов без повторного озвучивания всей ленты.
- Контраст, zoom 200%, reduced motion, touch targets не менее 44×44 CSS px.
- Статусы не кодируются одним цветом; файлы имеют доступные имена и progress.
- OTP поля поддерживают paste/autocomplete, но не логируют значение.
## 16. Тестовая матрица
### Unit/component
- auth state machine, PKCE callback state/nonce;
- refresh scheduler/single-flight/concurrent 401;
- UX idle boundary;
- idempotency key reuse;
- error mapping/redaction;
- text/file union и file validation;
- WS merge, duplicate, reconnect и poll switch;
- accessibility scans ключевых экранов.
### Contract/integration
- DTO соответствует `api-backend/openapi.yaml`;
- public cache/ETag;
- bootstrap без phone body;
- dialog 200/201;
- message 201/422/429/503/504;
- presigned PUT headers/checksum/expiry;
- profile/documents readonly;
- WS события и REST reconciliation.
### E2E
| Сценарий | Варианты |
|---|---|
| guest | просмотр public content; write закрыт |
| first send | manual/popular → consents → mock и real OTP → delivered |
| return | valid refresh без OTP; expired refresh с OTP |
| text safety | allow, block, pending-to-final, timeout |
| file | PDF/image allow, deny, wrong MIME/size/checksum, expired URL |
| realtime | message, status, close, reconnect, polling fallback |
| concurrency | два send click, несколько 401, две вкладки |
| profile | filled/null fields, empty documents, download failure |
| security | XSS text, token absence in logs/storage diagnostics |
| OTP resend | double click; старый код `superseded`; новый код; snapshot countdown; order unavailable |
Browsers: Chromium, Firefox, WebKit; viewport desktop/mobile. В CI используются Keycloak mock mode и локальный mock/WireMock Direct. Отдельный sandbox Direct не предполагается; provider smoke выполняется только ops на контролируемом номере.
## 17. Definition of Done
- все экраны и flows выше реализованы на русском;
- guest не вызывает protected write;
- PKCE/OTP, silent refresh, bootstrap и pending intent проверены E2E;
- UX-session создаётся и передаётся строго по правилам;
- idempotency и refresh single-flight выдерживают concurrency;
- text/file lifecycle работает через presigned PUT;
- WS и polling не оставляют gap;
- profile readonly и documents empty state реализованы;
- CSP/CORS совместимы без unsafe token practices;
- отсутствуют secrets, PII, message body и URLs в логах;
- accessibility checks и keyboard сценарии проходят;
- unit/component/contract/E2E matrix зелёная;
- production static и dev proxy режимы проверены через единственный nginx.
- frontend bundle/config/analytics не содержит raw OTP, `sms-service`/Direct credentials или provider status; resend/expiry/limits проверены для real-mode контракта.
## 18. Решения, допущения и TBD
**Решения:** Expo/TypeScript; server state через React Query; auth state machine; WS best effort + обязательная REST reconciliation; никаких optimistic delivered.
**Допущения:** test site использует те же API и Keycloak realm contracts, что мобильные клиенты; locale MVP — `ru`; browser secure storage не эквивалентен OS Keychain, поэтому CSP и отсутствие сторонних scripts обязательны.
**TBD:**
- F1: окончательный Keycloak browser adapter и token rotation policy;
- F2: точные max lengths и WS limits после OpenAPI;
- F3: web storage policy refresh token перед production security review;
- F4: окончательный DTO app-config (G10);
- F5: WS `event_id`/protocol version (TBD module-01/G11);
- F6 для Safety закрыт: generic deny использует `safety.chat.blocked`; остальные error-state мнемоники остаются в frontend content backlog.