Реализован интерфейс согласий

This commit is contained in:
mi
2026-07-23 14:29:16 +03:00
parent b1ed714d5b
commit 31efbf3b69
27 changed files with 817 additions and 110 deletions
+1 -1
View File
@@ -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 в безопасном хранилище после авторизации;
+3 -1
View File
@@ -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
View File
@@ -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")
+16 -6
View File
@@ -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": {
name: { "personal_data": {
"required": settings.boolean(f"consent.{name}.required"), "required": settings.boolean("consent.personal_data.required"),
"document_url": values.get(f"consent.{name}.document_url"), "document_url": values.get("consent.personal_data.document_url"),
"version": values[f"consent.{name}.version"], "privacy_policy_document_url": values.get(
} "consent.privacy_policy.document_url"
for name in ("personal_data", "user_agreement", "marketing") ),
"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": { "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) => { setConsentOpen(false);
const item = config.data?.consents?.[key]; setPending(null);
if (!item) return null; clearPendingTextIntent();
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);
setPending(null);
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.
+12 -14
View File
@@ -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;
// Переход в чат при отправке сообщения
navigate('/chat/new'); if (isGuest()) {
setMessage(''); navigate('/auth/consent', { state: { returnTo: '/auth/phone' } });
setIsFocused(false); return;
} }
navigate('/chat/new');
setMessage('');
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();
+83 -16
View File
@@ -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>
+9 -1
View File
@@ -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: 'Юрист сам подаст документы — вам только расписаться',
+20
View File
@@ -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';
}
+162
View File
@@ -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>
);
}
+2 -1
View File
@@ -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]);
-9
View File
@@ -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>
); );
+3 -1
View File
@@ -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' };
} }
+2
View File
@@ -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 },
+7 -2
View File
@@ -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"],
+1 -1
View File
@@ -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.
+146 -6
View File
@@ -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.
+6
View File
@@ -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