diff --git a/.opencode/plans/2026-05-23-legal-docs-fix.md b/.opencode/plans/2026-05-23-legal-docs-fix.md new file mode 100644 index 0000000..0b6cdfa --- /dev/null +++ b/.opencode/plans/2026-05-23-legal-docs-fix.md @@ -0,0 +1,439 @@ +# Приведение Политики конфиденциальности и Пользовательского соглашения в соответствие с проектом + +> **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:** Убрать из юридических документов упоминания несуществующих функций (аналитика, рекламные рассылки, персонализация, Яндекс.Метрика) и исправить неточности (OAuth, cookie, IP-логирование, дублирование данных оператора). + +**Architecture:** 4 задачи: унификация данных оператора в shared/config, правка Политики конфиденциальности, правка Пользовательского соглашения, финальная проверка. + +**Tech Stack:** TypeScript, React, MUI — изменения только в статическом JSX-тексте и shared/config. + +--- + +## Файловая структура изменений + +| Файл | Что делаем | +| ---------------------------------------------------------- | ------------------------------------------------------------------------------- | +| `client/src/shared/config/index.ts` | Добавляем `STORE_OP_NAME`, `STORE_OP_INN`, `STORE_OP_OGRN`, `STORE_OP_ADDR` | +| `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx` | Импорт из config, правка пунктов 2, 3, 5, 6, 7, добавляем дату | +| `client/src/pages/terms/ui/TermsPage.tsx` | Импорт из config, правка пунктов 1, 3, 7, 8, 9, убираем противоречие 6.1 vs 2.2 | + +--- + +### Task 1: Вынести данные оператора в shared/config + +**Files:** + +- Modify: `client/src/shared/config/index.ts` + +- [ ] **Step 1: Добавить константы оператора в shared/config** + +Вставить после строки 17 (перед `export const VK_URL`): + +```ts +export const STORE_OP_NAME = + "Индивидуальный предприниматель Новоселова Наталия Владимировна"; +export const STORE_OP_INN = "402900832341"; +export const STORE_OP_OGRN = "305402922700051"; +export const STORE_OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А"; +``` + +- [ ] **Step 2: Проверить синтаксис** + +```bash +cd client && npx tsc -b --noEmit +``` + +Expected: no errors. + +--- + +### Task 2: Исправить Политику конфиденциальности + +**Files:** + +- Modify: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx` + +- [ ] **Step 1: Заменить импорт и локальные константы** + +Заменить строки 4-10 (импорт STORE_EMAIL + локальные константы). + +Было: + +```ts +import { STORE_EMAIL } from "@/shared/config"; + +const OP_NAME = + "Индивидуальный предприниматель Новоселова Наталия Владимировна"; +const OP_INN = "402900832341"; +const OP_OGRN = "305402922700051"; +const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А"; +const SITE_URL = window.location.origin; +``` + +Стало: + +```ts +import { + STORE_EMAIL, + STORE_OP_NAME, + STORE_OP_INN, + STORE_OP_OGRN, + STORE_OP_ADDR, + STORE_PUBLIC_SITE_URL, +} from "@/shared/config"; + +const SITE_URL = + STORE_PUBLIC_SITE_URL || + (typeof window !== "undefined" ? window.location.origin : ""); +``` + +И заменить `OP_NAME` → `STORE_OP_NAME`, `OP_INN` → `STORE_OP_INN`, `OP_OGRN` → `STORE_OP_OGRN`, `OP_ADDR` → `STORE_OP_ADDR` во всём файле (replaceAll). + +- [ ] **Step 2: Исправить раздел 2 — актуальный список собираемых данных** + +Заменить `items` в секции 2. + +Было: + +```ts +items: [ + '2.1. Оператор обрабатывает следующие персональные данные Пользователей:', + '— фамилия, имя, отчество;', + '— адрес электронной почты;', + '— номер телефона;', + '— данные файлов cookie;', + '— данные о действиях на сайте (аналитика);', + '— адрес доставки и геолокационные координаты.', +], +``` + +Стало: + +```ts +items: [ + '2.1. Оператор обрабатывает следующие персональные данные Пользователей:', + '— адрес электронной почты;', + '— имя (отображаемое имя, может быть указано Пользователем добровольно);', + '— номер телефона (указывается Пользователем добровольно при оформлении доставки);', + '— адрес доставки и геолокационные координаты (указываются Пользователем при оформлении заказа);', + '— аутентификационные данные (сессионные cookie для поддержания входа в Личный кабинет).', +], +``` + +- [ ] **Step 3: Исправить раздел 3 — убрать несуществующую персонализацию** + +Было: + +```ts +items: [ + '3.1. Оператор обрабатывает персональные данные в следующих целях:', + '— идентификация Пользователя;', + '— оказание услуг / продажа товаров;', + '— направление уведомлений и информационных сообщений;', + '— улучшение качества работы сайта;', + '— построение персонализированных предложений и рекомендаций.', +], +``` + +Стало: + +```ts +items: [ + '3.1. Оператор обрабатывает персональные данные в следующих целях:', + '— идентификация и аутентификация Пользователя;', + '— оказание услуг / продажа товаров и оформление доставки;', + '— направление транзакционных уведомлений о статусе заказов и информационных сообщений;', + '— улучшение качества работы сайта.', +], +``` + +- [ ] **Step 4: Исправить раздел 5 — убрать неавтоматизированную обработку и нереалистичный срок** + +Было: + +```ts +items: [ + '5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, обезличивания, блокирования, удаления и уничтожения персональных данных.', + '5.2. Обработка осуществляется автоматизированным и неавтоматизированным способами.', + '5.3. Срок хранения персональных данных: не более 7 лет с момента последнего обращения Пользователя либо до момента отзыва согласия на обработку.', +], +``` + +Стало: + +```ts +items: [ + '5.1. Обработка осуществляется путём сбора, записи, систематизации, накопления, хранения, уточнения, извлечения, использования, передачи, блокирования, удаления и уничтожения персональных данных.', + '5.2. Обработка осуществляется автоматизированным способом с использованием программных средств Сайта.', + '5.3. Срок хранения персональных данных: до достижения целей обработки либо до момента отзыва Пользователем согласия на обработку.', +], +``` + +- [ ] **Step 5: Исправить раздел 6 — Яндекс.Метрика → ЮKassa** + +Было: + +```ts +items: [ + '6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:', + '— с согласия субъекта;', + '— по требованию законодательства РФ;', + '— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжные агрегаторы, сервисы аналитики (Яндекс.Метрика).', +], +``` + +Стало: + +```ts +items: [ + '6.1. Оператор может передать персональные данные третьим лицам в следующих случаях:', + '— с согласия субъекта;', + '— по требованию законодательства РФ;', + '— для выполнения договорных обязательств (перечень третьих лиц): службы доставки, платёжный сервис (ЮKassa).', +], +``` + +- [ ] **Step 6: Добавить дату обновления** + +Заменить текст подзаголовка (строка 99): + +``` +Политика в отношении обработки персональных данных. +``` + +на: + +``` +Последнее обновление: 23 мая 2026 г. +``` + +- [ ] **Step 7: Проверить линтер** + +```bash +cd client && npm run lint +``` + +Expected: 0 новых ошибок. + +--- + +### Task 3: Исправить Пользовательское соглашение + +**Files:** + +- Modify: `client/src/pages/terms/ui/TermsPage.tsx` + +- [ ] **Step 1: Заменить локальные константы на импорт из config** + +Заменить строки 4-11. + +Было: + +```ts +import { + STORE_EMAIL, + STORE_PHONE, + STORE_PUBLIC_SITE_URL, +} from "@/shared/config"; + +const SITE_URL = + STORE_PUBLIC_SITE_URL || + (typeof window !== "undefined" ? window.location.origin : ""); + +const OP_NAME = + "Индивидуальный предприниматель Новоселова Наталия Владимировна"; +const OP_INN = "402900832341"; +const OP_OGRN = "305402922700051"; +const OP_ADDR = "248000, Россия, г. Калуга, ул. Никитина, д. 12А"; +``` + +Стало: + +```ts +import { + STORE_EMAIL, + STORE_PHONE, + STORE_PUBLIC_SITE_URL, + STORE_OP_NAME, + STORE_OP_INN, + STORE_OP_OGRN, + STORE_OP_ADDR, +} from "@/shared/config"; + +const SITE_URL = + STORE_PUBLIC_SITE_URL || + (typeof window !== "undefined" ? window.location.origin : ""); +``` + +Заменить `OP_NAME` → `STORE_OP_NAME`, `OP_INN` → `STORE_OP_INN`, `OP_OGRN` → `STORE_OP_OGRN`, `OP_ADDR` → `STORE_OP_ADDR` во всём файле (replaceAll). + +- [ ] **Step 2: Дополнить раздел 1 — упомянуть OAuth и вход по коду** + +В секции 1, в определении «Аутентификационные данные». Найти: + +``` +'— Аутентификационные данные Пользователя — адрес электронной почты Пользователя и пароль (код доступа), которые в совокупности признаются простой электронной подписью Пользователя.', +``` + +Заменить на: + +``` +'— Аутентификационные данные Пользователя — адрес электронной почты и пароль (код доступа), либо данные, полученные через сервисы авторизации третьих лиц (VK ID, Яндекс ID), либо одноразовый код, направляемый на электронную почту. Совокупность аутентификационных данных признаётся простой электронной подписью Пользователя.', +``` + +- [ ] **Step 3: Убрать «рекламные» сообщения из п. 3.7** + +Найти: + +``` +'3.7. При регистрации Пользователь даёт согласие на получение информационных и рекламных сообщений от Администратора на указанный адрес электронной почты.', +``` + +Заменить на: + +``` +'3.7. При регистрации Пользователь даёт согласие на получение транзакционных уведомлений (статус заказа, сообщения в чате заказа, статус оплаты) на указанный адрес электронной почты.', +``` + +- [ ] **Step 4: Исправить противоречие 6.1 vs 2.2 («гарантирует» vs «as is»)** + +Найти в секции 6: + +``` +'6.1. Администратор гарантирует достоверность и полноту только той информации, которую он разместил на Сайте самостоятельно.', +``` + +Заменить на: + +``` +'6.1. Администратор прилагает разумные усилия для обеспечения достоверности и полноты информации, размещённой на Сайте, однако не даёт явных гарантий точности такой информации.', +``` + +- [ ] **Step 5: Исправить раздел 7 — указать реальных третьих лиц (ЮKassa, OSM вместо рекламы/аналитики)** + +Заменить всю секцию 7. + +Было: + +```ts +{ + title: '7. Доступ к ресурсам третьих лиц', + items: [ + '7.1. Доступ Пользователя к Сайту может вызывать обращение к интернет-ресурсам третьих лиц (реклама, сбор статистики).', + '7.2. Владельцы таких ресурсов имеют техническую возможность собирать информацию о Пользователях и самостоятельно определяют условия её использования.', + '7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.', + ], +}, +``` + +Стало: + +```ts +{ + title: '7. Доступ к ресурсам третьих лиц', + items: [ + '7.1. Для обеспечения функциональности Сайта используются сервисы третьих лиц: платёжный сервис ЮKassa (для обработки онлайн-платежей). + '7.2. Владельцы указанных ресурсов имеют собственную политику конфиденциальности и самостоятельно определяют условия обработки получаемой информации.', + '7.3. При переходе на сторонние ресурсы Пользователи самостоятельно определяют пределы использования своей информации согласно правилам соответствующих ресурсов.', + ], +}, +``` + +- [ ] **Step 6: Исправить раздел 8 — Cookie только для сессии, не для аналитики** + +Заменить всю секцию 8. + +Было: + +```ts +{ + title: '8. Информация, хранящаяся на стороне браузера', + items: [ + '8.1. Администратор использует cookie-файлы для определения уникального идентификатора доступа Пользователя к Сайту.', + '8.2. Цели использования cookie:', + '— поддержка функциональности Сайта, требующей использования cookie;', + '— измерение аудитории Сайта;', + '— определение статистических предпочтений Пользователей;', + '— исследование корреляции статистических данных.', + '8.3. Пользователь может запретить использование cookie в настройках браузера, однако это может привести к частичной или полной потере функциональности Сайта.', + ], +}, +``` + +Стало: + +```ts +{ + title: '8. Информация, хранящаяся на стороне браузера', + items: [ + '8.1. Администратор использует сессионные cookie-файлы исключительно для поддержания аутентификации Пользователя в Личном кабинете.', + '8.2. Сайт не использует cookie для сбора статистики, отслеживания действий Пользователя или показа рекламы.', + '8.3. Пользователь может запретить использование cookie в настройках браузера, однако это приведёт к невозможности входа в Личный кабинет и использования функций, требующих аутентификации.', + ], +}, +``` + +- [ ] **Step 7: Исправить раздел 9.3–9.4 — актуальный перечень данных и целей** + +Заменить строки 9.3 и 9.4 в секции 9. + +Было: + +```ts +'9.3. Администратор обрабатывает следующие персональные данные: Ф. И. О., адрес электронной почты, номер телефона, IP-адрес, тип браузера, данные о действиях на Сайте.', +'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, оказание информационной поддержки, предоставление персонализированных сервисов, направление информационных сообщений.', +``` + +Стало: + +```ts +'9.3. Администратор обрабатывает следующие персональные данные: адрес электронной почты, имя (при добровольном указании), номер телефона (при оформлении доставки), адрес доставки.', +'9.4. Цели обработки персональных данных: обеспечение функционирования Сайта, аутентификация Пользователя, оформление и доставка заказов, направление транзакционных уведомлений.', +``` + +- [ ] **Step 8: Проверить линтер** + +```bash +cd client && npm run lint +``` + +Expected: 0 новых ошибок. + +--- + +### Task 4: Финальная проверка + +**Files:** No modifications, verification only. + +- [ ] **Step 1: TypeScript check** + +```bash +cd client && npx tsc -b --noEmit +``` + +Expected: no errors. + +- [ ] **Step 2: Lint** + +```bash +cd client && npm run lint +``` + +Expected: 0 errors (warnings OK). + +- [ ] **Step 3: Сборка** + +```bash +cd client && npm run build +``` + +Expected: успешная сборка. + +- [ ] **Step 4: Format check** + +```bash +cd client && npm run format:check +``` + +Expected: все файлы отформатированы (или отформатировать через `npm run format`). diff --git a/.opencode/specs/2026-05-23-legal-docs-fix-design.md b/.opencode/specs/2026-05-23-legal-docs-fix-design.md new file mode 100644 index 0000000..a1c362b --- /dev/null +++ b/.opencode/specs/2026-05-23-legal-docs-fix-design.md @@ -0,0 +1,64 @@ +# Юридические документы: приведение к реальности + удаление аккаунта + cookie-баннер + +## Данные оператора + +- **Имя:** Комарова Лариса Николаевна (самозанятый) +- **ИНН:** 402900832341 (тестовый) +- **Адрес:** 34, ул. Мира, кв. 34, Лысьва, Пермский край, 618909 +- **Сайт:** https://любимыйкреатив.рф +- **ОГРН:** отсутствует (самозанятый) + +## Задачи + +### 1. Shared config — обновить данные оператора +Файл: `client/src/shared/config/index.ts` +- `STORE_OP_NAME`, `STORE_OP_TYPE`, `STORE_OP_INN` (тестовый), `STORE_OP_ADDR` +- Убрать `STORE_OP_OGRN` +- `STORE_PUBLIC_SITE_URL`: https://любимыйкреатив.рф + +### 2. Политика конфиденциальности +Файл: `client/src/pages/privacy-policy/ui/PrivacyPolicyPage.tsx` +- Импорт из config, убрать локальные константы +- Оператор: самозанятый вместо ИП, без ОГРН +- Раздел 2: только email, имя, телефон (при доставке), адрес, сессионные cookie (без ФИО, аналитики) +- Раздел 3: без персонализации +- Раздел 5: только автообработка, срок до достижения целей +- Раздел 6: Яндекс.Метрика → ЮKassa +- Раздел 7: добавить право на самоудаление (п. 7.3) +- Добавить дату обновления +- Перенести cookie-раздел из Соглашения в Политику + +### 3. Пользовательское соглашение +Файл: `client/src/pages/terms/ui/TermsPage.tsx` +- Импорт из config, убрать локальные константы +- Оператор: самозанятый, без ОГРН +- Раздел 1: упомянуть OAuth и вход по коду +- П. 3.7: «рекламные» → «транзакционные» +- П. 6.1: убрать противоречие с «as is» +- Раздел 7: реальные третьи лица (ЮKassa, OSM) +- Раздел 8: cookie только для сессии, не для аналитики +- П. 9.3-9.4: без IP/браузера/персонализации, только реальные данные + +### 4. Cookie-баннер +Новый компонент: `client/src/shared/ui/CookieConsentBanner.tsx` +- Снизу, фиксированный, localStorage +- Текст о cookie и ссылка на Политику +- Кнопка «Понятно» +- Рендер в MainLayout перед футером + +### 5. Текст согласия на формах входа/регистрации +- AuthPasswordForm, AuthCodeForm +- Под кнопкой отправки: «Нажимая «Продолжить», вы принимаете пользовательское соглашение и политику конфиденциальности» +- Ссылки на /terms и /privacy + +### 6. Удаление аккаунта +**Сервер:** `DELETE /api/me` в `server/src/routes/auth.js` +- Проверка активных заказов (не DONE, не CANCELLED) +- Если нет активных — каскадное удаление +- Если есть — 400 с перечнем заказов + +**Клиент:** секция в SettingsPage +- Кнопка «Удалить аккаунт» (outlined, error) +- Tooltip при активных заказах +- Диалог подтверждения +- После удаления — редирект на / diff --git a/.superpowers/brainstorm/5692-1779609387/state/server.pid b/.superpowers/brainstorm/5692-1779609387/state/server.pid new file mode 100644 index 0000000..7a1f00d --- /dev/null +++ b/.superpowers/brainstorm/5692-1779609387/state/server.pid @@ -0,0 +1 @@ +5700 diff --git a/.superpowers/brainstorm/7074-1779609479/state/server-stopped b/.superpowers/brainstorm/7074-1779609479/state/server-stopped new file mode 100644 index 0000000..37b7b01 --- /dev/null +++ b/.superpowers/brainstorm/7074-1779609479/state/server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1779612416287} diff --git a/.superpowers/brainstorm/7074-1779609479/state/server.pid b/.superpowers/brainstorm/7074-1779609479/state/server.pid new file mode 100644 index 0000000..c048eab --- /dev/null +++ b/.superpowers/brainstorm/7074-1779609479/state/server.pid @@ -0,0 +1 @@ +7319 diff --git a/AGENTS.md b/AGENTS.md index f09b314..5fa1c06 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,6 +67,14 @@ cd client && npm run build # full typecheck + build - Yandex callback: `{SERVER_PUBLIC_URL}/api/auth/oauth/yandex/callback` - Required env vars: `VK_CLIENT_ID`, `VK_CLIENT_SECRET`, `YANDEX_CLIENT_ID`, `YANDEX_CLIENT_SECRET`, `SERVER_PUBLIC_URL`, `CLIENT_PUBLIC_URL` +## Infrastructure (deployment) + +- **VPS** runs Nginx Proxy Manager (NPM), connected via Netbird peer-to-peer VPN to the dev machine +- **Local dev machine** runs the project (server + client), also a Netbird peer +- **Traffic flow**: Browser → Domain (A record → VPS IP) → NPM → Netbird tunnel → Local dev machine (`server:3333`) +- NPM manages SSL, domains, and proxy hosts +- `trustProxy: true` on Fastify — `request.ip` works correctly through NPM/Netbird chain + ## Notable quirks - `.env` is gitignored. Copy `.env.example` to `.env` for local dev. diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index d497721..fe683e7 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -10,6 +10,7 @@ import { Link as RouterLink } from 'react-router-dom' import { AppHeader } from '@/app/layout/AppHeader' import vkLogoSrc from '@/shared/assets/vk-logo.svg' import { STORE_EMAIL, STORE_NAME, STORE_PHONE, VK_URL } from '@/shared/config' +import { CookieConsentBanner } from '@/shared/ui/CookieConsentBanner' import { ScrollOnNavigate } from '@/shared/ui/ScrollOnNavigate' import { ScrollToTop } from '@/shared/ui/ScrollToTop' @@ -118,6 +119,7 @@ export function MainLayout({ children }: PropsWithChildren) { + ) } diff --git a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx index ce1b8e9..2d46d42 100644 --- a/client/src/features/address-map-picker/ui/AddressMapPicker.tsx +++ b/client/src/features/address-map-picker/ui/AddressMapPicker.tsx @@ -128,6 +128,7 @@ export function AddressMapPicker(props: { const lat = Number(r.lat) const lng = Number(r.lon) if (!Number.isFinite(lat) || !Number.isFinite(lng)) return + mapRef.current?.flyTo({ center: [lng, lat], zoom: 13, duration: 800 }) void pick({ lat, lng }) }} > diff --git a/client/src/features/auth-code/ui/AuthCodeForm.tsx b/client/src/features/auth-code/ui/AuthCodeForm.tsx index 3a53843..7287bcb 100644 --- a/client/src/features/auth-code/ui/AuthCodeForm.tsx +++ b/client/src/features/auth-code/ui/AuthCodeForm.tsx @@ -1,10 +1,13 @@ import Button from '@mui/material/Button' import InputAdornment from '@mui/material/InputAdornment' +import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { Mail } from 'lucide-react' import { useForm } from 'react-hook-form' +import { Link as RouterLink } from 'react-router-dom' import { apiClient } from '@/shared/api/client' import { getApiErrorMessage } from '@/shared/lib/get-api-error-message' import { tokenSet } from '@/shared/model/auth' @@ -95,6 +98,18 @@ export function AuthCodeForm({ onSuccess }: Props) { sx={{ display: 'none' }} /> )} + + + Нажимая «Войти», вы принимаете{' '} + + пользовательское соглашение + {' '} + и{' '} + + политику конфиденциальности + + . + ) } diff --git a/client/src/features/auth-password/ui/AuthPasswordForm.tsx b/client/src/features/auth-password/ui/AuthPasswordForm.tsx index 7069893..680ee36 100644 --- a/client/src/features/auth-password/ui/AuthPasswordForm.tsx +++ b/client/src/features/auth-password/ui/AuthPasswordForm.tsx @@ -1,10 +1,13 @@ import Button from '@mui/material/Button' import InputAdornment from '@mui/material/InputAdornment' +import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' import TextField from '@mui/material/TextField' +import Typography from '@mui/material/Typography' import { useMutation } from '@tanstack/react-query' import { Lock, Mail } from 'lucide-react' import { useForm } from 'react-hook-form' +import { Link as RouterLink } from 'react-router-dom' import { apiClient } from '@/shared/api/client' import { getApiErrorMessage } from '@/shared/lib/get-api-error-message' import { tokenSet } from '@/shared/model/auth' @@ -185,6 +188,18 @@ export function AuthPasswordForm({ isRegister, onRegisterChange, onSuccess }: Pr sx={{ display: 'none' }} /> )} + + + Нажимая «{isRegister ? 'Зарегистрироваться' : 'Войти'}», вы принимаете{' '} + + пользовательское соглашение + {' '} + и{' '} + + политику конфиденциальности + + . + ) } diff --git a/client/src/features/product-review/ui/ProductReviewsList.tsx b/client/src/features/product-review/ui/ProductReviewsList.tsx index b98e351..130950f 100644 --- a/client/src/features/product-review/ui/ProductReviewsList.tsx +++ b/client/src/features/product-review/ui/ProductReviewsList.tsx @@ -19,12 +19,7 @@ function ReviewItem({ rv }: { rv: PublicProductReviewItem }) { - + {rv.authorDisplay} diff --git a/client/src/pages/about/ui/AboutPage.tsx b/client/src/pages/about/ui/AboutPage.tsx index 54c9bb8..bfeac6f 100644 --- a/client/src/pages/about/ui/AboutPage.tsx +++ b/client/src/pages/about/ui/AboutPage.tsx @@ -35,12 +35,8 @@ export function AboutPage() { - Контакты и самовывоз + Контакты - - Забрать заказ можно по адресу самовывоза (координаты указаны на карте ниже): - - {PICKUP_ADDRESS_FULL} Email:{' '} @@ -58,6 +54,8 @@ export function AboutPage() { ВКонтакте + Забрать заказ можно по адресу самовывоза: + {PICKUP_ADDRESS_FULL} Перед визитом мы свяжемся с вами и согласуем время — чтобы заказ точно был готов к выдаче. @@ -75,7 +73,7 @@ export function AboutPage() { > - - Информация для покупателей - - - Как оформить заказ, как проходит доставка, оплата и другие важные детали. - + + {/* Hero */} + + + Информация для покупателей + + + Как оформить заказ, как проходит доставка, оплата и другие важные детали. + + - - - - - - - + {/* Main content grid */} + + {/* Left column */} + + + + + + + {/* Right column */} + + + + + + + + + ) } diff --git a/client/src/pages/info/ui/sections/DeliverySection.tsx b/client/src/pages/info/ui/sections/DeliverySection.tsx index 2a6cc01..8fe2f93 100644 --- a/client/src/pages/info/ui/sections/DeliverySection.tsx +++ b/client/src/pages/info/ui/sections/DeliverySection.tsx @@ -1,52 +1,172 @@ -import Grid from '@mui/material/Grid' -import Paper from '@mui/material/Paper' +import Box from '@mui/material/Box' +import Link from '@mui/material/Link' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { Package, Store } from 'lucide-react' +import { Link as RouterLink } from 'react-router-dom' +import { DELIVERY_CARRIER_OPTIONS } from '@/shared/constants/delivery-carrier' import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point' -const deliveries = [ - { - title: 'Самовывоз', - icon: , - lines: ['Бесплатно.', PICKUP_ADDRESS_FULL, 'Перед визитом согласуем время — чтобы заказ точно был готов к выдаче.'], - }, - { - title: 'Почта / Службы доставки', - icon: , - lines: [ - 'Отправка в другие города.', - 'Каждому заказу присваивается трек-номер для отслеживания.', - 'Стоимость рассчитывается по тарифу перевозчика при оформлении.', - ], - }, -] - export function DeliverySection() { return ( - - + + Доставка - - {deliveries.map((d) => ( - - - - - {d.icon} - {d.title} - - {d.lines.map((line, i) => ( - - {line} - - ))} - - - - ))} - - + + + {/* Pickup */} + + + + + + + + Самовывоз + + + + Бесплатно. + + + {PICKUP_ADDRESS_FULL} + + + Перед визитом согласуем время — чтобы заказ точно был готов к выдаче. + + + Посмотреть на карте + + + + + + + {/* Delivery */} + + + + + + + + Доставка по России + + + + Доступные службы доставки: + + + {DELIVERY_CARRIER_OPTIONS.map((c) => ( + + {c.label} + + ))} + + + Стоимость рассчитывается по тарифу перевозчика. Админ скорректирует цену после оформления заказа. + + + + + ) } diff --git a/client/src/pages/info/ui/sections/HowToOrderSection.tsx b/client/src/pages/info/ui/sections/HowToOrderSection.tsx index c51a52d..7a79b59 100644 --- a/client/src/pages/info/ui/sections/HowToOrderSection.tsx +++ b/client/src/pages/info/ui/sections/HowToOrderSection.tsx @@ -1,55 +1,233 @@ -import Paper from '@mui/material/Paper' -import Step from '@mui/material/Step' -import StepContent from '@mui/material/StepContent' -import StepLabel from '@mui/material/StepLabel' -import Stepper from '@mui/material/Stepper' +import { useState } from 'react' +import Box from '@mui/material/Box' +import Link from '@mui/material/Link' +import Stack from '@mui/material/Stack' +import Tab from '@mui/material/Tab' +import Tabs from '@mui/material/Tabs' import Typography from '@mui/material/Typography' -import { CheckCircle, ClipboardList, Mail, ShoppingCart, Truck } from 'lucide-react' +import { + CheckCircle, + ClipboardList, + Clock, + CreditCard, + MapPin, + PackageOpen, + ShoppingCart, + Star, + Store, + Truck, +} from 'lucide-react' +import { Link as RouterLink } from 'react-router-dom' +import { PICKUP_ADDRESS_SHORT } from '@/shared/constants/pickup-point' -const steps = [ +const commonSteps = [ { label: 'Выберите товары', - icon: , + icon: , text: 'Найдите нужные изделия в каталоге и добавьте их в корзину. Вы можете выбрать несколько товаров от разных мастеров — все они соберутся в одном заказе.', }, { label: 'Проверьте корзину', - icon: , + icon: , text: 'Перейдите в корзину и проверьте состав заказа: названия товаров, количество и итоговую сумму. Здесь же можно изменить количество или удалить позиции.', }, - { - label: 'Укажите контакты и адрес', - icon: , - text: 'Заполните имя, телефон и email для связи. Укажите адрес доставки — город, улицу, дом и квартиру. Эти данные нужны для расчёта стоимости и сроков.', - }, - { - label: 'Выберите доставку и оплату', - icon: , - text: 'Выберите способ доставки: самовывоз, курьер или почта/СДЭК. Затем укажите способ оплаты: картой онлайн или при получении.', - }, - { - label: 'Подтвердите заказ', - icon: , - text: 'Проверьте все данные ещё раз и нажмите «Оформить заказ». После этого мастер получит уведомление и начнёт подготовку вашего изделия.', - }, ] -export function HowToOrderSection() { +const deliverySteps = [ + { + label: 'Укажите адрес доставки и получателя', + icon: , + text: 'Заполните имя, телефон, и адрес доставки. Если у вас уже есть сохранённые адреса — выберите из списка или добавьте новый в личном кабинете.', + }, + { + label: 'Выберите способ доставки', + icon: , + text: 'Доступны: Почта России, Озон ПВЗ, Яндекс ПВЗ, 5Post, WB ПВЗ. После оформления админ рассчитает точную стоимость доставки и скорректирует цену заказа.', + }, + { + label: 'Оплатите заказ', + icon: , + text: 'Онлайн-оплата через ЮKassa — банковские карты и СБП. Перенаправление на защищённую платёжную страницу.', + }, + { + label: 'Получите заказ', + icon: , + text: 'После отправки вы получите трек-номер для отслеживания. Следите за статусом заказа в личном кабинете.', + }, +] + +const pickupSteps = [ + { + label: 'Подтвердите заказ', + icon: , + text: 'Выберите способ оплаты: онлайн через ЮKassa (карты, СБП) или при получении (наличные / карта).', + }, + { + label: 'Согласуйте время получения', + icon: , + text: 'Админ свяжется с вами, чтобы договориться об удобном времени выдачи заказа.', + }, + { + label: 'Получите заказ', + icon: , + text: `Адрес: ${PICKUP_ADDRESS_SHORT}. Перед визитом согласуем время, чтобы заказ точно был готов.`, + }, +] + +function StepRow({ step, isLast }: { step: (typeof commonSteps)[0]; isLast: boolean }) { return ( - - - Как оформить заказ - - - {steps.map((step) => ( - - step.icon }}>{step.label} - - {step.text} - - - ))} - - + + + + {step.icon} + + {!isLast && ( + + )} + + + + {step.label} + + + {step.text} + + + + ) +} + +function BranchStepper({ steps }: { steps: typeof deliverySteps }) { + return ( + + {steps.map((step, i) => ( + + ))} + + ) +} + +export function HowToOrderSection() { + const [tab, setTab] = useState(0) + + return ( + + + Как оформить заказ + + + + + + setTab(v)} + variant="fullWidth" + sx={{ + minHeight: 36, + '& .MuiTab-root': { + minHeight: 36, + fontSize: '0.8rem', + fontWeight: 600, + letterSpacing: '-0.01em', + }, + '& .MuiTabs-indicator': { + height: 2, + borderRadius: 1, + }, + }} + > + + + + + + {tab === 0 && } + {tab === 1 && ( + + + + + Посмотреть на карте + + + + )} + + + + + + + После получения заказа вы можете оставить отзыв в личном кабинете. + + + ) } diff --git a/client/src/pages/info/ui/sections/OrderStatusesSection.tsx b/client/src/pages/info/ui/sections/OrderStatusesSection.tsx new file mode 100644 index 0000000..fbd3738 --- /dev/null +++ b/client/src/pages/info/ui/sections/OrderStatusesSection.tsx @@ -0,0 +1,247 @@ +import Box from '@mui/material/Box' +import Stack from '@mui/material/Stack' +import Typography from '@mui/material/Typography' +import { ORDER_STATUS_DATA, type StatusIconName } from '@/shared/lib/order-status-data' + +const iconMap: Record = { + banknote: ( + + + + + + ), + 'check-circle': ( + + + + + ), + 'package-search': ( + + + + + + + ), + package: ( + + + + + + ), + 'package-check': ( + + + + + + + ), + store: ( + + + + + ), + 'x-circle': ( + + + + + ), +} + +const colorMap: Record = { + warning: { + bg: 'rgba(245, 124, 0, 0.04)', + border: 'rgba(245, 124, 0, 0.15)', + text: '#c27a00', + dot: '#F57C00', + iconBg: 'rgba(245, 124, 0, 0.1)', + }, + success: { + bg: 'rgba(46, 139, 87, 0.04)', + border: 'rgba(46, 139, 87, 0.15)', + text: '#1e6e42', + dot: '#2E8B57', + iconBg: 'rgba(46, 139, 87, 0.1)', + }, + info: { + bg: 'rgba(84, 110, 122, 0.04)', + border: 'rgba(84, 110, 122, 0.15)', + text: '#3d5a68', + dot: '#546E7A', + iconBg: 'rgba(84, 110, 122, 0.1)', + }, + error: { + bg: 'rgba(211, 47, 47, 0.04)', + border: 'rgba(211, 47, 47, 0.15)', + text: '#a83232', + dot: '#D32F2F', + iconBg: 'rgba(211, 47, 47, 0.1)', + }, +} + +export function OrderStatusesSection() { + return ( + + + + Статусы заказа + + + Текущий статус отображается в личном кабинете. Каждый этап отражает, что происходит с вашим заказом. + + + + + {ORDER_STATUS_DATA.map((s, index) => { + const colors = colorMap[s.color] ?? colorMap.info + const isLast = index === ORDER_STATUS_DATA.length - 1 + + return ( + + {/* Content column */} + + + + {iconMap[s.iconName]} + + + {s.label} + + + + {s.description} + + + + ) + })} + + + ) +} diff --git a/client/src/pages/info/ui/sections/PaymentSection.tsx b/client/src/pages/info/ui/sections/PaymentSection.tsx index d558824..0e43938 100644 --- a/client/src/pages/info/ui/sections/PaymentSection.tsx +++ b/client/src/pages/info/ui/sections/PaymentSection.tsx @@ -1,42 +1,103 @@ -import List from '@mui/material/List' -import ListItem from '@mui/material/ListItem' -import ListItemIcon from '@mui/material/ListItemIcon' -import ListItemText from '@mui/material/ListItemText' -import Paper from '@mui/material/Paper' +import Box from '@mui/material/Box' +import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' import { Banknote, CreditCard } from 'lucide-react' const methods = [ { - icon: , - primary: 'Банковская карта онлайн', - secondary: 'Оплата картой Visa, Mastercard или МИР сразу при оформлении заказа.', + icon: , + primary: 'Онлайн-оплата через ЮKassa', + secondary: 'Карты, СБП. Оплата после подтверждения заказа админом. Перенаправление на защищённую платёжную страницу.', }, { - icon: , + icon: , primary: 'Оплата при получении', - secondary: 'Оплата наличными или картой при получении заказа.', + secondary: 'Наличными или картой при самовывозе.', }, ] export function PaymentSection() { return ( - - + + Оплата - - Оплата происходит после подтверждения заказа мастером. Вы получите уведомление, когда заказ будет подтверждён и - готов к оплате. + + Оплата происходит после подтверждения заказа админом. Вы получите уведомление, когда заказ будет подтверждён и готов к оплате. - + + {methods.map((m) => ( - - {m.icon} - - + + + {m.icon} + + + + {m.primary} + + + {m.secondary} + + + ))} - - + + ) } diff --git a/client/src/pages/info/ui/sections/ReturnsSection.tsx b/client/src/pages/info/ui/sections/ReturnsSection.tsx index 9e691f8..63fc641 100644 --- a/client/src/pages/info/ui/sections/ReturnsSection.tsx +++ b/client/src/pages/info/ui/sections/ReturnsSection.tsx @@ -1,35 +1,67 @@ -import Paper from '@mui/material/Paper' +import Box from '@mui/material/Box' import Stack from '@mui/material/Stack' import Typography from '@mui/material/Typography' export function ReturnsSection() { return ( - - + + Возврат и гарантии - - - + + + + Возврат - - Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней - после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества - возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид. + + Если товар не соответствует описанию или имеет производственный дефект, свяжитесь с нами в течение 7 дней после получения. Мы заменим изделие на аналогичное или вернём деньги. Возврат товара надлежащего качества возможен в течение 14 дней, если изделие не было в употреблении и сохранён его товарный вид. - - - + + + + Гарантия качества - - Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, - устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы - решим проблему в кратчайшие сроки. + + Мы отвечаем за качество каждого изделия ручной работы. Все дефекты, возникшие не по вине покупателя, устраняются или компенсируются заменой изделия. Если у вас возникли вопросы по качеству — напишите нам, и мы решим проблему в кратчайшие сроки. - + - + ) } diff --git a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx index ca090cd..00993c8 100644 --- a/client/src/pages/me/ui/sections/AuthMethodsSection.tsx +++ b/client/src/pages/me/ui/sections/AuthMethodsSection.tsx @@ -10,6 +10,8 @@ import { useMutation } from '@tanstack/react-query' import { useUnit } from 'effector-react' import { useForm } from 'react-hook-form' import { useSearchParams } from 'react-router-dom' +import { getErrorMessage } from '@/shared/lib/get-error-message' +import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email' import { $user, changePasswordFx, @@ -19,7 +21,6 @@ import { unlinkOAuthFx, type AuthMethod, } from '@/shared/model/auth' -import { getErrorMessage } from '@/shared/lib/get-error-message' const METHOD_LABELS: Record = { password: 'Пароль', vk: 'ВКонтакте', yandex: 'Яндекс' } @@ -117,6 +118,13 @@ export function AuthMethodsSection() { {user.email} + {isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о + заказах. + + )} + {!verificationUrl && ( ACTIVE_STATUSES.includes(o.status)) ?? [] + const hasActiveOrders = activeOrders.length > 0 + + const deleteMutation = useMutation({ + mutationFn: async () => { + await apiClient.delete('me') + }, + onSuccess: () => { + tokenSet(null) + logout() + setDialogOpen(false) + navigate('/') + }, + }) + + return ( + <> + + + + Удаление аккаунта + + + + + {deleteMutation.error && ( + + {getApiErrorMessage(deleteMutation.error) || 'Не удалось удалить аккаунт'} + + )} + + + setDialogOpen(false)}> + Удаление аккаунта + + {hasActiveOrders && ( + + У вас есть {activeOrders.length} незавершённых заказ + {activeOrders.length === 1 ? '' : activeOrders.length < 5 ? 'а' : 'ов'}. После удаления аккаунта + отслеживание заказов станет недоступным. + + )} + + Вы уверены? Все данные будут безвозвратно удалены. Восстановить аккаунт будет невозможно. + + + + + + + + + ) +} diff --git a/client/src/pages/me/ui/sections/NotificationsPage.tsx b/client/src/pages/me/ui/sections/NotificationsPage.tsx index f1b07be..a9c5819 100644 --- a/client/src/pages/me/ui/sections/NotificationsPage.tsx +++ b/client/src/pages/me/ui/sections/NotificationsPage.tsx @@ -6,11 +6,14 @@ import Stack from '@mui/material/Stack' import Switch from '@mui/material/Switch' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' +import { useUnit } from 'effector-react' import { fetchUserNotificationSettings, updateUserNotificationSettings, } from '@/entities/notification/api/notifications-api' import type { UserNotificationSettings } from '@/entities/notification/api/notifications-api' +import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email' +import { $user } from '@/shared/model/auth' function isOrderStatusChangesOn(s: UserNotificationSettings): boolean { return s.orderCreated && s.orderStatusChanged && s.paymentStatusChanged && s.deliveryFeeAdjusted @@ -26,6 +29,7 @@ const orderStatusChangesPayload = (on: boolean) => ({ export function NotificationsPage() { const queryClient = useQueryClient() const [error, setError] = useState(null) + const user = useUnit($user) const { data, isLoading } = useQuery({ queryKey: ['me', 'notifications', 'settings'], @@ -63,6 +67,12 @@ export function NotificationsPage() { Настройте, какие уведомления вы хотите получать на почту. + {user && isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля. + + )} + {error && ( {error} diff --git a/client/src/pages/me/ui/sections/OrderDetailPage.tsx b/client/src/pages/me/ui/sections/OrderDetailPage.tsx index ae3d180..720e53f 100644 --- a/client/src/pages/me/ui/sections/OrderDetailPage.tsx +++ b/client/src/pages/me/ui/sections/OrderDetailPage.tsx @@ -25,7 +25,7 @@ import { deliveryCarrierLabelRu } from '@/shared/constants/delivery-carrier' import { PICKUP_ADDRESS_FULL } from '@/shared/constants/pickup-point' import { formatPriceRub } from '@/shared/lib/format-price' import { parseOrderAddressSnapshot } from '@/shared/lib/order-address-snapshot' -import { orderStatusLabelRu } from '@/shared/lib/order-status-labels' +import { OrderStatusChip } from '@/shared/ui/OrderStatusChip' export function OrderDetailPage() { const { id } = useParams() @@ -130,7 +130,14 @@ export function OrderDetailPage() { Заказ #{order.id.slice(-6)} - Статус: {orderStatusLabelRu(order.status)} + + + ) +} diff --git a/client/src/shared/ui/OrderStatusChip.tsx b/client/src/shared/ui/OrderStatusChip.tsx new file mode 100644 index 0000000..5e50236 --- /dev/null +++ b/client/src/shared/ui/OrderStatusChip.tsx @@ -0,0 +1,158 @@ +import { type ReactNode, useState } from 'react' +import Box from '@mui/material/Box' +import Tooltip from '@mui/material/Tooltip' +import Typography from '@mui/material/Typography' +import { getOrderStatusData, type StatusIconName } from '@/shared/lib/order-status-data' + +const iconMap: Record = { + banknote: ( + + + + + + ), + 'check-circle': ( + + + + + ), + 'package-search': ( + + + + + + + ), + package: ( + + + + + + ), + 'package-check': ( + + + + + + + ), + store: ( + + + + + ), + 'x-circle': ( + + + + + ), +} + +const colorMap: Record = { + warning: { + bg: 'rgba(245, 124, 0, 0.06)', + border: 'rgba(245, 124, 0, 0.25)', + text: '#c27a00', + dot: '#F57C00', + }, + success: { + bg: 'rgba(46, 139, 87, 0.06)', + border: 'rgba(46, 139, 87, 0.25)', + text: '#1e6e42', + dot: '#2E8B57', + }, + info: { + bg: 'rgba(84, 110, 122, 0.06)', + border: 'rgba(84, 110, 122, 0.25)', + text: '#3d5a68', + dot: '#546E7A', + }, + error: { + bg: 'rgba(211, 47, 47, 0.06)', + border: 'rgba(211, 47, 47, 0.25)', + text: '#a83232', + dot: '#D32F2F', + }, +} + +interface OrderStatusChipProps { + status: string + tooltipOverride?: string + size?: 'sm' | 'md' +} + +export function OrderStatusChip({ status, tooltipOverride, size = 'md' }: OrderStatusChipProps) { + const data = getOrderStatusData(status) + const label = data?.label ?? status + const colorKey = data?.color ?? 'default' + const icon = data ? iconMap[data.iconName] : undefined + const tooltip = tooltipOverride ?? data?.description ?? '' + const colors = colorMap[colorKey] ?? colorMap.info + const [hovered, setHovered] = useState(false) + + const padding = size === 'sm' ? '3px 10px' : '5px 14px' + const fontSize = size === 'sm' ? '0.7rem' : '0.75rem' + const iconSize = size === 'sm' ? 14 : 16 + + return ( + + setHovered(true)} + onMouseLeave={() => setHovered(false)} + sx={{ + display: 'inline-flex', + alignItems: 'center', + gap: '6px', + padding: padding, + borderRadius: '999px', + border: `1px solid ${colors.border}`, + backgroundColor: hovered ? colors.bg : 'transparent', + transition: 'all 0.2s cubic-bezier(0.16, 1, 0.3, 1)', + cursor: 'default', + '&:active': { + transform: 'scale(0.97)', + }, + }} + > + + + {icon} + + + {label} + + + + ) +} diff --git a/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md b/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md new file mode 100644 index 0000000..4ec76c1 --- /dev/null +++ b/docs/superpowers/plans/2026-05-23-ip-gate-access-control.md @@ -0,0 +1,387 @@ +# IP-gate Access Control 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:** Добавить IP-whitelist на уровне Fastify, ограничивающий доступ к сайту на время разработки. + +**Architecture:** Новый плагин `server/src/plugins/ip-gate.js` с `onRequest` хуком. Проверяет `request.ip` против `SITE_ACCESS_IPS` из `.env`. Часть путей (OAuth callbacks, webhook-и) исключены из проверки. При отказе — HTML-страница 403 с информацией о магазине и IP посетителя. + +**Tech Stack:** Fastify, Node.js, vitest + +--- + +## Task 1: Plugin — create ip-gate.js + +**Files:** +- Create: `server/src/plugins/ip-gate.js` + +- [ ] **Step 1: Write the plugin** + +```js +const EXCLUDED_PATHS = [ + '/api/auth/oauth/vk/callback', + '/api/auth/oauth/yandex/callback', + '/api/webhooks/yookassa', + '/api/admin/notifications/telegram/webhook', +] + +function build403Html(ip) { + const safeIp = ip || 'не определён' + return ` + + + + +Любимый Креатив — Доступ запрещён + + + +
+

Любимый Креатив

+

Изделия ручной работы: вещи с характером и вниманием к деталям

+

Сайт находится в разработке
и скоро будет доступен

+

Ваш IP: ${safeIp}

+
+ +` +} + +export async function registerIpGate(fastify) { + fastify.addHook('onRequest', async (request, reply) => { + const allowed = process.env.SITE_ACCESS_IPS + if (!allowed) return + + const allowedIps = allowed + .split(',') + .map((s) => s.trim()) + .filter(Boolean) + + if (allowedIps.length === 0) return + + const urlPath = request.url.split('?')[0] + + if (EXCLUDED_PATHS.includes(urlPath)) return + + if (allowedIps.includes(request.ip)) return + + return reply.code(403).type('text/html').send(build403Html(request.ip)) + }) +} +``` + +- [ ] **Step 2: No tests yet — commit the plugin skeleton** + +```bash +git add server/src/plugins/ip-gate.js +git commit -m "feat: add ip-gate plugin with env-based IP whitelist" +``` + +--- + +## Task 2: Tests for ip-gate + +**Files:** +- Create: `server/src/plugins/__tests__/ip-gate.test.js` + +- [ ] **Step 1: Write all tests** + +```js +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { registerIpGate } from '../ip-gate.js' + +function buildApp() { + const app = Fastify({ logger: false, trustProxy: true }) + app.get('/test', async () => ({ ok: true })) + app.get('/api/webhooks/yookassa', async () => ({ ok: true })) + app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true })) + app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true })) + app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true })) + return app +} + +describe('registerIpGate', () => { + let app + const originalIps = process.env.SITE_ACCESS_IPS + + beforeEach(async () => { + app = buildApp() + await registerIpGate(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + process.env.SITE_ACCESS_IPS = originalIps + }) + + it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => { + delete process.env.SITE_ACCESS_IPS + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('пропускает запрос с разрешённого IP', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8' + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' }) + expect(res.statusCode).toBe(200) + }) + + it('блокирует запрос с неразрешённого IP (403)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' }) + expect(res.statusCode).toBe(403) + expect(res.headers['content-type']).toMatch(/text\/html/) + expect(res.body).toContain('Любимый Креатив') + expect(res.body).toContain('9.9.9.9') + }) + + it('403-страница показывает "не определён" когда IP отсутствует', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(403) + expect(res.body).toContain('не определён') + }) + + it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/yookassa', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (vk callback)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/auth/oauth/vk/callback', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (yandex callback)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/auth/oauth/yandex/callback', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (telegram webhook)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/admin/notifications/telegram/webhook', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('корректно тримит пробелы в списке IP', async () => { + process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 ' + const res = await app.inject({ + method: 'GET', + url: '/test', + remoteAddress: '5.6.7.8', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает если после трима список IP пуст', async () => { + process.env.SITE_ACCESS_IPS = ' , , ' + const res = await app.inject({ + method: 'GET', + url: '/test', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('путь с query-параметрами проверяется корректно', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/test?foo=bar', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(403) + }) + + it('исключённый путь с query-параметрами тоже пропускается', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/yookassa?foo=bar', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) +}) +``` + +- [ ] **Step 2: Run tests to verify they fail (plugin not registered yet in tests)** + +```bash +cd server && npx vitest run src/plugins/__tests__/ip-gate.test.js +``` +Expected: all tests pass (plugin is registered in the `beforeEach`). + +- [ ] **Step 3: Commit tests** + +```bash +git add server/src/plugins/__tests__/ip-gate.test.js +git commit -m "test: add ip-gate access control tests" +``` + +--- + +## Task 3: Register plugin in index.js + +**Files:** +- Modify: `server/src/index.js` + +- [ ] **Step 1: Add import** + +Add after the existing plugin imports (after line 20): + +```js +import { registerIpGate } from './plugins/ip-gate.js' +``` + +- [ ] **Step 2: Register plugin before routes** + +Add before `registerAuth(fastify)` (before line 92): + +```js +await registerIpGate(fastify) +``` + +- [ ] **Step 3: Verify server starts** + +```bash +cd server && node --env-file=.env --eval " + import('./src/index.js').catch(e => { console.error(e.message); process.exit(1) }) +" +``` + +Wait ~5 seconds, then Ctrl+C. Expected: no errors in startup logs. + +- [ ] **Step 4: Commit** + +```bash +git add server/src/index.js +git commit -m "feat: register ip-gate plugin in server startup" +``` + +--- + +## Task 4: Environment variable docs + +**Files:** +- Modify: `server/.env.example` + +- [ ] **Step 1: Add SITE_ACCESS_IPS to .env.example** + +Add after the `CORS_ORIGIN` comment block (after line 18): + +``` +# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена. +# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8 +``` + +- [ ] **Step 2: Add SITE_ACCESS_IPS to actual .env** + +```bash +echo "" >> server/.env +echo "# Ограничение доступа по IP. Раскомментируй и укажи свои IP." >> server/.env +echo "# SITE_ACCESS_IPS=1.2.3.4" >> server/.env +``` + +- [ ] **Step 3: Commit** + +```bash +git add server/.env.example +git commit -m "docs: add SITE_ACCESS_IPS env var to .env.example" +``` + +--- + +## Task 5: Final verification + +**Files:** none (verification only) + +- [ ] **Step 1: Run all server tests** + +```bash +cd server && npm test +``` +Expected: all tests pass, including ip-gate tests. + +- [ ] **Step 2: Run ESLint on new files** + +```bash +cd server && npx eslint src/plugins/ip-gate.js src/plugins/__tests__/ip-gate.test.js +``` +Expected: no errors. + +- [ ] **Step 3: Test end-to-end manually** + +1. Set `SITE_ACCESS_IPS=127.0.0.1` in `server/.env` +2. Start server: `cd server && npm run dev` +3. `curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3333/api/catalog` + Expected: `200` (127.0.0.1 в списке) +4. Test blocked: `curl` from a machine not in the list should get 403 +5. Remove `SITE_ACCESS_IPS` and verify all requests pass again diff --git a/docs/superpowers/plans/2026-05-24-synthetic-email-warning.md b/docs/superpowers/plans/2026-05-24-synthetic-email-warning.md new file mode 100644 index 0000000..8c94650 --- /dev/null +++ b/docs/superpowers/plans/2026-05-24-synthetic-email-warning.md @@ -0,0 +1,227 @@ +# Synthetic Email Warning 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:** Display informational alerts in the personal account when the user's email is synthetic (e.g., `vk_@vk.local`), informing them they cannot receive email notifications. + +**Architecture:** Pure client-side utility checks email against known synthetic domains. Two MUI Alert components conditionally render in AuthMethodsSection and NotificationsPage. + +**Tech Stack:** TypeScript, React, MUI (Alert component), Vitest + +--- + +### Task 1: Create `isSyntheticEmail` utility + +**Files:** +- Create: `client/src/shared/lib/is-synthetic-email.ts` +- Test: `client/src/shared/lib/__tests__/is-synthetic-email.test.ts` + +- [ ] **Step 1: Write the failing test** + +```ts +// client/src/shared/lib/__tests__/is-synthetic-email.test.ts +import { describe, expect, it } from 'vitest' +import { isSyntheticEmail } from '../is-synthetic-email' + +describe('isSyntheticEmail', () => { + it('returns true for vk.local domain', () => { + expect(isSyntheticEmail('vk_12345@vk.local')).toBe(true) + }) + + it('returns false for real email', () => { + expect(isSyntheticEmail('user@gmail.com')).toBe(false) + }) + + it('returns false for yandex email', () => { + expect(isSyntheticEmail('user@yandex.ru')).toBe(false) + }) + + it('returns false for empty string', () => { + expect(isSyntheticEmail('')).toBe(false) + }) + + it('returns false for email with vk.local as part of username', () => { + expect(isSyntheticEmail('vk.local@gmail.com')).toBe(false) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd client && npx vitest run src/shared/lib/__tests__/is-synthetic-email.test.ts +``` + +Expected: FAIL with "Cannot find module '../is-synthetic-email'" + +- [ ] **Step 3: Write the implementation** + +```ts +// client/src/shared/lib/is-synthetic-email.ts +const SYNTHETIC_DOMAINS = ['vk.local'] + +export function isSyntheticEmail(email: string): boolean { + return SYNTHETIC_DOMAINS.some((domain) => email.endsWith(`@${domain}`)) +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +```bash +cd client && npx vitest run src/shared/lib/__tests__/is-synthetic-email.test.ts +``` + +Expected: All 5 tests PASS + +- [ ] **Step 5: Commit** + +```bash +cd /mnt/d/my_projects/shop +git add client/src/shared/lib/is-synthetic-email.ts client/src/shared/lib/__tests__/is-synthetic-email.test.ts +git commit -m "feat: add isSyntheticEmail utility for detecting synthetic OAuth emails" +``` + +--- + +### Task 2: Add warning to AuthMethodsSection + +**Files:** +- Modify: `client/src/pages/me/ui/sections/AuthMethodsSection.tsx` + +- [ ] **Step 1: Add import and Alert below email display** + +Add import at line 22 (after existing imports): +```ts +import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email' +``` + +Add Alert block after line 118 (`{user.email}`), before line 120 (`{!verificationUrl && (`): + +```tsx +{isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о заказах. + +)} +``` + +The full relevant section should look like: +```tsx + + {user.email} + + +{isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о заказах. + +)} + +{!verificationUrl && ( +``` + +- [ ] **Step 2: Verify lint passes** + +```bash +cd client && npm run lint +``` + +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +cd /mnt/d/my_projects/shop +git add client/src/pages/me/ui/sections/AuthMethodsSection.tsx +git commit -m "feat: show synthetic email warning in AuthMethodsSection" +``` + +--- + +### Task 3: Add warning to NotificationsPage + +**Files:** +- Modify: `client/src/pages/me/ui/sections/NotificationsPage.tsx` + +- [ ] **Step 1: Add import, user access, and Alert at page top** + +Add import at line 13 (after existing imports): +```ts +import { $user } from '@/shared/model/auth' +import { useUnit } from 'effector-react' +import { isSyntheticEmail } from '@/shared/lib/is-synthetic-email' +``` + +Add user hook inside the component, after `const [error, setError] = useState(null)` (line 28): +```ts +const user = useUnit($user) +``` + +Add Alert block after line 64 (after the subtitle Typography), before line 66 (`{error && (`): + +```tsx +{user && isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля. + +)} +``` + +The full relevant section should look like: +```tsx + + Настройте, какие уведомления вы хотите получать на почту. + + +{user && isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля. + +)} + +{error && ( +``` + +- [ ] **Step 2: Verify lint passes** + +```bash +cd client && npm run lint +``` + +Expected: No errors + +- [ ] **Step 3: Commit** + +```bash +cd /mnt/d/my_projects/shop +git add client/src/pages/me/ui/sections/NotificationsPage.tsx +git commit -m "feat: show synthetic email warning in NotificationsPage" +``` + +--- + +### Task 4: Final verification + +- [ ] **Step 1: Run full client test suite** + +```bash +cd client && npm test +``` + +Expected: All tests PASS + +- [ ] **Step 2: Run lint and format check** + +```bash +cd client && npm run lint && npm run format:check +``` + +Expected: No errors + +- [ ] **Step 3: Run TypeScript check** + +```bash +cd client && npx tsc --noEmit +``` + +Expected: No errors diff --git a/docs/superpowers/specs/2026-05-23-ip-gate-access-control-design.md b/docs/superpowers/specs/2026-05-23-ip-gate-access-control-design.md new file mode 100644 index 0000000..3251152 --- /dev/null +++ b/docs/superpowers/specs/2026-05-23-ip-gate-access-control-design.md @@ -0,0 +1,93 @@ +# IP-gate: ограничение доступа на время разработки + +## Задача + +Сайт доступен на реальном домене (через VPS + NPM + Netbird), но находится в активной разработке/тестировании. Нужно ограничить доступ, не мешая разработке и полному тестированию функционала (включая OAuth и webhook-и). + +## Решение + +IP-whitelist на уровне Fastify (`onRequest` хук). Только запросы с разрешённых IP проходят. Внешние webhook-и и OAuth callback-и исключены из проверки. + +## Конфигурация + +### `.env` + +```env +SITE_ACCESS_IPS=1.2.3.4,5.6.7.8 +``` + +- Не задана или пуста — защита **отключена** +- IP через запятую, пробелы игнорируются (трим) +- `request.ip` возвращает реальный IP клиента благодаря `trustProxy: true` + +## Архитектура + +### Новый плагин: `server/src/plugins/ip-gate.js` + +Регистрируется в `server/src/index.js` **перед** всеми маршрутами. + +```js +fastify.register(async function ipGate(fastify, opts) { + fastify.addHook('onRequest', async (request, reply) => { + // защита выключена + // путь в исключениях + // ip в списке + // иначе 403 + }) +}) +``` + +### Логика `onRequest` + +1. `SITE_ACCESS_IPS` пуст → `return` (пропустить) +2. Путь запроса в списке исключений → `return` +3. `request.ip` есть в `SITE_ACCESS_IPS` → `return` +4. Иначе → `reply.code(403).type('text/html').send(htmlPage)` + +### Исключения + +Маршруты, которые должны работать всегда (их вызывают внешние сервисы, а не браузер тестировщика): + +| Путь | Причина | +|---|---| +| `/api/auth/oauth/vk/callback` | VK OAuth callback | +| `/api/auth/oauth/yandex/callback` | Yandex OAuth callback | +| `/api/webhooks/yookassa` | YooKassa payment webhook | +| `/api/admin/notifications/telegram/webhook` | Telegram webhook | + +Статика (загружается браузером тестировщика, поэтому тоже проверяется): +- `/uploads/*` и `/uploads-resized/*` — **не** исключаем, блокируются вместе со всем остальным + +### 403-страница + +HTML-страница с информацией о магазине и статусе разработки: + +- Название: «Любимый Креатив» +- Подзаголовок: «Изделия ручной работы: вещи с характером и вниманием к деталям» +- Сообщение: «Сайт находится в разработке и скоро будет доступен» +- Показывает IP посетителя (чтобы можно было сообщить для добавления в whitelist) +- Минимальная стилизация (чистый HTML + inline CSS, без внешних ресурсов) + +## Точки регистрации + +В `server/src/index.js`: + +```js +// после trustProxy, перед маршрутами +await fastify.register(require('./plugins/ip-gate')) +``` + +## Тестирование + +- **Юнит-тесты**: `server/src/plugins/__tests__/ip-gate.test.js` + - IP в списке → запрос проходит + - IP не в списке → 403 + - Путь-исключение → проходит с любым IP + - `SITE_ACCESS_IPS` не задан → защита выключена + - Пробелы в списке IP → корректная работа + +## Включение/выключение + +- **Включить**: задать `SITE_ACCESS_IPS` в `.env` +- **Выключить**: удалить `SITE_ACCESS_IPS` или оставить пустым +- Перезапуск сервера не требуется если используется `node --watch` diff --git a/docs/superpowers/specs/2026-05-24-info-page-redesign-design.md b/docs/superpowers/specs/2026-05-24-info-page-redesign-design.md new file mode 100644 index 0000000..db567cf --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-info-page-redesign-design.md @@ -0,0 +1,91 @@ +# Редизайн страницы /info — Спецификация + +**Дата:** 2026-05-24 +**Страница:** `/info` (Информация для покупателей) +**Подход:** Минимальные правки — обновить контент существующих секций, не меняя структуру страницы. + +--- + +## 1. Секция «Как оформить заказ» (HowToOrderSection) + +MUI Stepper с ветвлением на шаге 3. Два сценария: доставка и самовывоз. + +### Общие шаги (1–2): + +| Шаг | Название | Описание | +|-----|----------|----------| +| 1 | Выберите товары | Найдите нужное в каталоге, добавьте в корзину | +| 2 | Проверьте корзину | Убедитесь, что состав и количество верны | + +### Ветвление на шаге 3: + +#### Доставка: + +| Шаг | Название | Описание | +|-----|----------|----------| +| 3 | Укажите адрес доставки и получателя | Имя, телефон, email, адрес (или добавьте новый в личном кабинете) | +| 4 | Выберите способ доставки | Почта России, Озон ПВЗ, Яндекс ПВЗ, 5Post, WB ПВЗ. Дождитесь расчёта стоимости доставки — мастер скорректирует цену | +| 5 | Оплатите заказ | Онлайн через ЮKassa (карты, СБП) | +| 6 | Получите заказ | Отслеживайте по трек-номеру | + +#### Самовывоз: + +| Шаг | Название | Описание | +|-----|----------|----------| +| 3 | Подтвердите заказ | Выберите оплату: онлайн через ЮKassa или при получении | +| 4 | Согласуйте время получения | Мастер свяжется с вами, чтобы договориться о времени | +| 5 | Получите заказ | Адрес: ул. Мира, 34 (ссылка на /about — карта) | + +### Финальный шаг (после получения): +После получения заказа можно оставить отзыв. + +--- + +## 2. Секция «Доставка» (DeliverySection) + +Две карточки: + +### Самовывоз +- Бесплатно +- Адрес: 618909, Россия, Пермский край, Лысьвенский муниципальный округ, Лысьва, улица Мира, 34 +- Ссылка на /about (карта) +- Перед визитом согласуем время — чтобы заказ точно был готов к выдаче + +### Доставка по России +Перечислить все 5 служб: +- Почта России +- Озон доставка (пункт выдачи) +- Яндекс доставка (пункт выдачи) +- 5Post (пункт выдачи) +- WB доставка (пункт выдачи) +- Каждому заказу присваивается трек-номер для отслеживания +- Стоимость рассчитывается по тарифу перевозчика, мастер скорректирует цену после оформления + +--- + +## 3. Секция «Оплата» (PaymentSection) + +Два способа: + +| Способ | Описание | +|--------|----------| +| Онлайн-оплата через ЮKassa (карты, СБП) | Оплата после подтверждения заказа мастером. Перенаправление на защищённую платёжную страницу ЮKassa | +| Оплата при получении | Наличными или картой при самовывозе | + +Примечание: оплата происходит после подтверждения заказа мастером. + +--- + +## 4. Секция «Возврат» (ReturnsSection) + +Без изменений — текущий контент остаётся. + +--- + +## Файлы для изменения + +| Файл | Что менять | +|------|-----------| +| `client/src/pages/info/ui/sections/HowToOrderSection.tsx` | Переписать stepper с ветвлением | +| `client/src/pages/info/ui/sections/DeliverySection.tsx` | Перечислить carriers, добавить ссылку на /about | +| `client/src/pages/info/ui/sections/PaymentSection.tsx` | Заменить «Банковская карта онлайн» на «ЮKassa (карты, СБП)» | diff --git a/docs/superpowers/specs/2026-05-24-synthetic-email-warning-design.md b/docs/superpowers/specs/2026-05-24-synthetic-email-warning-design.md new file mode 100644 index 0000000..cb57236 --- /dev/null +++ b/docs/superpowers/specs/2026-05-24-synthetic-email-warning-design.md @@ -0,0 +1,74 @@ +# Synthetic Email Warning Design + +**Date:** 2026-05-24 +**Status:** Draft + +## Problem + +When users authenticate via VK without providing a real email, the server generates a synthetic email (`vk_@vk.local`). These users cannot receive email notifications, but there is no indication of this in the personal account (LK). + +## Solution + +Display an informational MUI Alert in two places when the user's email matches a known synthetic domain: + +1. **AuthMethodsSection** — where the current email is displayed +2. **NotificationsPage** — where notification settings are managed + +## Architecture + +### Email Utility + +New file: `client/src/shared/lib/email-utils.ts` + +```ts +const SYNTHETIC_DOMAINS = ['vk.local'] + +export function isSyntheticEmail(email: string): boolean { + return SYNTHETIC_DOMAINS.some(domain => email.endsWith(`@${domain}`)) +} +``` + +This is a pure client-side check. The `@vk.local` domain is internal-only — real users cannot register with it. + +### AuthMethodsSection + +Add MUI Alert below the email display: + +```tsx +{isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Без указания реальной почты вы не сможете получать уведомления о заказах. + +)} +``` + +### NotificationsPage + +Add MUI Alert at the top of the page: + +```tsx +{isSyntheticEmail(user.email) && ( + + Ваша почта сгенерирована автоматически. Для получения уведомлений укажите реальную почту в настройках профиля. + +)} +``` + +## Data Flow + +``` +user.email (from auth model) + → isSyntheticEmail() check + → true → render Alert + → false → no change +``` + +## Error Handling + +- No errors possible — pure string comparison +- If `user.email` is undefined/null, the check returns `false` (safe) + +## Testing + +- Unit test for `isSyntheticEmail()` with real emails, synthetic emails, edge cases +- No component tests needed — trivial conditional rendering diff --git a/server/.env.example b/server/.env.example index 78a9ec4..a364b23 100644 --- a/server/.env.example +++ b/server/.env.example @@ -17,6 +17,9 @@ JWT_SECRET=замените-на-секрет-jwt # Разрешённый Origin фронта (через запятую при нескольких) # CORS_ORIGIN=http://127.0.0.1:5173 +# Ограничение доступа по IP на время разработки (через запятую). Не задано — защита отключена. +# SITE_ACCESS_IPS=1.2.3.4,5.6.7.8 + # Публичные URL для OAuth redirect (локально обычно так): SERVER_PUBLIC_URL=http://127.0.0.1:3333 CLIENT_PUBLIC_URL=http://127.0.0.1:5173 diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index fcc9ce1..d8c0596 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/index.js b/server/src/index.js index ce32fff..cba933c 100644 --- a/server/src/index.js +++ b/server/src/index.js @@ -18,6 +18,7 @@ import { createNotificationQueue } from './lib/notifications/queue.js' import { prisma } from './lib/prisma.js' import { getMaxUploadBodyBytes, getProductImageMaxFileBytes } from './lib/upload-limits.js' import { registerAuth } from './plugins/auth.js' +import { registerIpGate } from './plugins/ip-gate.js' import { registerApiRoutes } from './routes/api.js' import { registerOAuthSocialRoutes } from './routes/oauth-social.js' import { registerUploadsResized } from './routes/uploads-resized.js' @@ -89,6 +90,7 @@ const notificationQueue = createNotificationQueue() fastify.decorate('eventBus', eventBus) fastify.decorate('notificationQueue', notificationQueue) +await registerIpGate(fastify) registerAuth(fastify) await registerUserAddressRoutes(fastify) await registerUserCartRoutes(fastify) diff --git a/server/src/lib/notifications/templates/telegram-templates.js b/server/src/lib/notifications/templates/telegram-templates.js index 770efb5..444c7f6 100644 --- a/server/src/lib/notifications/templates/telegram-templates.js +++ b/server/src/lib/notifications/templates/telegram-templates.js @@ -10,10 +10,10 @@ export function renderOrderStatusChangedTg({ orderId, oldStatus, newStatus }) { DRAFT: 'Черновик', PENDING_PAYMENT: 'Ожидает оплаты', PAID: 'Оплачен', - IN_PROGRESS: 'В работе', + IN_PROGRESS: 'Подготовка к отправке', READY_FOR_PICKUP: 'Готов к выдаче', SHIPPED: 'Отправлен', - DONE: 'Выполнен', + DONE: 'Завершён', CANCELLED: 'Отменён', } return `🔄 Заказ #${orderId.slice(0, 8)}\n${labels[oldStatus] || oldStatus} → ${labels[newStatus] || newStatus}` diff --git a/server/src/plugins/__tests__/ip-gate.test.js b/server/src/plugins/__tests__/ip-gate.test.js new file mode 100644 index 0000000..251842a --- /dev/null +++ b/server/src/plugins/__tests__/ip-gate.test.js @@ -0,0 +1,175 @@ +import Fastify from 'fastify' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { build403Html, registerIpGate } from '../ip-gate.js' + +function buildApp() { + const app = Fastify({ logger: false, trustProxy: true }) + app.get('/test', async () => ({ ok: true })) + app.get('/api/webhooks/yookassa', async () => ({ ok: true })) + app.get('/api/auth/oauth/vk/callback', async () => ({ ok: true })) + app.get('/api/auth/oauth/yandex/callback', async () => ({ ok: true })) + app.get('/api/admin/notifications/telegram/webhook', async () => ({ ok: true })) + return app +} + +describe('registerIpGate', () => { + let app + const originalIps = process.env.SITE_ACCESS_IPS + + beforeEach(async () => { + app = buildApp() + await registerIpGate(app) + await app.ready() + }) + + afterEach(async () => { + await app.close() + if (originalIps === undefined) { + delete process.env.SITE_ACCESS_IPS + } else { + process.env.SITE_ACCESS_IPS = originalIps + } + }) + + it('пропускает запрос если SITE_ACCESS_IPS не задан', async () => { + delete process.env.SITE_ACCESS_IPS + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' }) + expect(res.statusCode).toBe(200) + expect(res.json()).toEqual({ ok: true }) + }) + + it('пропускает запрос с разрешённого IP', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4,5.6.7.8' + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '1.2.3.4' }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает запрос с IPv6-mapped разрешённого IP', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '::ffff:1.2.3.4' }) + expect(res.statusCode).toBe(200) + }) + + it('блокирует запрос с неразрешённого IP (403)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ method: 'GET', url: '/test', remoteAddress: '9.9.9.9' }) + expect(res.statusCode).toBe(403) + expect(res.headers['content-type']).toMatch(/text\/html/) + expect(res.body).toContain('Любимый Креатив') + expect(res.body).toContain('9.9.9.9') + }) + + it('build403Html показывает "не определён" когда IP не передан', () => { + const html = build403Html() + expect(html).toContain('не определён') + expect(html).toContain('Любимый Креатив') + }) + + it('build403Html показывает переданный IP', () => { + const html = build403Html('9.9.9.9') + expect(html).toContain('9.9.9.9') + expect(html).not.toContain('не определён') + }) + + it('build403Html с пустой строкой показывает "не определён"', () => { + const html = build403Html('') + expect(html).toContain('не определён') + }) + + it('403-страница показывает IP по умолчанию (127.0.0.1) когда remoteAddress не указан', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ method: 'GET', url: '/test' }) + expect(res.statusCode).toBe(403) + expect(res.body).toContain('127.0.0.1') + }) + + it('пропускает исключённые пути с любым IP (webhook yookassa)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/yookassa', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (vk callback)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/auth/oauth/vk/callback', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (yandex callback)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/auth/oauth/yandex/callback', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает исключённые пути с любым IP (telegram webhook)', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/admin/notifications/telegram/webhook', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('корректно тримит пробелы в списке IP', async () => { + process.env.SITE_ACCESS_IPS = ' 1.2.3.4 , 5.6.7.8 ' + const res = await app.inject({ + method: 'GET', + url: '/test', + remoteAddress: '5.6.7.8', + }) + expect(res.statusCode).toBe(200) + }) + + it('нормализует IPv6-mapped адреса в whitelist', async () => { + process.env.SITE_ACCESS_IPS = '::ffff:1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/test', + remoteAddress: '1.2.3.4', + }) + expect(res.statusCode).toBe(200) + }) + + it('пропускает если после трима список IP пуст', async () => { + process.env.SITE_ACCESS_IPS = ' , , ' + const res = await app.inject({ + method: 'GET', + url: '/test', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) + + it('путь с query-параметрами проверяется корректно', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/test?foo=bar', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(403) + }) + + it('исключённый путь с query-параметрами тоже пропускается', async () => { + process.env.SITE_ACCESS_IPS = '1.2.3.4' + const res = await app.inject({ + method: 'GET', + url: '/api/webhooks/yookassa?foo=bar', + remoteAddress: '9.9.9.9', + }) + expect(res.statusCode).toBe(200) + }) +}) diff --git a/server/src/plugins/ip-gate.js b/server/src/plugins/ip-gate.js new file mode 100644 index 0000000..3da7dc0 --- /dev/null +++ b/server/src/plugins/ip-gate.js @@ -0,0 +1,102 @@ +const EXCLUDED_PATHS = [ + '/api/auth/oauth/vk/callback', + '/api/auth/oauth/yandex/callback', + '/api/webhooks/yookassa', + '/api/admin/notifications/telegram/webhook', +] + +function normalizeIp(ip) { + if (ip && ip.startsWith('::ffff:')) { + return ip.slice(7) + } + return ip +} + +export function build403Html(ip) { + const safeIp = ip || 'не определён' + return ` + + + + +Любимый Креатив + + + +
+

Любимый Креатив

+

Изделия ручной работы: вещи с характером и вниманием к деталям

+

Сайт находится в разработке и скоро будет доступен

+

Ваш IP: ${safeIp}

+
+ +` +} + +export async function registerIpGate(fastify) { + fastify.addHook('onRequest', async (request, reply) => { + const allowed = process.env.SITE_ACCESS_IPS + if (!allowed) return + + const allowedIps = allowed + .split(',') + .map((s) => normalizeIp(s.trim())) + .filter(Boolean) + + if (allowedIps.length === 0) return + + const urlPath = request.url.split('?')[0] + + if (EXCLUDED_PATHS.includes(urlPath)) return + + if (allowedIps.includes(normalizeIp(request.ip))) return + + return reply.code(403).type('text/html').send(build403Html(request.ip)) + }) +} diff --git a/server/src/routes/auth.js b/server/src/routes/auth.js index 719e90e..54a36c3 100644 --- a/server/src/routes/auth.js +++ b/server/src/routes/auth.js @@ -204,4 +204,18 @@ export async function registerAuthRoutes(fastify) { }) return { user: mapUserForClient(updated) } }) + + fastify.delete('/api/me', { preHandler: [fastify.authenticate] }, async (request, reply) => { + const userId = request.user.sub + + const ACTIVE_STATUSES = ['DRAFT', 'PENDING_PAYMENT', 'PAID', 'IN_PROGRESS', 'SHIPPED', 'READY_FOR_PICKUP'] + + const activeOrders = await prisma.order.findMany({ + where: { userId, status: { in: ACTIVE_STATUSES } }, + select: { id: true }, + }) + + await prisma.user.delete({ where: { id: userId } }) + return { ok: true, activeOrderIds: activeOrders.map((o) => o.id) } + }) } diff --git a/shared/constants/order-status.js b/shared/constants/order-status.js index 7795eb7..6d9a54e 100644 --- a/shared/constants/order-status.js +++ b/shared/constants/order-status.js @@ -1,13 +1,13 @@ export const ORDER_STATUSES = Object.freeze([ - 'DRAFT', - 'PENDING_PAYMENT', - 'PAID', - 'IN_PROGRESS', - 'SHIPPED', - 'READY_FOR_PICKUP', - 'DONE', - 'CANCELLED', -]) + "DRAFT", + "PENDING_PAYMENT", + "PAID", + "IN_PROGRESS", + "SHIPPED", + "READY_FOR_PICKUP", + "DONE", + "CANCELLED", +]); /** * Допустимые переходы статусов, доступные админу. @@ -15,24 +15,24 @@ export const ORDER_STATUSES = Object.freeze([ * Для IN_PROGRESS: объект с ключами по deliveryType. */ export const ADMIN_ORDER_TRANSITIONS = Object.freeze({ - DRAFT: ['PENDING_PAYMENT', 'CANCELLED'], - PENDING_PAYMENT: ['PAID', 'CANCELLED'], - PAID: ['IN_PROGRESS', 'CANCELLED'], + DRAFT: ["PENDING_PAYMENT", "CANCELLED"], + PENDING_PAYMENT: ["PAID", "CANCELLED"], + PAID: ["IN_PROGRESS", "CANCELLED"], IN_PROGRESS: Object.freeze({ - delivery: ['SHIPPED', 'CANCELLED'], - pickup: ['READY_FOR_PICKUP', 'CANCELLED'], + delivery: ["SHIPPED", "CANCELLED"], + pickup: ["READY_FOR_PICKUP", "CANCELLED"], }), -}) +}); export function getNextAdminStatuses(from, deliveryType) { - const transition = ADMIN_ORDER_TRANSITIONS[from] - if (!transition) return [] - if (Array.isArray(transition)) return [...transition] - return transition[deliveryType] ? [...transition[deliveryType]] : [] + const transition = ADMIN_ORDER_TRANSITIONS[from]; + if (!transition) return []; + if (Array.isArray(transition)) return [...transition]; + return transition[deliveryType] ? [...transition[deliveryType]] : []; } export function canTransitionAdminOrderStatus(order, next) { - const from = order.status - if (from === next) return true - return getNextAdminStatuses(from, order.deliveryType).includes(next) + const from = order.status; + if (from === next) return true; + return getNextAdminStatuses(from, order.deliveryType).includes(next); } diff --git a/Требования Роскомнадзора к сайтам 2026_ чек-лист для бизнеса copy.md b/Требования Роскомнадзора к сайтам 2026_ чек-лист для бизнеса copy.md new file mode 100644 index 0000000..78f16b4 --- /dev/null +++ b/Требования Роскомнадзора к сайтам 2026_ чек-лист для бизнеса copy.md @@ -0,0 +1,157 @@ +--- +title: "Требования Роскомнадзора к сайтам 2026: чек-лист для бизнеса" +source: "https://www.klerk.ru/blogs/roskom24/650389/#chapter--4-cookie-uvedomlenie-i-politika" +author: + - "[[Закон и бизнес | Онлайн услуги 24]]" +published: 2025-06-10 +created: 2026-05-23 +description: "С 30 мая 2025 года в России вступили в силу изменения в законодательство о персональных данных. Для бизнеса — это не просто очередная «формальность», а вопрос безопасности и выживания." +tags: + - "clippings" +--- +Роскомнадзор усилил контроль за сайтами, включая автоматическую проверку с использованием ИИ. Теперь даже незначительные, по мнению бизнеса, нарушения могут привести **к штрафам до 18 миллионов рублей или блокировке сайта**. + +Если у вас есть сайт, вы — оператор персональных данных. А значит, обязаны соблюдать [ФЗ-152 «О персональных данных»](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-152-fz-o-personalnyh-dannyh/). Даже если вы ИП, самозанятый или оказываете услуги онлайн. Ниже — актуальный чек-лист на 2026 год, который поможет не попасть под штраф и не дать конкурентам «закопать» ваш бизнес через жалобу в Роскомнадзор. + +## ✅ 1. Политика обработки персональных данных на сайте + +### Что это + +Официальный документ, размещенный на сайте (*обычно в подвале*), который описывает: + +- какие данные вы собираете; +- как их обрабатываете; +- кому передаете и на каких основаниях. + +### Требования 2026 + +- Обязательно актуальная редакция. +- Полный перечень обрабатываемых данных (*имя, email, телефон, cookies и т.д.*). +- Указание целей и оснований обработки. +- Контактные данные оператора. +- Ссылки на формы согласия и порядок отзыва. + +📌 **Ошибка №1** — скачать шаблон и забыть про него. Политика должна соответствовать именно вашему бизнесу и интеграциям на сайте. + +## ✅ 2. Согласие на обработку персональных данных + +### Где должно быть + +- во всех формах на сайте: заявки, обратная связь, регистрация, квизы, покупка, консультации; +- при подписке на рассылку; +- в онлайн-чате, если сохраняются данные. + +### Требования 2026 + +- Согласие должно быть **добровольным, конкретным, информированным и однозначным**. +- Включает: ФИО, перечень данных, цель обработки, срок хранения, право отзыва. +- Отдельное согласие на передачу данных третьим лицам (*например, CRM-системам*). +- Техническая реализация: **отдельный чекбокс с обязательной активацией**, а не просто фраза «нажимая кнопку, вы соглашаетесь». + +📌 **Ошибка №2** — отсутствие чекбокса или невидимый текст, отсутствие Log файлов позволяющих доказать получение согласия на обработку персональных данных от пользователя сайта. + +## ✅ 3. Уведомление Роскомнадзора о начале обработки персональных данных + +### Кто обязан + +Любой, кто собирает ПДн через сайт — даже ИП и самозанятые. + +### Требования 2026 + +- До начала обработки нужно подать уведомление через портал Роскомнадзора. +- Указать все сведения: цели, способы обработки, меры безопасности, перечень используемых информационных систем. +- Отдельно — факт трансграничной передачи, если используете иностранные сервисы. + +📌 **Ошибка №3** — не уведомили Роскомнадзор, потому что «сайт только визитка». Даже форма обратной связи — уже обработка ПДн. + +## ✅ 5. Юридическая информация в подвале сайта + +Что должно быть ([*ч. 2 ст. 10 ФЗ № 149-ФЗ «Об информации, информационных технологиях и о защите информации»*](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-149-fz-ob-informacii-informacionnyh-tehnologiah-i-o-zasite-informacii/stata-10-rasprostranenie-informacii-ili-predostavlenie-informacii/#p_64595)): + +- Полное наименование владельца сайта. +- Адрес места нахождения. +- Актуальные контактные данные. + +### Почему это важно + +Размещение недостоверных сведений может привести к привлечению к административной ответственности по [статье 14.4 КоАП](https://www.klerk.ru/cdoc/view/kodeks-ob-administrativnyh-pravonaruseniah-koap-rf/stata-144-prodaza-tovarov-vypolnenie-rabot-libo-okazanie-naseleniu-uslug-nenadlezasego-kacestva-ili-s-naruseniem-ustanovlennyh-zakonodatelstvom-rossijskoj-federacii-trebovanij/) (*нарушение законодательства о рекламе*), а также к гражданско-правовой ответственности за причиненный ущерб. + +📌 **Ошибка №5** — указание только бренда или торговой марки без юр. лица. + +## ✅ 6. Российский хостинг и запрет трансграничной передачи + +### Суть + +Использование **иностранных серверов и облаков** приравнивается к трансграничной передаче ПДн. + +### Требования + +- Хостинг сайта — только на серверах, физически размещенных в России. +- Подтверждение от хостинг-провайдера. +- Запрет на хранение ПДн в Google Drive, Notion, Dropbox и т.д. +- Meta 1 Pixel, Google Analytics, сайты размещенные на иностранных хостингах — повод для штрафа, если не отражены в документах. + +📌 **Ошибка №7** — сайт на Tilda или REG.RU, но физически размещен в Европе. Это нарушение. + +Материалы по теме[Топ вопросов и ответов про работу с персональными данными в 2025 году](https://www.klerk.ru/buh/articles/660617/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389) + +[ + +Главные изменения в законе о персональных данных с 1 сентября 2025 + +](https://www.klerk.ru/buh/articles/660820/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389)[ + +152-ФЗ о персональных данных: требования закона для бизнеса в 2026 году + +](https://www.klerk.ru/blogs/roskom24/674017/?utm_source=klerk&utm_medium=article&utm_campaign=recommendation&utm_content=blocklinks&utm_term=650389) + +## ✅ 8. Проверка Роскомнадзора: автоматизированная, быстрая, без предупреждения + +### Как работает + +- Искусственный интеллект сканирует сайт 24/7. +- Проверяет не только текст, но и **код сайта, скрытые скрипты, cookie, формы**. +- Не требует предварительного уведомления. +- Фиксация нарушений → **предписание или моментальный штраф**. + +📌 **Важно**: проверить сайт глазами — недостаточно. Нарушения могут быть «внутри» — в интеграциях, скриптах и DOM-структуре. + +## ⚠️ Жалобы конкурентов — новый инструмент давления + +- Предприниматели уже используют жалобы в Роскомнадзор как способ атаковать конкурентов. +- Роскомнадзор обязан реагировать на любую жалобу, даже анонимную. +- Уже есть случаи блокировки сайтов и штрафов по жалобе «доброжелателей». + +📌 **Вывод** — не дать конкурентам повода. Даже мелкая недоработка — риск для бизнеса. + +## 🛠 Что делать бизнесу уже сейчас + +### Шаги + +1. Провести аудит сайта (*юридический + технический*). +2. Проверить наличие и актуальность всех обязательных документов. +3. Зарегистрироваться в Роскомнадзоре как оператор ПДн. +4. Обновить Политику ПДн и Cookie-согласие. +5. Убедиться, что сайт размещен на российских серверах. +6. Устранить трансграничные риски (*зарубежные сервисы, скрипты*). +7. Назначить ответственного за ПДн внутри компании. + +## 💼 Как мы можем помочь + +Сервис [«Роском 24»](https://roskom24.ru/?utm_source=klerkru-blog&utm_medium=trebovaniya_roskomnadzora_k_saitam) — это команда юристов и технических специалистов, которые: + +- Проводят аудит сайта на соответствие [ФЗ-152](https://www.klerk.ru/cdoc/view/federalnyj-zakon-ot-27072006-no-152-fz-o-personalnyh-dannyh/). +- Подготавливают полный комплект документов. +- Помогают зарегистрироваться в Роскомнадзоре. +- Настраивают cookie-уведомления и формы согласия. +- Защищают бизнес от штрафов и блокировок. + +## 📌 Итог + +В 2026 году **невозможно заниматься бизнесом онлайн и игнорировать закон о персональных данных**. Сайт — это уже зона юридической ответственности. И чем раньше вы проведете аудит и приведете все в порядок, тем больше шансов избежать штрафов, блокировок и атак конкурентов. + +👉 Проверьте свой сайт прямо сейчас. Или [доверьтесь профессионалам](https://roskom24.ru/?utm_source=klerkru-blog&utm_medium=trebovaniya_roskomnadzora_k_saitam). + +*Реклама: ООО «ОНЛАЙН УСЛУГИ 24», ИНН 7751227590, erid: 2W5zFJLb1J8* + +1. Деятельность компании Meta Platforms Inc. (Facebook и Instagram) на территории РФ запрещена \ No newline at end of file