From 31efbf3b6948abb5ed1a5d6dc58aa7b8cc99d372 Mon Sep 17 00:00:00 2001 From: mi Date: Thu, 23 Jul 2026 14:29:16 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B0=D0=BB=D0=B8=D0=B7=D0=BE?= =?UTF-8?q?=D0=B2=D0=B0=D0=BD=20=D0=B8=D0=BD=D1=82=D0=B5=D1=80=D1=84=D0=B5?= =?UTF-8?q?=D0=B9=D1=81=20=D1=81=D0=BE=D0=B3=D0=BB=D0=B0=D1=81=D0=B8=D0=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- architectory/arch-01-system-architecture.md | 2 +- architectory/arch-04-settings-and-content.md | 4 +- backlog.md | 4 +- .../alembic/versions/0001_initial_han_app.py | 10 + .../0006_consent_privacy_policy_url.py | 34 +++ .../0007_consent_marketing_document_url.py | 34 +++ codebase/backend/api-backend/app/main.py | 22 +- codebase/backend/api-backend/app/services.py | 2 + .../app-settings.production-like.yaml | 2 + .../backend/frontend-test-site/app/index.tsx | 65 ++--- .../src/components/ConsentModal.tsx | 228 ++++++++++++++++++ .../backend/frontend-test-site/src/types.ts | 1 + .../tests/e2e/smoke.spec.ts | 15 +- figma/Main page specification.zip | Bin 91495 -> 95202 bytes figma/src/app/components/ChatInput.tsx | 26 +- figma/src/app/components/Notifications.tsx | 99 ++++++-- figma/src/app/data/companyMessages.ts | 10 +- figma/src/app/data/session.ts | 20 ++ figma/src/app/pages/AuthConsent.tsx | 162 +++++++++++++ figma/src/app/pages/AuthLoading.tsx | 3 +- figma/src/app/pages/AuthPhone.tsx | 9 - figma/src/app/pages/History.tsx | 4 +- figma/src/app/routes.tsx | 2 + modules/module-01-api-backend.md | 9 +- modules/module-02-frontend-test-site.md | 2 +- ops-monitoring/send_sms.md | 152 +++++++++++- releases/#0 deploy-steps.md | 6 + 27 files changed, 817 insertions(+), 110 deletions(-) create mode 100644 codebase/backend/api-backend/alembic/versions/0006_consent_privacy_policy_url.py create mode 100644 codebase/backend/api-backend/alembic/versions/0007_consent_marketing_document_url.py create mode 100644 codebase/backend/frontend-test-site/src/components/ConsentModal.tsx create mode 100644 figma/src/app/data/session.ts create mode 100644 figma/src/app/pages/AuthConsent.tsx diff --git a/architectory/arch-01-system-architecture.md b/architectory/arch-01-system-architecture.md index dd7147c..921dd2c 100644 --- a/architectory/arch-01-system-architecture.md +++ b/architectory/arch-01-system-architecture.md @@ -141,7 +141,7 @@ flowchart LR - стартовый экран с приветствием, популярными вопросами, полем ввода, историей и профилем; - гостевой режим до первого сообщения; -- показ pop-up с обязательными согласиями на обработку персональных данных и пользовательское соглашение, а также необязательным согласием на рекламные коммуникации; +- показ pop-up с обязательными согласиями на обработку персональных данных (со ссылками на согласие и политику ПД) и пользовательское соглашение, а также необязательным согласием на рекламные коммуникации; - сбор данных устройства для передачи в backend; - **управление аналитической UX-сессией** на клиенте: после получения JWT — `session_start`, хранение `ux_session_id` и `last_activity_at` **только в памяти**, заголовок `X-Ux-Session-Id` в JWT-запросах; - хранение access token и refresh token в безопасном хранилище после авторизации; diff --git a/architectory/arch-04-settings-and-content.md b/architectory/arch-04-settings-and-content.md index 84eb4c6..f15b706 100644 --- a/architectory/arch-04-settings-and-content.md +++ b/architectory/arch-04-settings-and-content.md @@ -88,7 +88,7 @@ Managed PostgreSQL **поднимается до** развёртывания п | Auth | `auth.phone.enabled`, `auth.password.enabled` | | OTP (продукт; потребитель — Keycloak SPI через settings bridge api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, `otp.phone.max_verify_attempts`, `otp.phone.code_length`, `otp.phone.ttl_seconds`, `otp.phone.sms_order_timeout_ms` | | Оператор | `operator.call.phone` | -| Consent | `consent.personal_data.*`, `consent.user_agreement.*`, `consent.marketing.*` | +| Consent | `consent.personal_data.*`, `consent.privacy_policy.document_url`, `consent.user_agreement.*`, `consent.marketing.*` | | Файлы чата | `chat.attachments.*` | | Rate limits (app) | `rate_limit.message_send.*`, `rate_limit.download_url.*`, `rate_limit.public_endpoints.*`, `rate_limit.login.*` | | UX | `ux.session.idle_timeout_minutes` | @@ -112,10 +112,12 @@ operator.call.phone=+74999591007 consent.personal_data.required=true consent.personal_data.document_url=https://www.han0107.ru/privacy/persdata-agree-mobile consent.personal_data.version=2026-06-10 +consent.privacy_policy.document_url=https://www.han0107.ru/privacy consent.user_agreement.required=true consent.user_agreement.document_url=https://www.han0107.ru/user-agreement consent.user_agreement.version=2026-06-10 consent.marketing.required=false +consent.marketing.document_url=https://www.han0107.ru/privacy/ads-agree consent.marketing.version=2026-06-10 chat.attachments.allowed_extensions=jpg,jpeg,png,webp,heic,heif,pdf diff --git a/backlog.md b/backlog.md index 9b1a348..a7a7dd0 100644 --- a/backlog.md +++ b/backlog.md @@ -15,13 +15,13 @@ 14. Проверить повторную отправку СМС (меня перенесло на главный экран) 15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak)) 16. Сделать тестового пользователя с фиксированным СМС-входом -17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама) +~~17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)~~ ~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~ На будущее (после доработки отдельных функциональностей): 1. Разработка message-safety 2. Разработка sync-service -3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP. +~~3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.~~ 3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?) 4. Моделирование профиля клиента/ 5. Моделирование уведомлений. diff --git a/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py index 12f9e3e..20e1f46 100644 --- a/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py +++ b/codebase/backend/api-backend/alembic/versions/0001_initial_han_app.py @@ -30,6 +30,11 @@ SEED = { True, ), "consent.personal_data.version": ("2026-06-10", "string", True), + "consent.privacy_policy.document_url": ( + "https://www.han0107.ru/privacy", + "string", + True, + ), "consent.user_agreement.required": ("true", "boolean", True), "consent.user_agreement.document_url": ( "https://www.han0107.ru/user-agreement", @@ -38,6 +43,11 @@ SEED = { ), "consent.user_agreement.version": ("2026-06-10", "string", True), "consent.marketing.required": ("false", "boolean", True), + "consent.marketing.document_url": ( + "https://www.han0107.ru/privacy/ads-agree", + "string", + True, + ), "consent.marketing.version": ("2026-06-10", "string", True), "chat.attachments.allowed_extensions": ( "jpg,jpeg,png,webp,heic,heif,pdf", diff --git a/codebase/backend/api-backend/alembic/versions/0006_consent_privacy_policy_url.py b/codebase/backend/api-backend/alembic/versions/0006_consent_privacy_policy_url.py new file mode 100644 index 0000000..f2ba8c2 --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0006_consent_privacy_policy_url.py @@ -0,0 +1,34 @@ +"""Seed consent.privacy_policy.document_url for personal_data consent UI. + +Revision ID: 0006_privacy_policy +Revises: 0005_otp_settings +Create Date: 2026-07-23 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0006_privacy_policy" +down_revision: str | None = "0005_otp_settings" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + INSERT INTO han_app.app_settings + (setting_key, setting_value, value_type, is_public, description, + record_status, updated_at) + VALUES + ('consent.privacy_policy.document_url', + 'https://www.han0107.ru/privacy', 'string', true, + 'Privacy policy URL shown next to personal_data consent', 'A', now()) + ON CONFLICT (setting_key) DO NOTHING + """ + ) + + +def downgrade() -> None: + raise RuntimeError("Privacy policy consent URL migration is forward-only") diff --git a/codebase/backend/api-backend/alembic/versions/0007_consent_marketing_document_url.py b/codebase/backend/api-backend/alembic/versions/0007_consent_marketing_document_url.py new file mode 100644 index 0000000..8f74c5c --- /dev/null +++ b/codebase/backend/api-backend/alembic/versions/0007_consent_marketing_document_url.py @@ -0,0 +1,34 @@ +"""Seed consent.marketing.document_url for marketing consent UI link. + +Revision ID: 0007_marketing_doc +Revises: 0006_privacy_policy +Create Date: 2026-07-23 +""" + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0007_marketing_doc" +down_revision: str | None = "0006_privacy_policy" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute( + """ + INSERT INTO han_app.app_settings + (setting_key, setting_value, value_type, is_public, description, + record_status, updated_at) + VALUES + ('consent.marketing.document_url', + 'https://www.han0107.ru/privacy/ads-agree', 'string', true, + 'Marketing communications consent document URL', 'A', now()) + ON CONFLICT (setting_key) DO NOTHING + """ + ) + + +def downgrade() -> None: + raise RuntimeError("Marketing consent document URL migration is forward-only") diff --git a/codebase/backend/api-backend/app/main.py b/codebase/backend/api-backend/app/main.py index 9cdfb92..ccbd0cd 100644 --- a/codebase/backend/api-backend/app/main.py +++ b/codebase/backend/api-backend/app/main.py @@ -476,12 +476,22 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep }, "operator": {"call_phone": values["operator.call.phone"]}, "consents": { - name: { - "required": settings.boolean(f"consent.{name}.required"), - "document_url": values.get(f"consent.{name}.document_url"), - "version": values[f"consent.{name}.version"], - } - for name in ("personal_data", "user_agreement", "marketing") + "personal_data": { + "required": settings.boolean("consent.personal_data.required"), + "document_url": values.get("consent.personal_data.document_url"), + "privacy_policy_document_url": values.get( + "consent.privacy_policy.document_url" + ), + "version": values["consent.personal_data.version"], + }, + **{ + name: { + "required": settings.boolean(f"consent.{name}.required"), + "document_url": values.get(f"consent.{name}.document_url"), + "version": values[f"consent.{name}.version"], + } + for name in ("user_agreement", "marketing") + }, }, "attachments": { "allowed_extensions": settings.strings("chat.attachments.allowed_extensions"), diff --git a/codebase/backend/api-backend/app/services.py b/codebase/backend/api-backend/app/services.py index a9f060c..8758004 100644 --- a/codebase/backend/api-backend/app/services.py +++ b/codebase/backend/api-backend/app/services.py @@ -69,10 +69,12 @@ REQUIRED_SETTINGS = { "consent.personal_data.required", "consent.personal_data.document_url", "consent.personal_data.version", + "consent.privacy_policy.document_url", "consent.user_agreement.required", "consent.user_agreement.document_url", "consent.user_agreement.version", "consent.marketing.required", + "consent.marketing.document_url", "consent.marketing.version", "chat.attachments.allowed_extensions", "chat.attachments.allowed_mime_types", diff --git a/codebase/backend/deployment/app-settings.production-like.yaml b/codebase/backend/deployment/app-settings.production-like.yaml index 84c8974..533df48 100644 --- a/codebase/backend/deployment/app-settings.production-like.yaml +++ b/codebase/backend/deployment/app-settings.production-like.yaml @@ -12,10 +12,12 @@ settings: consent.personal_data.required: {type: boolean, value: true, public: true} consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true} consent.personal_data.version: {type: string, value: "2026-06-10", public: true} + consent.privacy_policy.document_url: {type: string, value: "https://www.han0107.ru/privacy", public: true} consent.user_agreement.required: {type: boolean, value: true, public: true} consent.user_agreement.document_url: {type: string, value: "https://www.han0107.ru/user-agreement", public: true} consent.user_agreement.version: {type: string, value: "2026-06-10", public: true} consent.marketing.required: {type: boolean, value: false, public: true} + consent.marketing.document_url: {type: string, value: "https://www.han0107.ru/privacy/ads-agree", public: true} consent.marketing.version: {type: string, value: "2026-06-10", public: true} chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true} chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true} diff --git a/codebase/backend/frontend-test-site/app/index.tsx b/codebase/backend/frontend-test-site/app/index.tsx index eade638..6081acf 100644 --- a/codebase/backend/frontend-test-site/app/index.tsx +++ b/codebase/backend/frontend-test-site/app/index.tsx @@ -1,10 +1,11 @@ import { useQuery } from "@tanstack/react-query"; import { useRouter } from "expo-router"; import React, { useState } from "react"; -import { Linking, ScrollView, Switch, Text, View } from "react-native"; +import { ScrollView, Text, View } from "react-native"; import { useApp } from "../src/app-context"; import { AppHeader } from "../src/components/AppHeader"; import { ChatInputBar } from "../src/components/ChatInputBar"; +import { ConsentModal } from "../src/components/ConsentModal"; import { HanLogo } from "../src/components/HanLogo"; import { PopularQuestionsList } from "../src/components/PopularQuestionsList"; import { QuickActions } from "../src/components/QuickActions"; @@ -17,14 +18,13 @@ import { } from "../src/pending-intent"; import { dialogApi, publicApi, uploadAttachment } from "../src/services"; import type { Consents } from "../src/types"; -import { Button, ErrorNotice, Loading, styles } from "../src/ui"; +import { ErrorNotice, Loading, styles } from "../src/ui"; export default function HomeScreen() { const { authStatus, authorize } = useApp(); const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config }); const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content }); const [consentOpen, setConsentOpen] = useState(false); - const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false }); const [pending, setPending] = useState(null); const [message, setMessage] = useState(""); const [sendError, setSendError] = useState(); @@ -111,13 +111,16 @@ export default function HomeScreen() { } }; - const accept = async () => { - if (!required.personal || !required.agreement) return; + const accept = async (accepted: { + personal_data: boolean; + user_agreement: boolean; + marketing: boolean; + }) => { const versions = config.data?.consents; const consents: Consents = { - personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" }, - user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" }, - marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" }, + personal_data: { accepted: accepted.personal_data, version: versions?.personal_data?.version ?? "current" }, + user_agreement: { accepted: accepted.user_agreement, version: versions?.user_agreement?.version ?? "current" }, + marketing: { accepted: accepted.marketing, version: versions?.marketing?.version ?? "current" }, }; setConsentOpen(false); try { @@ -165,46 +168,18 @@ export default function HomeScreen() { {consentOpen && ( - - - Согласия перед входом - Для отправки сообщения или файла необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации. - {(["personal_data", "user_agreement", "marketing"] as const).map((key) => { - const item = config.data?.consents?.[key]; - if (!item) return null; - return item.document_url ? ( - void Linking.openURL(item.document_url!)}> - {key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version} - - ) : null; - })} - setRequired({ ...required, personal })} /> - setRequired({ ...required, agreement })} /> - setRequired({ ...required, marketing })} /> - - + + ); +} export function Notifications() { const navigate = useNavigate(); const [messages, setMessages] = useState(() => getActiveMessages()); const [index, setIndex] = useState(0); + const [installReady, setInstallReady] = useState(false); + const [installing, setInstalling] = useState(false); + + // Перехватываем beforeinstallprompt + useEffect(() => { + const handler = (e: Event) => { + e.preventDefault(); + deferredPrompt = e as BeforeInstallPromptEvent; + setInstallReady(true); + }; + window.addEventListener('beforeinstallprompt', handler); + return () => window.removeEventListener('beforeinstallprompt', handler); + }, []); const handleDismiss = useCallback((id: string) => { dismissMessage(id); @@ -81,6 +130,24 @@ export function Notifications() { setIndex(i => Math.min(i, Math.max(next.length - 1, 0))); }, []); + async function handleInstall(msgId: string) { + if (!deferredPrompt) { + // На десктопе или если prompt недоступен — просто закрываем + handleDismiss(msgId); + return; + } + setInstalling(true); + await deferredPrompt.prompt(); + const { outcome } = await deferredPrompt.userChoice; + deferredPrompt = null; + setInstalling(false); + if (outcome === 'accepted') { + handleDismiss(msgId); + } + } + + if (isGuest()) return ; + if (messages.length === 0) return null; const msg = messages[index]; @@ -90,9 +157,8 @@ export function Notifications() { function handleAction() { if (msg.type === 'message' && msg.chatId) { navigate(`/chat/${msg.chatId}`); - } else if (msg.type === 'promo') { - // внешний переход — в реальном приложении будет ссылка - navigate(`/notification/${msg.id}`); + } else if (msg.type === 'install') { + handleInstall(msg.id); } else { navigate(`/notification/${msg.id}`); } @@ -100,13 +166,14 @@ export function Notifications() { const actionLabel = msg.type === 'message' ? 'Открыть чат →' : + msg.type === 'install' ? (installing ? 'Открываем...' : (installReady ? 'Установить →' : 'Как установить →')) : msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' : 'Подробнее →'; return (
- {/* Верхняя строка: метка + навигация + закрыть */} + {/* Верхняя строка */}
{cfg.label} @@ -151,7 +218,6 @@ export function Notifications() {

{msg.title}

{msg.description}

- {/* Промо-цена */} {msg.type === 'promo' && msg.promo && (
{msg.promo.price} @@ -163,20 +229,21 @@ export function Notifications() {
- {/* Футер с действием */} + {/* Футер */}
- {/* Для промо — дополнительно показываем дату */} - {msg.type === 'promo' && ( - {msg.date} + {msg.type === 'install' && !installReady && ( + iOS: через Safari → «На экран» )}
diff --git a/figma/src/app/data/companyMessages.ts b/figma/src/app/data/companyMessages.ts index 0dab64c..df267ad 100644 --- a/figma/src/app/data/companyMessages.ts +++ b/figma/src/app/data/companyMessages.ts @@ -1,6 +1,6 @@ export interface CompanyMessage { id: string; - type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo'; + type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install'; title: string; description: string; fullContent: string; @@ -80,6 +80,14 @@ export const companyMessages: CompanyMessage[] = [ }, { id: '7', + type: 'install', + title: 'Установите приложение', + description: 'Быстрый доступ с экрана телефона без браузера', + fullContent: 'Добавьте HAN на главный экран — приложение откроется мгновенно, будет работать офлайн и присылать важные напоминания о документах.', + date: 'Сегодня', + }, + { + id: '9', type: 'promo', title: 'Полное оформление ВНЖ под ключ', description: 'Юрист сам подаст документы — вам только расписаться', diff --git a/figma/src/app/data/session.ts b/figma/src/app/data/session.ts new file mode 100644 index 0000000..2285ef1 --- /dev/null +++ b/figma/src/app/data/session.ts @@ -0,0 +1,20 @@ +export type AuthStatus = 'authenticated' | 'guest' | null; + +const KEY = 'han_auth_status'; + +export function getAuthStatus(): AuthStatus { + return (localStorage.getItem(KEY) as AuthStatus) ?? null; +} + +export function setAuthStatus(status: AuthStatus) { + if (status === null) localStorage.removeItem(KEY); + else localStorage.setItem(KEY, status); +} + +export function isGuest() { + return getAuthStatus() === 'guest'; +} + +export function isAuthenticated() { + return getAuthStatus() === 'authenticated'; +} diff --git a/figma/src/app/pages/AuthConsent.tsx b/figma/src/app/pages/AuthConsent.tsx new file mode 100644 index 0000000..7223100 --- /dev/null +++ b/figma/src/app/pages/AuthConsent.tsx @@ -0,0 +1,162 @@ +import { useState } from 'react'; +import { useNavigate, useLocation } from 'react-router'; +import { ArrowRight, Check, ExternalLink, ShieldCheck } from 'lucide-react'; + +interface ConsentLink { + label: string; + href: string; +} + +interface ConsentItem { + id: string; + required: boolean; + text: string; + links: ConsentLink[]; +} + +const consents: ConsentItem[] = [ + { + id: 'pdp', + required: true, + text: 'Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных', + links: [ + { label: 'Согласие на обработку ПД', href: '#pdp-consent' }, + { label: 'Политика обработки ПД', href: '#pdp-policy' }, + ], + }, + { + id: 'terms', + required: true, + text: 'Я прочитал и соглашаюсь с Пользовательским соглашением', + links: [{ label: 'Пользовательское соглашение', href: '#terms' }], + }, + { + id: 'marketing', + required: false, + text: 'Я даю своё согласие на получение рекламных и маркетинговых коммуникаций', + links: [{ label: 'Условия получения коммуникаций', href: '#marketing' }], + }, +]; + +function Checkbox({ checked, onChange }: { checked: boolean; onChange: () => void }) { + return ( + + ); +} + +export function AuthConsent() { + const navigate = useNavigate(); + const location = useLocation(); + const returnTo = (location.state as { returnTo?: string })?.returnTo ?? '/auth/phone'; + + const [checked, setChecked] = useState>({ + pdp: false, + terms: false, + marketing: false, + }); + + const toggle = (id: string) => setChecked(prev => ({ ...prev, [id]: !prev[id] })); + + const requiredDone = consents.filter(c => c.required).every(c => checked[c.id]); + + function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + if (requiredDone) navigate(returnTo); + } + + return ( +
+
+ + {/* Иконка + заголовок */} +
+
+ +
+

+ Перед началом работы +

+

+ Для использования приложения ознакомьтесь со следующими документами и предоставьте необходимые согласия +

+
+ + {/* Форма согласий */} +
+
+ {consents.map(consent => ( +
toggle(consent.id)} + > +
+
+ toggle(consent.id)} /> +
+
+

+ {consent.text} + {consent.required && ( + * + )} +

+ {/* Ссылки на документы */} + +
+
+
+ ))} + +

+ * — обязательные согласия +

+
+ + {/* Кнопка */} +
+ +
+
+ +
+
+ ); +} diff --git a/figma/src/app/pages/AuthLoading.tsx b/figma/src/app/pages/AuthLoading.tsx index 6d2ede0..9de5af9 100644 --- a/figma/src/app/pages/AuthLoading.tsx +++ b/figma/src/app/pages/AuthLoading.tsx @@ -1,5 +1,6 @@ import { useEffect } from 'react'; import { useNavigate, useLocation } from 'react-router'; +import { setAuthStatus } from '../data/session'; export function AuthLoading() { const navigate = useNavigate(); @@ -8,7 +9,7 @@ export function AuthLoading() { useEffect(() => { // Имитируем авторизацию — через 2.8 секунды переходим на главную - const t = setTimeout(() => navigate('/'), 2800); + const t = setTimeout(() => { setAuthStatus('authenticated'); navigate('/'); }, 2800); return () => clearTimeout(t); }, [navigate]); diff --git a/figma/src/app/pages/AuthPhone.tsx b/figma/src/app/pages/AuthPhone.tsx index 8cb92f2..5a9d583 100644 --- a/figma/src/app/pages/AuthPhone.tsx +++ b/figma/src/app/pages/AuthPhone.tsx @@ -88,15 +88,6 @@ export function AuthPhone() {
- {/* Низ страницы */} -
-

- Нажимая «Получить код», вы соглашаетесь с{' '} - условиями использования - {' '}и{' '} - политикой конфиденциальности -

-
); diff --git a/figma/src/app/pages/History.tsx b/figma/src/app/pages/History.tsx index 45c37ee..625495a 100644 --- a/figma/src/app/pages/History.tsx +++ b/figma/src/app/pages/History.tsx @@ -1,4 +1,4 @@ -import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag } from 'lucide-react'; +import { Clock, ArrowLeft, Bell, AlertCircle, Calendar, MessageCircle, Tag, Smartphone } from 'lucide-react'; import { useNavigate } from 'react-router'; import { getActiveMessages, CompanyMessage } from '../data/companyMessages'; @@ -7,6 +7,7 @@ function getIcon(type: CompanyMessage['type']) { if (type === 'reminder') return ; if (type === 'message') return ; if (type === 'promo') return ; + if (type === 'install') return ; return ; } @@ -15,6 +16,7 @@ function typeColors(type: CompanyMessage['type']) { if (type === 'reminder') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' }; if (type === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' }; if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' }; + if (type === 'install') return { icon: 'bg-[#d1c4e9] text-[#4527a0]', dot: 'bg-[#7c4dff]' }; return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' }; } diff --git a/figma/src/app/routes.tsx b/figma/src/app/routes.tsx index f43c806..db0ebae 100644 --- a/figma/src/app/routes.tsx +++ b/figma/src/app/routes.tsx @@ -9,6 +9,7 @@ import { NotificationDetail } from './pages/NotificationDetail'; import { AuthPhone } from './pages/AuthPhone'; import { AuthOtp } from './pages/AuthOtp'; import { AuthLoading } from './pages/AuthLoading'; +import { AuthConsent } from './pages/AuthConsent'; export const router = createBrowserRouter([ { @@ -21,6 +22,7 @@ export const router = createBrowserRouter([ { path: 'chat/:id', Component: Chat }, { path: 'calendar', Component: Calendar }, { path: 'notification/:id', Component: NotificationDetail }, + { path: 'auth/consent', Component: AuthConsent }, { path: 'auth/phone', Component: AuthPhone }, { path: 'auth/otp', Component: AuthOtp }, { path: 'auth/loading', Component: AuthLoading }, diff --git a/modules/module-01-api-backend.md b/modules/module-01-api-backend.md index 0718a3f..018f1f7 100644 --- a/modules/module-01-api-backend.md +++ b/modules/module-01-api-backend.md @@ -241,9 +241,14 @@ Pydantic `422` преобразуется в `400 validation_error`, чтобы "auth": {"phone_enabled": true, "password_enabled": false}, "operator": {"call_phone": "+74999591007"}, "consents": { - "personal_data": {"required": true, "document_url": "https://...", "version": "2026-06-10"}, + "personal_data": { + "required": true, + "document_url": "https://www.han0107.ru/privacy/persdata-agree-mobile", + "privacy_policy_document_url": "https://www.han0107.ru/privacy", + "version": "2026-06-10" + }, "user_agreement": {"required": true, "document_url": "https://...", "version": "2026-06-10"}, - "marketing": {"required": false, "document_url": null, "version": "2026-06-10"} + "marketing": {"required": false, "document_url": "https://www.han0107.ru/privacy/ads-agree", "version": "2026-06-10"} }, "attachments": { "allowed_extensions": ["jpg", "jpeg", "png", "webp", "heic", "heif", "pdf"], diff --git a/modules/module-02-frontend-test-site.md b/modules/module-02-frontend-test-site.md index 326d904..ff27412 100644 --- a/modules/module-02-frontend-test-site.md +++ b/modules/module-02-frontend-test-site.md @@ -74,7 +74,7 @@ Auth state machine: `guest → authorizing → bootstrapping → authenticated`; ### 5.2. Согласия и OTP -Modal согласий отображает актуальные URL/версии из config. `personal_data` и `user_agreement` обязательны, `marketing` необязателен. После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect. +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. diff --git a/ops-monitoring/send_sms.md b/ops-monitoring/send_sms.md index d8eed77..18c2673 100644 --- a/ops-monitoring/send_sms.md +++ b/ops-monitoring/send_sms.md @@ -1,4 +1,6 @@ -На ВМ выполните: +# Отправка СМС + +## На ВМ выполните: cd /opt/han-chat/backend umask 077 @@ -46,7 +48,7 @@ with open(sys.argv[1], "w", encoding="utf-8") as file: PY -Создайте функцию отправки: +## Создайте функцию отправки: send_sms_smoke() { docker compose --env-file .env --profile ops run --rm --no-deps \ @@ -66,13 +68,13 @@ send_sms_smoke() { ' } -Отправка: +## Отправка: send_sms_smoke Ожидается: HTTP 202 и JSON с sms_message_id. -Проверьте журнал: +## Проверьте журнал: SELECT id, phone_masked, @@ -86,6 +88,144 @@ FROM sms.sms_outbound_message ORDER BY created_at DESC LIMIT 5; -После проверки удалите секретные данные: +## После проверки удалите секретные данные: shred -u "$REQUEST_FILE" 2>/dev/null || rm -f "$REQUEST_FILE" -unset SMS_TOKEN OTP_CODE TEST_PHONE CHALLENGE_ID REQUEST_FILE \ No newline at end of file +unset SMS_TOKEN OTP_CODE TEST_PHONE CHALLENGE_ID REQUEST_FILE + +# Тесты + +Выполняйте на ВМ из `/opt/han-chat/backend`. + +### 1. Проверить запрет публичного internal API + +```bash +PUBLIC_WEB_URL=$(python3 - <<'PY' +from pathlib import Path +for line in Path(".env").read_text().splitlines(): + if line.startswith("PUBLIC_WEB_URL="): + print(line.split("=", 1)[1].strip().strip("\"'")) + break +PY +) + +curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \ + "$PUBLIC_WEB_URL/internal/sms/v1/messages/00000000-0000-0000-0000-000000000000" +``` + +Ожидается: + +```text +HTTP 404 +``` + +### 2. Проверить callback с неправильного IP + +```bash +curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \ + -X POST \ + -H 'Content-Type: application/json' \ + --data '[]' \ + "$PUBLIC_WEB_URL/callbacks/idgtl/sms" +``` + +Ожидается: + +```text +HTTP 403 +``` + +Заголовок `X-Forwarded-For` не должен позволять обойти ограничение. + +### 3. Проверить Basic auth внутри Docker-сети + +Получите credentials из `.env`: + +```bash +CB_USER=$(python3 - <<'PY' +from pathlib import Path +for line in Path(".env").read_text().splitlines(): + if line.startswith("IDGTL_SMS_CALLBACK_USERNAME="): + print(line.split("=", 1)[1].strip().strip("\"'")) + break +PY +) + +CB_PASS=$(python3 - <<'PY' +from pathlib import Path +for line in Path(".env").read_text().splitlines(): + if line.startswith("IDGTL_SMS_CALLBACK_PASSWORD="): + print(line.split("=", 1)[1].strip().strip("\"'")) + break +PY +) + +export CB_USER CB_PASS +``` + +Неверные credentials: + +```bash +docker compose --env-file .env --profile ops run --rm --no-deps \ + --entrypoint sh toolbox -ec ' + curl -sS -o /dev/null -w "HTTP %{http_code}\n" \ + -u invalid:invalid \ + -H "Content-Type: application/json" \ + --data "[]" \ + http://sms-service:8080/callbacks/idgtl/sms + ' +``` + +Ожидается `HTTP 401`. + +Правильные credentials: + +```bash +docker compose --env-file .env --profile ops run --rm --no-deps \ + --entrypoint sh -e CB_USER -e CB_PASS toolbox -ec ' + curl -sS -o /dev/null -w "HTTP %{http_code}\n" \ + -u "$CB_USER:$CB_PASS" \ + -H "Content-Type: application/json" \ + --data "[]" \ + http://sms-service:8080/callbacks/idgtl/sms + ' +``` + +Ожидается `HTTP 422`: авторизация прошла, но пустой callback-массив невалиден. + +После проверки: + +```bash +unset CB_USER CB_PASS +``` + +### 4. Проверить реальный callback Direct + +После тестовой SMS: + +```sql +SELECT + id, + send_status, + delivery_status, + provider_message_id, + callback_last_at, + sent_at, + delivered_at +FROM sms.sms_outbound_message +ORDER BY created_at DESC +LIMIT 5; +``` + +Успешный реальный callback подтверждается: + +- `callback_last_at IS NOT NULL`; +- `delivery_status = sent` или `delivered`; +- заполняются `sent_at`/`delivered_at`. + +Дополнительно: + +```bash +docker compose --env-file .env logs --since=30m nginx sms-service +``` + +Для callback должен быть ответ `204`. Только реальный запрос Direct может полноценно подтвердить IP allowlist. \ No newline at end of file diff --git a/releases/#0 deploy-steps.md b/releases/#0 deploy-steps.md index 16fef04..2449d00 100644 --- a/releases/#0 deploy-steps.md +++ b/releases/#0 deploy-steps.md @@ -8,6 +8,9 @@ - (без спец.символов) openssl rand -hex 16 - openssl rand -base64 16 | xclip -selection clipboard # Linux +Посмотреть состояние контейнеров +docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}" + Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5432 root@135.106.164.58 -N #Обновление проекта @@ -49,6 +52,9 @@ rsync -rltD --no-perms --no-owner --no-group -ivc --delete \ /mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \ root@135.106.164.58:/opt/han-chat/backend/ +3. Копирование env (опционально) +scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\.env" root@135.106.164.58:/opt/han-chat/backend + cd /opt/han-chat/backend find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} + chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh