Реализован интерфейс согласий
This commit is contained in:
@@ -141,7 +141,7 @@ flowchart LR
|
|||||||
|
|
||||||
- стартовый экран с приветствием, популярными вопросами, полем ввода, историей и профилем;
|
- стартовый экран с приветствием, популярными вопросами, полем ввода, историей и профилем;
|
||||||
- гостевой режим до первого сообщения;
|
- гостевой режим до первого сообщения;
|
||||||
- показ pop-up с обязательными согласиями на обработку персональных данных и пользовательское соглашение, а также необязательным согласием на рекламные коммуникации;
|
- показ pop-up с обязательными согласиями на обработку персональных данных (со ссылками на согласие и политику ПД) и пользовательское соглашение, а также необязательным согласием на рекламные коммуникации;
|
||||||
- сбор данных устройства для передачи в backend;
|
- сбор данных устройства для передачи в backend;
|
||||||
- **управление аналитической UX-сессией** на клиенте: после получения JWT — `session_start`, хранение `ux_session_id` и `last_activity_at` **только в памяти**, заголовок `X-Ux-Session-Id` в JWT-запросах;
|
- **управление аналитической UX-сессией** на клиенте: после получения JWT — `session_start`, хранение `ux_session_id` и `last_activity_at` **только в памяти**, заголовок `X-Ux-Session-Id` в JWT-запросах;
|
||||||
- хранение access token и refresh token в безопасном хранилище после авторизации;
|
- хранение access token и refresh token в безопасном хранилище после авторизации;
|
||||||
|
|||||||
@@ -88,7 +88,7 @@ Managed PostgreSQL **поднимается до** развёртывания п
|
|||||||
| Auth | `auth.phone.enabled`, `auth.password.enabled` |
|
| 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` |
|
| 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` |
|
| Оператор | `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.*` |
|
| Файлы чата | `chat.attachments.*` |
|
||||||
| Rate limits (app) | `rate_limit.message_send.*`, `rate_limit.download_url.*`, `rate_limit.public_endpoints.*`, `rate_limit.login.*` |
|
| Rate limits (app) | `rate_limit.message_send.*`, `rate_limit.download_url.*`, `rate_limit.public_endpoints.*`, `rate_limit.login.*` |
|
||||||
| UX | `ux.session.idle_timeout_minutes` |
|
| UX | `ux.session.idle_timeout_minutes` |
|
||||||
@@ -112,10 +112,12 @@ operator.call.phone=+74999591007
|
|||||||
consent.personal_data.required=true
|
consent.personal_data.required=true
|
||||||
consent.personal_data.document_url=https://www.han0107.ru/privacy/persdata-agree-mobile
|
consent.personal_data.document_url=https://www.han0107.ru/privacy/persdata-agree-mobile
|
||||||
consent.personal_data.version=2026-06-10
|
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.required=true
|
||||||
consent.user_agreement.document_url=https://www.han0107.ru/user-agreement
|
consent.user_agreement.document_url=https://www.han0107.ru/user-agreement
|
||||||
consent.user_agreement.version=2026-06-10
|
consent.user_agreement.version=2026-06-10
|
||||||
consent.marketing.required=false
|
consent.marketing.required=false
|
||||||
|
consent.marketing.document_url=https://www.han0107.ru/privacy/ads-agree
|
||||||
consent.marketing.version=2026-06-10
|
consent.marketing.version=2026-06-10
|
||||||
|
|
||||||
chat.attachments.allowed_extensions=jpg,jpeg,png,webp,heic,heif,pdf
|
chat.attachments.allowed_extensions=jpg,jpeg,png,webp,heic,heif,pdf
|
||||||
|
|||||||
+2
-2
@@ -15,13 +15,13 @@
|
|||||||
14. Проверить повторную отправку СМС (меня перенесло на главный экран)
|
14. Проверить повторную отправку СМС (меня перенесло на главный экран)
|
||||||
15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
|
15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
|
||||||
16. Сделать тестового пользователя с фиксированным СМС-входом
|
16. Сделать тестового пользователя с фиксированным СМС-входом
|
||||||
17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)
|
~~17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)~~
|
||||||
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
|
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
|
||||||
|
|
||||||
На будущее (после доработки отдельных функциональностей):
|
На будущее (после доработки отдельных функциональностей):
|
||||||
1. Разработка message-safety
|
1. Разработка message-safety
|
||||||
2. Разработка sync-service
|
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 заливка мнемоник в БД (?)
|
3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
|
||||||
4. Моделирование профиля клиента/
|
4. Моделирование профиля клиента/
|
||||||
5. Моделирование уведомлений.
|
5. Моделирование уведомлений.
|
||||||
|
|||||||
@@ -30,6 +30,11 @@ SEED = {
|
|||||||
True,
|
True,
|
||||||
),
|
),
|
||||||
"consent.personal_data.version": ("2026-06-10", "string", 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.required": ("true", "boolean", True),
|
||||||
"consent.user_agreement.document_url": (
|
"consent.user_agreement.document_url": (
|
||||||
"https://www.han0107.ru/user-agreement",
|
"https://www.han0107.ru/user-agreement",
|
||||||
@@ -38,6 +43,11 @@ SEED = {
|
|||||||
),
|
),
|
||||||
"consent.user_agreement.version": ("2026-06-10", "string", True),
|
"consent.user_agreement.version": ("2026-06-10", "string", True),
|
||||||
"consent.marketing.required": ("false", "boolean", 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),
|
"consent.marketing.version": ("2026-06-10", "string", True),
|
||||||
"chat.attachments.allowed_extensions": (
|
"chat.attachments.allowed_extensions": (
|
||||||
"jpg,jpeg,png,webp,heic,heif,pdf",
|
"jpg,jpeg,png,webp,heic,heif,pdf",
|
||||||
|
|||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
@@ -476,12 +476,22 @@ async def app_config(request: Request, response: Response, settings: SnapshotDep
|
|||||||
},
|
},
|
||||||
"operator": {"call_phone": values["operator.call.phone"]},
|
"operator": {"call_phone": values["operator.call.phone"]},
|
||||||
"consents": {
|
"consents": {
|
||||||
|
"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: {
|
name: {
|
||||||
"required": settings.boolean(f"consent.{name}.required"),
|
"required": settings.boolean(f"consent.{name}.required"),
|
||||||
"document_url": values.get(f"consent.{name}.document_url"),
|
"document_url": values.get(f"consent.{name}.document_url"),
|
||||||
"version": values[f"consent.{name}.version"],
|
"version": values[f"consent.{name}.version"],
|
||||||
}
|
}
|
||||||
for name in ("personal_data", "user_agreement", "marketing")
|
for name in ("user_agreement", "marketing")
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"attachments": {
|
"attachments": {
|
||||||
"allowed_extensions": settings.strings("chat.attachments.allowed_extensions"),
|
"allowed_extensions": settings.strings("chat.attachments.allowed_extensions"),
|
||||||
|
|||||||
@@ -69,10 +69,12 @@ REQUIRED_SETTINGS = {
|
|||||||
"consent.personal_data.required",
|
"consent.personal_data.required",
|
||||||
"consent.personal_data.document_url",
|
"consent.personal_data.document_url",
|
||||||
"consent.personal_data.version",
|
"consent.personal_data.version",
|
||||||
|
"consent.privacy_policy.document_url",
|
||||||
"consent.user_agreement.required",
|
"consent.user_agreement.required",
|
||||||
"consent.user_agreement.document_url",
|
"consent.user_agreement.document_url",
|
||||||
"consent.user_agreement.version",
|
"consent.user_agreement.version",
|
||||||
"consent.marketing.required",
|
"consent.marketing.required",
|
||||||
|
"consent.marketing.document_url",
|
||||||
"consent.marketing.version",
|
"consent.marketing.version",
|
||||||
"chat.attachments.allowed_extensions",
|
"chat.attachments.allowed_extensions",
|
||||||
"chat.attachments.allowed_mime_types",
|
"chat.attachments.allowed_mime_types",
|
||||||
|
|||||||
@@ -12,10 +12,12 @@ settings:
|
|||||||
consent.personal_data.required: {type: boolean, value: true, public: true}
|
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.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.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.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.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.user_agreement.version: {type: string, value: "2026-06-10", public: true}
|
||||||
consent.marketing.required: {type: boolean, value: false, 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}
|
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_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}
|
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRouter } from "expo-router";
|
import { useRouter } from "expo-router";
|
||||||
import React, { useState } from "react";
|
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 { useApp } from "../src/app-context";
|
||||||
import { AppHeader } from "../src/components/AppHeader";
|
import { AppHeader } from "../src/components/AppHeader";
|
||||||
import { ChatInputBar } from "../src/components/ChatInputBar";
|
import { ChatInputBar } from "../src/components/ChatInputBar";
|
||||||
|
import { ConsentModal } from "../src/components/ConsentModal";
|
||||||
import { HanLogo } from "../src/components/HanLogo";
|
import { HanLogo } from "../src/components/HanLogo";
|
||||||
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
|
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
|
||||||
import { QuickActions } from "../src/components/QuickActions";
|
import { QuickActions } from "../src/components/QuickActions";
|
||||||
@@ -17,14 +18,13 @@ import {
|
|||||||
} from "../src/pending-intent";
|
} from "../src/pending-intent";
|
||||||
import { dialogApi, publicApi, uploadAttachment } from "../src/services";
|
import { dialogApi, publicApi, uploadAttachment } from "../src/services";
|
||||||
import type { Consents } from "../src/types";
|
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() {
|
export default function HomeScreen() {
|
||||||
const { authStatus, authorize } = useApp();
|
const { authStatus, authorize } = useApp();
|
||||||
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
|
||||||
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
|
||||||
const [consentOpen, setConsentOpen] = useState(false);
|
const [consentOpen, setConsentOpen] = useState(false);
|
||||||
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false });
|
|
||||||
const [pending, setPending] = useState<PendingTextIntent | null>(null);
|
const [pending, setPending] = useState<PendingTextIntent | null>(null);
|
||||||
const [message, setMessage] = useState("");
|
const [message, setMessage] = useState("");
|
||||||
const [sendError, setSendError] = useState<unknown>();
|
const [sendError, setSendError] = useState<unknown>();
|
||||||
@@ -111,13 +111,16 @@ export default function HomeScreen() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const accept = async () => {
|
const accept = async (accepted: {
|
||||||
if (!required.personal || !required.agreement) return;
|
personal_data: boolean;
|
||||||
|
user_agreement: boolean;
|
||||||
|
marketing: boolean;
|
||||||
|
}) => {
|
||||||
const versions = config.data?.consents;
|
const versions = config.data?.consents;
|
||||||
const consents: Consents = {
|
const consents: Consents = {
|
||||||
personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" },
|
personal_data: { accepted: accepted.personal_data, version: versions?.personal_data?.version ?? "current" },
|
||||||
user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" },
|
user_agreement: { accepted: accepted.user_agreement, version: versions?.user_agreement?.version ?? "current" },
|
||||||
marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" },
|
marketing: { accepted: accepted.marketing, version: versions?.marketing?.version ?? "current" },
|
||||||
};
|
};
|
||||||
setConsentOpen(false);
|
setConsentOpen(false);
|
||||||
try {
|
try {
|
||||||
@@ -165,46 +168,18 @@ export default function HomeScreen() {
|
|||||||
</View>
|
</View>
|
||||||
|
|
||||||
{consentOpen && (
|
{consentOpen && (
|
||||||
<View accessibilityViewIsModal style={styles.modalBackdrop}>
|
<ConsentModal
|
||||||
<View style={styles.modal}>
|
consents={config.data?.consents}
|
||||||
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
|
onAccept={(accepted) => void accept(accepted)}
|
||||||
<Text style={styles.text}>Для отправки сообщения или файла необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
|
onCancel={() => {
|
||||||
{(["personal_data", "user_agreement", "marketing"] as const).map((key) => {
|
|
||||||
const item = config.data?.consents?.[key];
|
|
||||||
if (!item) return null;
|
|
||||||
return item.document_url ? (
|
|
||||||
<Text key={key} accessibilityRole="link" style={styles.link} onPress={() => void Linking.openURL(item.document_url!)}>
|
|
||||||
{key === "personal_data" ? "Политика персональных данных" : key === "user_agreement" ? "Пользовательское соглашение" : "Согласие на рекламу"} · версия {item.version}
|
|
||||||
</Text>
|
|
||||||
) : null;
|
|
||||||
})}
|
|
||||||
<ConsentRow label="Обработка персональных данных (обязательно)" value={required.personal} onChange={(personal) => setRequired({ ...required, personal })} />
|
|
||||||
<ConsentRow label="Пользовательское соглашение (обязательно)" value={required.agreement} onChange={(agreement) => setRequired({ ...required, agreement })} />
|
|
||||||
<ConsentRow label="Рекламные коммуникации (необязательно)" value={required.marketing} onChange={(marketing) => setRequired({ ...required, marketing })} />
|
|
||||||
<View style={styles.row}>
|
|
||||||
<Button title="Продолжить" disabled={!required.personal || !required.agreement} onPress={() => void accept()} />
|
|
||||||
<Button
|
|
||||||
title="Отмена"
|
|
||||||
secondary
|
|
||||||
onPress={() => {
|
|
||||||
setConsentOpen(false);
|
setConsentOpen(false);
|
||||||
setPending(null);
|
setPending(null);
|
||||||
clearPendingTextIntent();
|
clearPendingTextIntent();
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
)}
|
)}
|
||||||
</ScreenShell>
|
</ScreenShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const spacing = 16;
|
const spacing = 16;
|
||||||
|
|
||||||
function ConsentRow({ label, value, onChange }: { label: string; value: boolean; onChange: (value: boolean) => void }) {
|
|
||||||
return <View style={[styles.row, { justifyContent: "space-between" }]}>
|
|
||||||
<Text style={[styles.text, { flex: 1 }]}>{label}</Text>
|
|
||||||
<Switch accessibilityLabel={label} value={value} onValueChange={onChange} />
|
|
||||||
</View>;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -0,0 +1,228 @@
|
|||||||
|
import React, { useMemo, useState } from "react";
|
||||||
|
import { Linking, Pressable, Text, View } from "react-native";
|
||||||
|
import type { PublicConfig } from "../types";
|
||||||
|
import { Button, styles } from "../ui";
|
||||||
|
import { colors, radii, spacing } from "../theme";
|
||||||
|
|
||||||
|
type ConsentKey = "personal_data" | "user_agreement" | "marketing";
|
||||||
|
|
||||||
|
type ConsentLink = {
|
||||||
|
label: string;
|
||||||
|
url: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConsentBlock = {
|
||||||
|
key: ConsentKey;
|
||||||
|
text: string;
|
||||||
|
required: boolean;
|
||||||
|
links: ConsentLink[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type ConsentConfigItem = PublicConfig["consents"][string];
|
||||||
|
|
||||||
|
type Props = {
|
||||||
|
consents: PublicConfig["consents"] | undefined;
|
||||||
|
onAccept: (accepted: Record<ConsentKey, boolean>) => void;
|
||||||
|
onCancel: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
const LABELS: Record<ConsentKey, {
|
||||||
|
text: string;
|
||||||
|
links: Array<{ label: string; readUrl: (item: ConsentConfigItem) => string | null | undefined }>;
|
||||||
|
}> = {
|
||||||
|
personal_data: {
|
||||||
|
text: "Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных",
|
||||||
|
links: [
|
||||||
|
{ label: "Согласие на обработку ПД", readUrl: (item) => item.document_url },
|
||||||
|
{ label: "Политика обработки ПД", readUrl: (item) => item.privacy_policy_document_url },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
user_agreement: {
|
||||||
|
text: "Я прочитал и соглашаюсь с Пользовательским соглашением",
|
||||||
|
links: [{ label: "Пользовательское соглашение", readUrl: (item) => item.document_url }],
|
||||||
|
},
|
||||||
|
marketing: {
|
||||||
|
text: "Я даю своё согласие на получение рекламных и маркетинговых коммуникаций",
|
||||||
|
links: [{ label: "Условия получения коммуникаций", readUrl: (item) => item.document_url }],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ConsentModal({ consents, onAccept, onCancel }: Props) {
|
||||||
|
const blocks = useMemo(() => buildBlocks(consents), [consents]);
|
||||||
|
const [checked, setChecked] = useState<Record<ConsentKey, boolean>>({
|
||||||
|
personal_data: false,
|
||||||
|
user_agreement: false,
|
||||||
|
marketing: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const requiredDone = blocks.length > 0
|
||||||
|
&& blocks.filter((block) => block.required).every((block) => checked[block.key]);
|
||||||
|
|
||||||
|
const toggle = (key: ConsentKey) => {
|
||||||
|
setChecked((prev) => ({ ...prev, [key]: !prev[key] }));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View accessibilityViewIsModal style={styles.modalBackdrop}>
|
||||||
|
<View style={[styles.modal, { maxWidth: 400, gap: spacing.lg }]}>
|
||||||
|
<View style={{ gap: spacing.sm }}>
|
||||||
|
<View style={consentStyles.iconWrap}>
|
||||||
|
<Text style={consentStyles.iconGlyph}>✓</Text>
|
||||||
|
</View>
|
||||||
|
<Text accessibilityRole="header" style={[styles.title, { fontSize: 22 }]}>
|
||||||
|
Перед началом работы
|
||||||
|
</Text>
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
Для использования приложения ознакомьтесь со следующими документами и предоставьте необходимые согласия
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: spacing.md }}>
|
||||||
|
{blocks.map((block) => {
|
||||||
|
const isChecked = checked[block.key];
|
||||||
|
return (
|
||||||
|
<Pressable
|
||||||
|
key={block.key}
|
||||||
|
accessibilityRole="checkbox"
|
||||||
|
accessibilityState={{ checked: isChecked }}
|
||||||
|
onPress={() => toggle(block.key)}
|
||||||
|
style={[
|
||||||
|
consentStyles.card,
|
||||||
|
isChecked ? consentStyles.cardChecked : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
<View style={consentStyles.cardRow}>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
consentStyles.checkbox,
|
||||||
|
isChecked ? consentStyles.checkboxChecked : null,
|
||||||
|
]}
|
||||||
|
>
|
||||||
|
{isChecked ? <Text style={consentStyles.checkMark}>✓</Text> : null}
|
||||||
|
</View>
|
||||||
|
<View style={{ flex: 1, gap: spacing.sm }}>
|
||||||
|
<Text style={styles.text}>
|
||||||
|
{block.text}
|
||||||
|
{block.required ? <Text style={{ color: colors.destructive }}> *</Text> : null}
|
||||||
|
</Text>
|
||||||
|
{block.links.length > 0 ? (
|
||||||
|
<View style={consentStyles.links}>
|
||||||
|
{block.links.map((link) => (
|
||||||
|
<Pressable
|
||||||
|
key={link.url}
|
||||||
|
accessibilityRole="link"
|
||||||
|
onPress={(event) => {
|
||||||
|
event?.stopPropagation?.();
|
||||||
|
void Linking.openURL(link.url);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text style={consentStyles.link}>↗ {link.label}</Text>
|
||||||
|
</Pressable>
|
||||||
|
))}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</Pressable>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<Text style={styles.muted}>
|
||||||
|
<Text style={{ color: colors.destructive }}>*</Text>
|
||||||
|
{" — обязательные согласия"}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View style={{ gap: spacing.sm }}>
|
||||||
|
<Button
|
||||||
|
title="Продолжить"
|
||||||
|
disabled={!requiredDone}
|
||||||
|
onPress={() => onAccept(checked)}
|
||||||
|
/>
|
||||||
|
<Button title="Отмена" secondary onPress={onCancel} />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildBlocks(consents: PublicConfig["consents"] | undefined): ConsentBlock[] {
|
||||||
|
return (Object.keys(LABELS) as ConsentKey[]).flatMap((key) => {
|
||||||
|
const item = consents?.[key];
|
||||||
|
if (!item) return [];
|
||||||
|
const meta = LABELS[key];
|
||||||
|
const links = meta.links.flatMap((link) => {
|
||||||
|
const url = link.readUrl(item);
|
||||||
|
return url ? [{ label: link.label, url }] : [];
|
||||||
|
});
|
||||||
|
return [{
|
||||||
|
key,
|
||||||
|
text: meta.text,
|
||||||
|
required: item.required,
|
||||||
|
links,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const consentStyles = {
|
||||||
|
iconWrap: {
|
||||||
|
width: 48,
|
||||||
|
height: 48,
|
||||||
|
borderRadius: 16,
|
||||||
|
backgroundColor: "rgba(3, 2, 19, 0.08)",
|
||||||
|
alignItems: "center" as const,
|
||||||
|
justifyContent: "center" as const,
|
||||||
|
marginBottom: spacing.xs,
|
||||||
|
},
|
||||||
|
iconGlyph: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: "700" as const,
|
||||||
|
},
|
||||||
|
card: {
|
||||||
|
borderWidth: 1,
|
||||||
|
borderColor: colors.border,
|
||||||
|
borderRadius: radii.xl,
|
||||||
|
backgroundColor: colors.card,
|
||||||
|
padding: spacing.md,
|
||||||
|
},
|
||||||
|
cardChecked: {
|
||||||
|
borderColor: "rgba(3, 2, 19, 0.3)",
|
||||||
|
backgroundColor: "rgba(3, 2, 19, 0.04)",
|
||||||
|
},
|
||||||
|
cardRow: {
|
||||||
|
flexDirection: "row" as const,
|
||||||
|
alignItems: "flex-start" as const,
|
||||||
|
gap: spacing.md,
|
||||||
|
},
|
||||||
|
checkbox: {
|
||||||
|
width: 20,
|
||||||
|
height: 20,
|
||||||
|
borderRadius: radii.sm,
|
||||||
|
borderWidth: 2,
|
||||||
|
borderColor: colors.border,
|
||||||
|
backgroundColor: colors.background,
|
||||||
|
alignItems: "center" as const,
|
||||||
|
justifyContent: "center" as const,
|
||||||
|
marginTop: 2,
|
||||||
|
},
|
||||||
|
checkboxChecked: {
|
||||||
|
borderColor: colors.primary,
|
||||||
|
backgroundColor: colors.primary,
|
||||||
|
},
|
||||||
|
checkMark: {
|
||||||
|
color: colors.primaryForeground,
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: "700" as const,
|
||||||
|
lineHeight: 14,
|
||||||
|
},
|
||||||
|
links: {
|
||||||
|
flexDirection: "row" as const,
|
||||||
|
flexWrap: "wrap" as const,
|
||||||
|
gap: spacing.sm,
|
||||||
|
},
|
||||||
|
link: {
|
||||||
|
color: colors.primary,
|
||||||
|
fontSize: 12,
|
||||||
|
textDecorationLine: "underline" as const,
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -59,6 +59,7 @@ export type PublicConfig = {
|
|||||||
consents: Record<string, {
|
consents: Record<string, {
|
||||||
required: boolean;
|
required: boolean;
|
||||||
document_url: string | null;
|
document_url: string | null;
|
||||||
|
privacy_policy_document_url?: string | null;
|
||||||
version: string;
|
version: string;
|
||||||
}>;
|
}>;
|
||||||
attachments: {
|
attachments: {
|
||||||
|
|||||||
@@ -8,7 +8,12 @@ test.beforeEach(async ({ page }) => {
|
|||||||
ux: { idle_timeout_minutes: 15 },
|
ux: { idle_timeout_minutes: 15 },
|
||||||
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
|
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
|
||||||
consents: {
|
consents: {
|
||||||
personal_data: { required: true, version: "2026-07-01", document_url: "https://example.test/personal" },
|
personal_data: {
|
||||||
|
required: true,
|
||||||
|
version: "2026-07-01",
|
||||||
|
document_url: "https://example.test/personal",
|
||||||
|
privacy_policy_document_url: "https://example.test/privacy",
|
||||||
|
},
|
||||||
user_agreement: { required: true, version: "2026-07-01", document_url: "https://example.test/agreement" },
|
user_agreement: { required: true, version: "2026-07-01", document_url: "https://example.test/agreement" },
|
||||||
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
|
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
|
||||||
},
|
},
|
||||||
@@ -49,7 +54,9 @@ test("первое сообщение требует обязательные с
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByLabel("Сообщение").fill("Здравствуйте");
|
await page.getByLabel("Сообщение").fill("Здравствуйте");
|
||||||
await page.getByRole("button", { name: "Отправить" }).click();
|
await page.getByRole("button", { name: "Отправить" }).click();
|
||||||
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
|
||||||
|
await expect(page.getByText("Согласие на обработку ПД")).toBeVisible();
|
||||||
|
await expect(page.getByText("Политика обработки ПД")).toBeVisible();
|
||||||
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
|
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -57,7 +64,7 @@ test("Enter отправляет введённое сообщение", async (
|
|||||||
await page.goto("/");
|
await page.goto("/");
|
||||||
await page.getByLabel("Сообщение").fill("Отправка с клавиатуры");
|
await page.getByLabel("Сообщение").fill("Отправка с клавиатуры");
|
||||||
await page.getByLabel("Сообщение").press("Enter");
|
await page.getByLabel("Сообщение").press("Enter");
|
||||||
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
|
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
|
||||||
});
|
});
|
||||||
|
|
||||||
test("сообщение ограничено 4000 символами", async ({ page }) => {
|
test("сообщение ограничено 4000 символами", async ({ page }) => {
|
||||||
@@ -65,7 +72,7 @@ test("сообщение ограничено 4000 символами", async ({
|
|||||||
await page.getByLabel("Сообщение").fill("а".repeat(4001));
|
await page.getByLabel("Сообщение").fill("а".repeat(4001));
|
||||||
await page.getByRole("button", { name: "Отправить" }).click();
|
await page.getByRole("button", { name: "Отправить" }).click();
|
||||||
await expect(page.getByRole("alert")).toContainText("Максимум — 4000 символов");
|
await expect(page.getByRole("alert")).toContainText("Максимум — 4000 символов");
|
||||||
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toHaveCount(0);
|
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toHaveCount(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
|
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
|
||||||
|
|||||||
Binary file not shown.
@@ -1,6 +1,7 @@
|
|||||||
import { Mic, Paperclip, Send } from 'lucide-react';
|
import { Mic, Paperclip, Send } from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
|
import { isGuest } from '../data/session';
|
||||||
|
|
||||||
export function ChatInput() {
|
export function ChatInput() {
|
||||||
const [message, setMessage] = useState('');
|
const [message, setMessage] = useState('');
|
||||||
@@ -9,12 +10,16 @@ export function ChatInput() {
|
|||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (message.trim()) {
|
if (!message.trim()) return;
|
||||||
// Переход в чат при отправке сообщения
|
|
||||||
|
if (isGuest()) {
|
||||||
|
navigate('/auth/consent', { state: { returnTo: '/auth/phone' } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
navigate('/chat/new');
|
navigate('/chat/new');
|
||||||
setMessage('');
|
setMessage('');
|
||||||
setIsFocused(false);
|
setIsFocused(false);
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -35,18 +40,11 @@ export function ChatInput() {
|
|||||||
value={message}
|
value={message}
|
||||||
onChange={(e) => setMessage(e.target.value)}
|
onChange={(e) => setMessage(e.target.value)}
|
||||||
onFocus={() => setIsFocused(true)}
|
onFocus={() => setIsFocused(true)}
|
||||||
onBlur={() => {
|
onBlur={() => { if (!message.trim()) setIsFocused(false); }}
|
||||||
if (!message.trim()) {
|
|
||||||
setIsFocused(false);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
placeholder="Напишите ваш вопрос..."
|
placeholder="Напишите ваш вопрос..."
|
||||||
rows={isFocused ? 3 : 1}
|
rows={isFocused ? 3 : 1}
|
||||||
className="flex-1 bg-transparent resize-none outline-none py-2 px-2 max-h-32 min-h-[40px] text-base transition-all"
|
className="flex-1 bg-transparent resize-none outline-none py-2 px-2 max-h-32 min-h-[40px] text-base transition-all"
|
||||||
style={{
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
scrollbarWidth: 'none',
|
|
||||||
msOverflowStyle: 'none'
|
|
||||||
}}
|
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
if (e.key === 'Enter' && !e.shiftKey) {
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|||||||
@@ -1,12 +1,11 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { useNavigate } from 'react-router';
|
import { useNavigate } from 'react-router';
|
||||||
import {
|
import {
|
||||||
Calendar, AlertCircle, Bell, MessageCircle, Tag,
|
Calendar, AlertCircle, Bell, MessageCircle, Tag, Download,
|
||||||
X, ChevronLeft, ChevronRight, ArrowRight,
|
X, ChevronLeft, ChevronRight, ArrowRight, Smartphone, UserX,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
|
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
|
||||||
|
import { isGuest } from '../data/session';
|
||||||
// ─── Тип-конфиг ──────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
type TypeConfig = {
|
type TypeConfig = {
|
||||||
label: string;
|
label: string;
|
||||||
@@ -55,6 +54,15 @@ function getConfig(type: CompanyMessage['type']): TypeConfig {
|
|||||||
badge: 'bg-[#fff3cd] text-[#e65100]',
|
badge: 'bg-[#fff3cd] text-[#e65100]',
|
||||||
actionColor: 'text-[#e65100]',
|
actionColor: 'text-[#e65100]',
|
||||||
};
|
};
|
||||||
|
case 'install':
|
||||||
|
return {
|
||||||
|
label: 'Приложение',
|
||||||
|
icon: <Smartphone className="w-4 h-4" />,
|
||||||
|
bg: 'bg-[#ede7f6] border-[#b39ddb]',
|
||||||
|
iconWrap: 'bg-[#d1c4e9] text-[#4527a0]',
|
||||||
|
badge: 'bg-[#d1c4e9] text-[#4527a0]',
|
||||||
|
actionColor: 'text-[#4527a0]',
|
||||||
|
};
|
||||||
default:
|
default:
|
||||||
return {
|
return {
|
||||||
label: 'Новость',
|
label: 'Новость',
|
||||||
@@ -67,12 +75,53 @@ function getConfig(type: CompanyMessage['type']): TypeConfig {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Основной компонент ───────────────────────────────────────────────────────
|
// Глобальное хранение события установки PWA
|
||||||
|
let deferredPrompt: BeforeInstallPromptEvent | null = null;
|
||||||
|
|
||||||
|
interface BeforeInstallPromptEvent extends Event {
|
||||||
|
prompt: () => Promise<void>;
|
||||||
|
userChoice: Promise<{ outcome: 'accepted' | 'dismissed' }>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GuestBanner() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<button
|
||||||
|
onClick={() => navigate('/auth/consent', { state: { returnTo: '/auth/phone' } })}
|
||||||
|
className="w-full rounded-xl border border-dashed border-primary/40 bg-primary/4 px-4 py-3 flex items-start gap-3 hover:bg-primary/8 transition-colors text-left"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-center w-8 h-8 rounded-full bg-primary/10 text-primary flex-shrink-0 mt-0.5">
|
||||||
|
<UserX className="w-4 h-4" />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm font-medium text-foreground mb-0.5">Вы в гостевом режиме</p>
|
||||||
|
<p className="text-xs text-muted-foreground leading-snug">
|
||||||
|
Авторизуйтесь для получения полноценного доступа к функционалу приложения →
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Notifications() {
|
export function Notifications() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [messages, setMessages] = useState(() => getActiveMessages());
|
const [messages, setMessages] = useState(() => getActiveMessages());
|
||||||
const [index, setIndex] = useState(0);
|
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) => {
|
const handleDismiss = useCallback((id: string) => {
|
||||||
dismissMessage(id);
|
dismissMessage(id);
|
||||||
@@ -81,6 +130,24 @@ export function Notifications() {
|
|||||||
setIndex(i => Math.min(i, Math.max(next.length - 1, 0)));
|
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 <GuestBanner />;
|
||||||
|
|
||||||
if (messages.length === 0) return null;
|
if (messages.length === 0) return null;
|
||||||
|
|
||||||
const msg = messages[index];
|
const msg = messages[index];
|
||||||
@@ -90,9 +157,8 @@ export function Notifications() {
|
|||||||
function handleAction() {
|
function handleAction() {
|
||||||
if (msg.type === 'message' && msg.chatId) {
|
if (msg.type === 'message' && msg.chatId) {
|
||||||
navigate(`/chat/${msg.chatId}`);
|
navigate(`/chat/${msg.chatId}`);
|
||||||
} else if (msg.type === 'promo') {
|
} else if (msg.type === 'install') {
|
||||||
// внешний переход — в реальном приложении будет ссылка
|
handleInstall(msg.id);
|
||||||
navigate(`/notification/${msg.id}`);
|
|
||||||
} else {
|
} else {
|
||||||
navigate(`/notification/${msg.id}`);
|
navigate(`/notification/${msg.id}`);
|
||||||
}
|
}
|
||||||
@@ -100,13 +166,14 @@ export function Notifications() {
|
|||||||
|
|
||||||
const actionLabel =
|
const actionLabel =
|
||||||
msg.type === 'message' ? 'Открыть чат →' :
|
msg.type === 'message' ? 'Открыть чат →' :
|
||||||
|
msg.type === 'install' ? (installing ? 'Открываем...' : (installReady ? 'Установить →' : 'Как установить →')) :
|
||||||
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
|
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
|
||||||
'Подробнее →';
|
'Подробнее →';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="px-4 py-3">
|
<div className="px-4 py-3">
|
||||||
<div className={`rounded-xl border ${cfg.bg} overflow-hidden`}>
|
<div className={`rounded-xl border ${cfg.bg} overflow-hidden`}>
|
||||||
{/* Верхняя строка: метка + навигация + закрыть */}
|
{/* Верхняя строка */}
|
||||||
<div className="flex items-center justify-between px-3 pt-2.5 pb-2">
|
<div className="flex items-center justify-between px-3 pt-2.5 pb-2">
|
||||||
<span className={`text-[11px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full ${cfg.badge}`}>
|
<span className={`text-[11px] font-semibold uppercase tracking-wider px-2 py-0.5 rounded-full ${cfg.badge}`}>
|
||||||
{cfg.label}
|
{cfg.label}
|
||||||
@@ -151,7 +218,6 @@ export function Notifications() {
|
|||||||
<p className="font-medium text-sm text-foreground leading-snug mb-0.5">{msg.title}</p>
|
<p className="font-medium text-sm text-foreground leading-snug mb-0.5">{msg.title}</p>
|
||||||
<p className="text-xs text-muted-foreground leading-snug">{msg.description}</p>
|
<p className="text-xs text-muted-foreground leading-snug">{msg.description}</p>
|
||||||
|
|
||||||
{/* Промо-цена */}
|
|
||||||
{msg.type === 'promo' && msg.promo && (
|
{msg.type === 'promo' && msg.promo && (
|
||||||
<div className="flex items-baseline gap-1.5 mt-1.5">
|
<div className="flex items-baseline gap-1.5 mt-1.5">
|
||||||
<span className="text-base font-bold text-[#e65100]">{msg.promo.price}</span>
|
<span className="text-base font-bold text-[#e65100]">{msg.promo.price}</span>
|
||||||
@@ -163,20 +229,21 @@ export function Notifications() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Футер с действием */}
|
{/* Футер */}
|
||||||
<div className="border-t border-black/6 px-3 py-2 flex items-center justify-between">
|
<div className="border-t border-black/6 px-3 py-2 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={handleAction}
|
onClick={handleAction}
|
||||||
className={`text-xs font-semibold flex items-center gap-1 ${cfg.actionColor} hover:opacity-75 transition-opacity`}
|
disabled={installing}
|
||||||
|
className={`text-xs font-semibold flex items-center gap-1 transition-opacity disabled:opacity-50 ${cfg.actionColor} hover:opacity-75`}
|
||||||
>
|
>
|
||||||
|
{msg.type === 'install' && <Download className="w-3 h-3" />}
|
||||||
{msg.type === 'message' && <MessageCircle className="w-3 h-3" />}
|
{msg.type === 'message' && <MessageCircle className="w-3 h-3" />}
|
||||||
{msg.type === 'promo' && <ArrowRight className="w-3 h-3" />}
|
{msg.type === 'promo' && <ArrowRight className="w-3 h-3" />}
|
||||||
{actionLabel}
|
{actionLabel}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Для промо — дополнительно показываем дату */}
|
{msg.type === 'install' && !installReady && (
|
||||||
{msg.type === 'promo' && (
|
<span className="text-[11px] text-muted-foreground">iOS: через Safari → «На экран»</span>
|
||||||
<span className="text-[11px] text-muted-foreground">{msg.date}</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
export interface CompanyMessage {
|
export interface CompanyMessage {
|
||||||
id: string;
|
id: string;
|
||||||
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo';
|
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install';
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
fullContent: string;
|
fullContent: string;
|
||||||
@@ -80,6 +80,14 @@ export const companyMessages: CompanyMessage[] = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: '7',
|
id: '7',
|
||||||
|
type: 'install',
|
||||||
|
title: 'Установите приложение',
|
||||||
|
description: 'Быстрый доступ с экрана телефона без браузера',
|
||||||
|
fullContent: 'Добавьте HAN на главный экран — приложение откроется мгновенно, будет работать офлайн и присылать важные напоминания о документах.',
|
||||||
|
date: 'Сегодня',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: '9',
|
||||||
type: 'promo',
|
type: 'promo',
|
||||||
title: 'Полное оформление ВНЖ под ключ',
|
title: 'Полное оформление ВНЖ под ключ',
|
||||||
description: 'Юрист сам подаст документы — вам только расписаться',
|
description: 'Юрист сам подаст документы — вам только расписаться',
|
||||||
|
|||||||
@@ -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';
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onChange}
|
||||||
|
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||||
|
checked
|
||||||
|
? 'bg-primary border-primary'
|
||||||
|
: 'border-border bg-background hover:border-primary/50'
|
||||||
|
}`}
|
||||||
|
aria-checked={checked}
|
||||||
|
role="checkbox"
|
||||||
|
>
|
||||||
|
{checked && <Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AuthConsent() {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
const returnTo = (location.state as { returnTo?: string })?.returnTo ?? '/auth/phone';
|
||||||
|
|
||||||
|
const [checked, setChecked] = useState<Record<string, boolean>>({
|
||||||
|
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 (
|
||||||
|
<div className="flex flex-col h-full bg-background">
|
||||||
|
<div className="flex flex-col h-full px-6 pt-14 pb-8">
|
||||||
|
|
||||||
|
{/* Иконка + заголовок */}
|
||||||
|
<div className="mb-8">
|
||||||
|
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center mb-5">
|
||||||
|
<ShieldCheck className="w-6 h-6 text-primary" />
|
||||||
|
</div>
|
||||||
|
<h1 className="text-2xl font-semibold text-foreground mb-2">
|
||||||
|
Перед началом работы
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||||
|
Для использования приложения ознакомьтесь со следующими документами и предоставьте необходимые согласия
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Форма согласий */}
|
||||||
|
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
|
||||||
|
<div className="space-y-4 flex-1">
|
||||||
|
{consents.map(consent => (
|
||||||
|
<div
|
||||||
|
key={consent.id}
|
||||||
|
className={`rounded-xl border p-4 transition-colors cursor-pointer ${
|
||||||
|
checked[consent.id]
|
||||||
|
? 'border-primary/30 bg-primary/4'
|
||||||
|
: 'border-border bg-card'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggle(consent.id)}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="mt-0.5">
|
||||||
|
<Checkbox checked={checked[consent.id]} onChange={() => toggle(consent.id)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<p className="text-sm text-foreground leading-relaxed mb-2">
|
||||||
|
{consent.text}
|
||||||
|
{consent.required && (
|
||||||
|
<span className="text-destructive ml-1">*</span>
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
{/* Ссылки на документы */}
|
||||||
|
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||||
|
{consent.links.map(link => (
|
||||||
|
<a
|
||||||
|
key={link.href}
|
||||||
|
href={link.href}
|
||||||
|
onClick={e => e.stopPropagation()}
|
||||||
|
className="inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 hover:opacity-75 transition-opacity"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
>
|
||||||
|
<ExternalLink className="w-3 h-3" />
|
||||||
|
{link.label}
|
||||||
|
</a>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<p className="text-xs text-muted-foreground px-1">
|
||||||
|
<span className="text-destructive">*</span> — обязательные согласия
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Кнопка */}
|
||||||
|
<div className="pt-6">
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={!requiredDone}
|
||||||
|
className="w-full h-14 bg-primary text-primary-foreground rounded-xl font-medium flex items-center justify-center gap-2.5 transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:bg-primary/90 active:scale-[0.98]"
|
||||||
|
>
|
||||||
|
Продолжить
|
||||||
|
<ArrowRight className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { useNavigate, useLocation } from 'react-router';
|
import { useNavigate, useLocation } from 'react-router';
|
||||||
|
import { setAuthStatus } from '../data/session';
|
||||||
|
|
||||||
export function AuthLoading() {
|
export function AuthLoading() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -8,7 +9,7 @@ export function AuthLoading() {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Имитируем авторизацию — через 2.8 секунды переходим на главную
|
// Имитируем авторизацию — через 2.8 секунды переходим на главную
|
||||||
const t = setTimeout(() => navigate('/'), 2800);
|
const t = setTimeout(() => { setAuthStatus('authenticated'); navigate('/'); }, 2800);
|
||||||
return () => clearTimeout(t);
|
return () => clearTimeout(t);
|
||||||
}, [navigate]);
|
}, [navigate]);
|
||||||
|
|
||||||
|
|||||||
@@ -88,15 +88,6 @@ export function AuthPhone() {
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Низ страницы */}
|
|
||||||
<div className="text-center">
|
|
||||||
<p className="text-xs text-muted-foreground leading-relaxed">
|
|
||||||
Нажимая «Получить код», вы соглашаетесь с{' '}
|
|
||||||
<span className="text-foreground/70 underline underline-offset-2">условиями использования</span>
|
|
||||||
{' '}и{' '}
|
|
||||||
<span className="text-foreground/70 underline underline-offset-2">политикой конфиденциальности</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router';
|
||||||
import { getActiveMessages, CompanyMessage } from '../data/companyMessages';
|
import { getActiveMessages, CompanyMessage } from '../data/companyMessages';
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ function getIcon(type: CompanyMessage['type']) {
|
|||||||
if (type === 'reminder') return <Calendar className="w-4 h-4" />;
|
if (type === 'reminder') return <Calendar className="w-4 h-4" />;
|
||||||
if (type === 'message') return <MessageCircle className="w-4 h-4" />;
|
if (type === 'message') return <MessageCircle className="w-4 h-4" />;
|
||||||
if (type === 'promo') return <Tag className="w-4 h-4" />;
|
if (type === 'promo') return <Tag className="w-4 h-4" />;
|
||||||
|
if (type === 'install') return <Smartphone className="w-4 h-4" />;
|
||||||
return <Bell className="w-4 h-4" />;
|
return <Bell className="w-4 h-4" />;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -15,6 +16,7 @@ function typeColors(type: CompanyMessage['type']) {
|
|||||||
if (type === 'reminder') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' };
|
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 === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
|
||||||
if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' };
|
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' };
|
return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { NotificationDetail } from './pages/NotificationDetail';
|
|||||||
import { AuthPhone } from './pages/AuthPhone';
|
import { AuthPhone } from './pages/AuthPhone';
|
||||||
import { AuthOtp } from './pages/AuthOtp';
|
import { AuthOtp } from './pages/AuthOtp';
|
||||||
import { AuthLoading } from './pages/AuthLoading';
|
import { AuthLoading } from './pages/AuthLoading';
|
||||||
|
import { AuthConsent } from './pages/AuthConsent';
|
||||||
|
|
||||||
export const router = createBrowserRouter([
|
export const router = createBrowserRouter([
|
||||||
{
|
{
|
||||||
@@ -21,6 +22,7 @@ export const router = createBrowserRouter([
|
|||||||
{ path: 'chat/:id', Component: Chat },
|
{ path: 'chat/:id', Component: Chat },
|
||||||
{ path: 'calendar', Component: Calendar },
|
{ path: 'calendar', Component: Calendar },
|
||||||
{ path: 'notification/:id', Component: NotificationDetail },
|
{ path: 'notification/:id', Component: NotificationDetail },
|
||||||
|
{ path: 'auth/consent', Component: AuthConsent },
|
||||||
{ path: 'auth/phone', Component: AuthPhone },
|
{ path: 'auth/phone', Component: AuthPhone },
|
||||||
{ path: 'auth/otp', Component: AuthOtp },
|
{ path: 'auth/otp', Component: AuthOtp },
|
||||||
{ path: 'auth/loading', Component: AuthLoading },
|
{ path: 'auth/loading', Component: AuthLoading },
|
||||||
|
|||||||
@@ -241,9 +241,14 @@ Pydantic `422` преобразуется в `400 validation_error`, чтобы
|
|||||||
"auth": {"phone_enabled": true, "password_enabled": false},
|
"auth": {"phone_enabled": true, "password_enabled": false},
|
||||||
"operator": {"call_phone": "+74999591007"},
|
"operator": {"call_phone": "+74999591007"},
|
||||||
"consents": {
|
"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"},
|
"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": {
|
"attachments": {
|
||||||
"allowed_extensions": ["jpg", "jpeg", "png", "webp", "heic", "heif", "pdf"],
|
"allowed_extensions": ["jpg", "jpeg", "png", "webp", "heic", "heif", "pdf"],
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ Auth state machine: `guest → authorizing → bootstrapping → authenticated`;
|
|||||||
|
|
||||||
### 5.2. Согласия и OTP
|
### 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.
|
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.
|
||||||
|
|
||||||
|
|||||||
+145
-5
@@ -1,4 +1,6 @@
|
|||||||
На ВМ выполните:
|
# Отправка СМС
|
||||||
|
|
||||||
|
## На ВМ выполните:
|
||||||
|
|
||||||
cd /opt/han-chat/backend
|
cd /opt/han-chat/backend
|
||||||
umask 077
|
umask 077
|
||||||
@@ -46,7 +48,7 @@ with open(sys.argv[1], "w", encoding="utf-8") as file:
|
|||||||
PY
|
PY
|
||||||
|
|
||||||
|
|
||||||
Создайте функцию отправки:
|
## Создайте функцию отправки:
|
||||||
|
|
||||||
send_sms_smoke() {
|
send_sms_smoke() {
|
||||||
docker compose --env-file .env --profile ops run --rm --no-deps \
|
docker compose --env-file .env --profile ops run --rm --no-deps \
|
||||||
@@ -66,13 +68,13 @@ send_sms_smoke() {
|
|||||||
'
|
'
|
||||||
}
|
}
|
||||||
|
|
||||||
Отправка:
|
## Отправка:
|
||||||
send_sms_smoke
|
send_sms_smoke
|
||||||
|
|
||||||
Ожидается:
|
Ожидается:
|
||||||
HTTP 202 и JSON с sms_message_id.
|
HTTP 202 и JSON с sms_message_id.
|
||||||
|
|
||||||
Проверьте журнал:
|
## Проверьте журнал:
|
||||||
SELECT
|
SELECT
|
||||||
id,
|
id,
|
||||||
phone_masked,
|
phone_masked,
|
||||||
@@ -86,6 +88,144 @@ FROM sms.sms_outbound_message
|
|||||||
ORDER BY created_at DESC
|
ORDER BY created_at DESC
|
||||||
LIMIT 5;
|
LIMIT 5;
|
||||||
|
|
||||||
После проверки удалите секретные данные:
|
## После проверки удалите секретные данные:
|
||||||
shred -u "$REQUEST_FILE" 2>/dev/null || rm -f "$REQUEST_FILE"
|
shred -u "$REQUEST_FILE" 2>/dev/null || rm -f "$REQUEST_FILE"
|
||||||
unset SMS_TOKEN OTP_CODE TEST_PHONE CHALLENGE_ID REQUEST_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.
|
||||||
@@ -8,6 +8,9 @@
|
|||||||
- (без спец.символов) openssl rand -hex 16
|
- (без спец.символов) openssl rand -hex 16
|
||||||
- openssl rand -base64 16 | xclip -selection clipboard # Linux
|
- 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
|
Туннель до БД: 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/ \
|
/mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \
|
||||||
root@135.106.164.58:/opt/han-chat/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
|
cd /opt/han-chat/backend
|
||||||
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
|
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
|
chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh
|
||||||
|
|||||||
Reference in New Issue
Block a user