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

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;
- **управление аналитической UX-сессией** на клиенте: после получения JWT — `session_start`, хранение `ux_session_id` и `last_activity_at` **только в памяти**, заголовок `X-Ux-Session-Id` в JWT-запросах;
- хранение access token и refresh token в безопасном хранилище после авторизации;
+3 -1
View File
@@ -88,7 +88,7 @@ Managed PostgreSQL **поднимается до** развёртывания п
| Auth | `auth.phone.enabled`, `auth.password.enabled` |
| OTP (продукт; потребитель — Keycloak SPI через settings bridge api-backend) | `otp.phone.max_send_attempts_per_24h`, `otp.phone.min_seconds_between_attempts`, `otp.phone.max_verify_attempts`, `otp.phone.code_length`, `otp.phone.ttl_seconds`, `otp.phone.sms_order_timeout_ms` |
| Оператор | `operator.call.phone` |
| Consent | `consent.personal_data.*`, `consent.user_agreement.*`, `consent.marketing.*` |
| Consent | `consent.personal_data.*`, `consent.privacy_policy.document_url`, `consent.user_agreement.*`, `consent.marketing.*` |
| Файлы чата | `chat.attachments.*` |
| Rate limits (app) | `rate_limit.message_send.*`, `rate_limit.download_url.*`, `rate_limit.public_endpoints.*`, `rate_limit.login.*` |
| UX | `ux.session.idle_timeout_minutes` |
@@ -112,10 +112,12 @@ operator.call.phone=+74999591007
consent.personal_data.required=true
consent.personal_data.document_url=https://www.han0107.ru/privacy/persdata-agree-mobile
consent.personal_data.version=2026-06-10
consent.privacy_policy.document_url=https://www.han0107.ru/privacy
consent.user_agreement.required=true
consent.user_agreement.document_url=https://www.han0107.ru/user-agreement
consent.user_agreement.version=2026-06-10
consent.marketing.required=false
consent.marketing.document_url=https://www.han0107.ru/privacy/ads-agree
consent.marketing.version=2026-06-10
chat.attachments.allowed_extensions=jpg,jpeg,png,webp,heic,heif,pdf
+2 -2
View File
@@ -15,13 +15,13 @@
14. Проверить повторную отправку СМС (меня перенесло на главный экран)
15. При выходе из профиля надо бы сбрасывать cookies Keycloack (Классический OIDC front-channel logout (redirect на end-session → браузер сам сбрасывает cookies Keycloak))
16. Сделать тестового пользователя с фиксированным СМС-входом
17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)
~~17. Формы согласий поправить (Согласие на обработку ПД + Политика, Пользовательское соглашение, Реклама)~~
~~18. При повторном запросе OTP кода при авторизации не нужно указывать ошибку "Новый код заказан. Предыдущий код больше не действует."~~
На будущее (после доработки отдельных функциональностей):
1. Разработка message-safety
2. Разработка sync-service
3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.
~~3. Интеграция с СМС-провайдером — спецификация и план rollout зафиксированы в `modules/module-11-idgtl-sms.md`; пункт не закрыт до реализации `sms-service`/worker, Keycloak lifecycle, schema `sms`, callback/nginx, env validation, observability и общего DoD. Production prerequisites: согласованные sender/template, Direct `TOKEN_1`, callback credentials/подтверждённый source IP и статический egress IP.~~
3. Определение итогового перечня мнемоник, перевод фронтенда на мнемоники, seed заливка мнемоник в БД (?)
4. Моделирование профиля клиента/
5. Моделирование уведомлений.
@@ -30,6 +30,11 @@ SEED = {
True,
),
"consent.personal_data.version": ("2026-06-10", "string", True),
"consent.privacy_policy.document_url": (
"https://www.han0107.ru/privacy",
"string",
True,
),
"consent.user_agreement.required": ("true", "boolean", True),
"consent.user_agreement.document_url": (
"https://www.han0107.ru/user-agreement",
@@ -38,6 +43,11 @@ SEED = {
),
"consent.user_agreement.version": ("2026-06-10", "string", True),
"consent.marketing.required": ("false", "boolean", True),
"consent.marketing.document_url": (
"https://www.han0107.ru/privacy/ads-agree",
"string",
True,
),
"consent.marketing.version": ("2026-06-10", "string", True),
"chat.attachments.allowed_extensions": (
"jpg,jpeg,png,webp,heic,heif,pdf",
@@ -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"]},
"consents": {
name: {
"required": settings.boolean(f"consent.{name}.required"),
"document_url": values.get(f"consent.{name}.document_url"),
"version": values[f"consent.{name}.version"],
}
for name in ("personal_data", "user_agreement", "marketing")
"personal_data": {
"required": settings.boolean("consent.personal_data.required"),
"document_url": values.get("consent.personal_data.document_url"),
"privacy_policy_document_url": values.get(
"consent.privacy_policy.document_url"
),
"version": values["consent.personal_data.version"],
},
**{
name: {
"required": settings.boolean(f"consent.{name}.required"),
"document_url": values.get(f"consent.{name}.document_url"),
"version": values[f"consent.{name}.version"],
}
for name in ("user_agreement", "marketing")
},
},
"attachments": {
"allowed_extensions": settings.strings("chat.attachments.allowed_extensions"),
@@ -69,10 +69,12 @@ REQUIRED_SETTINGS = {
"consent.personal_data.required",
"consent.personal_data.document_url",
"consent.personal_data.version",
"consent.privacy_policy.document_url",
"consent.user_agreement.required",
"consent.user_agreement.document_url",
"consent.user_agreement.version",
"consent.marketing.required",
"consent.marketing.document_url",
"consent.marketing.version",
"chat.attachments.allowed_extensions",
"chat.attachments.allowed_mime_types",
@@ -12,10 +12,12 @@ settings:
consent.personal_data.required: {type: boolean, value: true, public: true}
consent.personal_data.document_url: {type: string, value: "https://www.han0107.ru/privacy/persdata-agree-mobile", public: true}
consent.personal_data.version: {type: string, value: "2026-06-10", public: true}
consent.privacy_policy.document_url: {type: string, value: "https://www.han0107.ru/privacy", public: true}
consent.user_agreement.required: {type: boolean, value: true, public: true}
consent.user_agreement.document_url: {type: string, value: "https://www.han0107.ru/user-agreement", public: true}
consent.user_agreement.version: {type: string, value: "2026-06-10", public: true}
consent.marketing.required: {type: boolean, value: false, public: true}
consent.marketing.document_url: {type: string, value: "https://www.han0107.ru/privacy/ads-agree", public: true}
consent.marketing.version: {type: string, value: "2026-06-10", public: true}
chat.attachments.allowed_extensions: {type: string_list, value: "jpg,jpeg,png,webp,heic,heif,pdf", public: true}
chat.attachments.allowed_mime_types: {type: string_list, value: "image/jpeg,image/png,image/webp,image/heic,image/heif,application/pdf", public: true}
@@ -1,10 +1,11 @@
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { Linking, ScrollView, Switch, Text, View } from "react-native";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../src/app-context";
import { AppHeader } from "../src/components/AppHeader";
import { ChatInputBar } from "../src/components/ChatInputBar";
import { ConsentModal } from "../src/components/ConsentModal";
import { HanLogo } from "../src/components/HanLogo";
import { PopularQuestionsList } from "../src/components/PopularQuestionsList";
import { QuickActions } from "../src/components/QuickActions";
@@ -17,14 +18,13 @@ import {
} from "../src/pending-intent";
import { dialogApi, publicApi, uploadAttachment } from "../src/services";
import type { Consents } from "../src/types";
import { Button, ErrorNotice, Loading, styles } from "../src/ui";
import { ErrorNotice, Loading, styles } from "../src/ui";
export default function HomeScreen() {
const { authStatus, authorize } = useApp();
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const content = useQuery({ queryKey: ["public-content"], queryFn: publicApi.content });
const [consentOpen, setConsentOpen] = useState(false);
const [required, setRequired] = useState({ personal: false, agreement: false, marketing: false });
const [pending, setPending] = useState<PendingTextIntent | null>(null);
const [message, setMessage] = useState("");
const [sendError, setSendError] = useState<unknown>();
@@ -111,13 +111,16 @@ export default function HomeScreen() {
}
};
const accept = async () => {
if (!required.personal || !required.agreement) return;
const accept = async (accepted: {
personal_data: boolean;
user_agreement: boolean;
marketing: boolean;
}) => {
const versions = config.data?.consents;
const consents: Consents = {
personal_data: { accepted: true, version: versions?.personal_data?.version ?? "current" },
user_agreement: { accepted: true, version: versions?.user_agreement?.version ?? "current" },
marketing: { accepted: required.marketing, version: versions?.marketing?.version ?? "current" },
personal_data: { accepted: accepted.personal_data, version: versions?.personal_data?.version ?? "current" },
user_agreement: { accepted: accepted.user_agreement, version: versions?.user_agreement?.version ?? "current" },
marketing: { accepted: accepted.marketing, version: versions?.marketing?.version ?? "current" },
};
setConsentOpen(false);
try {
@@ -165,46 +168,18 @@ export default function HomeScreen() {
</View>
{consentOpen && (
<View accessibilityViewIsModal style={styles.modalBackdrop}>
<View style={styles.modal}>
<Text accessibilityRole="header" style={styles.heading}>Согласия перед входом</Text>
<Text style={styles.text}>Для отправки сообщения или файла необходимо войти по номеру телефона. Код вводится только на защищённой странице авторизации.</Text>
{(["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);
setPending(null);
clearPendingTextIntent();
}}
/>
</View>
</View>
</View>
<ConsentModal
consents={config.data?.consents}
onAccept={(accepted) => void accept(accepted)}
onCancel={() => {
setConsentOpen(false);
setPending(null);
clearPendingTextIntent();
}}
/>
)}
</ScreenShell>
);
}
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, {
required: boolean;
document_url: string | null;
privacy_policy_document_url?: string | null;
version: string;
}>;
attachments: {
@@ -8,7 +8,12 @@ test.beforeEach(async ({ page }) => {
ux: { idle_timeout_minutes: 15 },
attachments: { max_size_mb: 5, allowed_extensions: ["png", "pdf"], allowed_mime_types: ["image/png", "application/pdf"] },
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" },
marketing: { required: false, version: "2026-07-01", document_url: "https://example.test/marketing" },
},
@@ -49,7 +54,9 @@ test("первое сообщение требует обязательные с
await page.goto("/");
await page.getByLabel("Сообщение").fill("Здравствуйте");
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();
});
@@ -57,7 +64,7 @@ test("Enter отправляет введённое сообщение", async (
await page.goto("/");
await page.getByLabel("Сообщение").fill("Отправка с клавиатуры");
await page.getByLabel("Сообщение").press("Enter");
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
await expect(page.getByRole("heading", { name: "Перед началом работы" })).toBeVisible();
});
test("сообщение ограничено 4000 символами", async ({ page }) => {
@@ -65,7 +72,7 @@ test("сообщение ограничено 4000 символами", async ({
await page.getByLabel("Сообщение").fill("а".repeat(4001));
await page.getByRole("button", { name: "Отправить" }).click();
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 }) => {
Binary file not shown.
+12 -14
View File
@@ -1,6 +1,7 @@
import { Mic, Paperclip, Send } from 'lucide-react';
import { useState } from 'react';
import { useNavigate } from 'react-router';
import { isGuest } from '../data/session';
export function ChatInput() {
const [message, setMessage] = useState('');
@@ -9,12 +10,16 @@ export function ChatInput() {
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (message.trim()) {
// Переход в чат при отправке сообщения
navigate('/chat/new');
setMessage('');
setIsFocused(false);
if (!message.trim()) return;
if (isGuest()) {
navigate('/auth/consent', { state: { returnTo: '/auth/phone' } });
return;
}
navigate('/chat/new');
setMessage('');
setIsFocused(false);
};
return (
@@ -35,18 +40,11 @@ export function ChatInput() {
value={message}
onChange={(e) => setMessage(e.target.value)}
onFocus={() => setIsFocused(true)}
onBlur={() => {
if (!message.trim()) {
setIsFocused(false);
}
}}
onBlur={() => { if (!message.trim()) setIsFocused(false); }}
placeholder="Напишите ваш вопрос..."
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"
style={{
scrollbarWidth: 'none',
msOverflowStyle: 'none'
}}
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
onKeyDown={(e) => {
if (e.key === 'Enter' && !e.shiftKey) {
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 {
Calendar, AlertCircle, Bell, MessageCircle, Tag,
X, ChevronLeft, ChevronRight, ArrowRight,
Calendar, AlertCircle, Bell, MessageCircle, Tag, Download,
X, ChevronLeft, ChevronRight, ArrowRight, Smartphone, UserX,
} from 'lucide-react';
import { getActiveMessages, dismissMessage, CompanyMessage } from '../data/companyMessages';
// ─── Тип-конфиг ──────────────────────────────────────────────────────────────
import { isGuest } from '../data/session';
type TypeConfig = {
label: string;
@@ -55,6 +54,15 @@ function getConfig(type: CompanyMessage['type']): TypeConfig {
badge: 'bg-[#fff3cd] 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:
return {
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() {
const navigate = useNavigate();
const [messages, setMessages] = useState(() => getActiveMessages());
const [index, setIndex] = useState(0);
const [installReady, setInstallReady] = useState(false);
const [installing, setInstalling] = useState(false);
// Перехватываем beforeinstallprompt
useEffect(() => {
const handler = (e: Event) => {
e.preventDefault();
deferredPrompt = e as BeforeInstallPromptEvent;
setInstallReady(true);
};
window.addEventListener('beforeinstallprompt', handler);
return () => window.removeEventListener('beforeinstallprompt', handler);
}, []);
const handleDismiss = useCallback((id: string) => {
dismissMessage(id);
@@ -81,6 +130,24 @@ export function Notifications() {
setIndex(i => Math.min(i, Math.max(next.length - 1, 0)));
}, []);
async function handleInstall(msgId: string) {
if (!deferredPrompt) {
// На десктопе или если prompt недоступен — просто закрываем
handleDismiss(msgId);
return;
}
setInstalling(true);
await deferredPrompt.prompt();
const { outcome } = await deferredPrompt.userChoice;
deferredPrompt = null;
setInstalling(false);
if (outcome === 'accepted') {
handleDismiss(msgId);
}
}
if (isGuest()) return <GuestBanner />;
if (messages.length === 0) return null;
const msg = messages[index];
@@ -90,9 +157,8 @@ export function Notifications() {
function handleAction() {
if (msg.type === 'message' && msg.chatId) {
navigate(`/chat/${msg.chatId}`);
} else if (msg.type === 'promo') {
// внешний переход — в реальном приложении будет ссылка
navigate(`/notification/${msg.id}`);
} else if (msg.type === 'install') {
handleInstall(msg.id);
} else {
navigate(`/notification/${msg.id}`);
}
@@ -100,13 +166,14 @@ export function Notifications() {
const actionLabel =
msg.type === 'message' ? 'Открыть чат →' :
msg.type === 'install' ? (installing ? 'Открываем...' : (installReady ? 'Установить →' : 'Как установить →')) :
msg.type === 'promo' ? (msg.promo?.cta ?? 'Подробнее') + ' →' :
'Подробнее →';
return (
<div className="px-4 py-3">
<div className={`rounded-xl border ${cfg.bg} overflow-hidden`}>
{/* Верхняя строка: метка + навигация + закрыть */}
{/* Верхняя строка */}
<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}`}>
{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="text-xs text-muted-foreground leading-snug">{msg.description}</p>
{/* Промо-цена */}
{msg.type === 'promo' && msg.promo && (
<div className="flex items-baseline gap-1.5 mt-1.5">
<span className="text-base font-bold text-[#e65100]">{msg.promo.price}</span>
@@ -163,20 +229,21 @@ export function Notifications() {
</div>
</div>
{/* Футер с действием */}
{/* Футер */}
<div className="border-t border-black/6 px-3 py-2 flex items-center justify-between">
<button
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 === 'promo' && <ArrowRight className="w-3 h-3" />}
{actionLabel}
</button>
{/* Для промо — дополнительно показываем дату */}
{msg.type === 'promo' && (
<span className="text-[11px] text-muted-foreground">{msg.date}</span>
{msg.type === 'install' && !installReady && (
<span className="text-[11px] text-muted-foreground">iOS: через Safari «На экран»</span>
)}
</div>
</div>
+9 -1
View File
@@ -1,6 +1,6 @@
export interface CompanyMessage {
id: string;
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo';
type: 'urgent' | 'reminder' | 'info' | 'message' | 'promo' | 'install';
title: string;
description: string;
fullContent: string;
@@ -80,6 +80,14 @@ export const companyMessages: CompanyMessage[] = [
},
{
id: '7',
type: 'install',
title: 'Установите приложение',
description: 'Быстрый доступ с экрана телефона без браузера',
fullContent: 'Добавьте HAN на главный экран — приложение откроется мгновенно, будет работать офлайн и присылать важные напоминания о документах.',
date: 'Сегодня',
},
{
id: '9',
type: 'promo',
title: 'Полное оформление ВНЖ под ключ',
description: 'Юрист сам подаст документы — вам только расписаться',
+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 { useNavigate, useLocation } from 'react-router';
import { setAuthStatus } from '../data/session';
export function AuthLoading() {
const navigate = useNavigate();
@@ -8,7 +9,7 @@ export function AuthLoading() {
useEffect(() => {
// Имитируем авторизацию — через 2.8 секунды переходим на главную
const t = setTimeout(() => navigate('/'), 2800);
const t = setTimeout(() => { setAuthStatus('authenticated'); navigate('/'); }, 2800);
return () => clearTimeout(t);
}, [navigate]);
-9
View File
@@ -88,15 +88,6 @@ export function AuthPhone() {
</form>
</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>
);
+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 { 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 === 'message') return <MessageCircle 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" />;
}
@@ -15,6 +16,7 @@ function typeColors(type: CompanyMessage['type']) {
if (type === 'reminder') return { icon: 'bg-primary/10 text-primary', dot: 'bg-primary' };
if (type === 'message') return { icon: 'bg-[#c8e6c9] text-[#2e7d32]', dot: 'bg-[#43a047]' };
if (type === 'promo') return { icon: 'bg-[#fff3cd] text-[#e65100]', dot: 'bg-[#fb8c00]' };
if (type === 'install') return { icon: 'bg-[#d1c4e9] text-[#4527a0]', dot: 'bg-[#7c4dff]' };
return { icon: 'bg-muted text-muted-foreground', dot: 'bg-muted-foreground' };
}
+2
View File
@@ -9,6 +9,7 @@ import { NotificationDetail } from './pages/NotificationDetail';
import { AuthPhone } from './pages/AuthPhone';
import { AuthOtp } from './pages/AuthOtp';
import { AuthLoading } from './pages/AuthLoading';
import { AuthConsent } from './pages/AuthConsent';
export const router = createBrowserRouter([
{
@@ -21,6 +22,7 @@ export const router = createBrowserRouter([
{ path: 'chat/:id', Component: Chat },
{ path: 'calendar', Component: Calendar },
{ path: 'notification/:id', Component: NotificationDetail },
{ path: 'auth/consent', Component: AuthConsent },
{ path: 'auth/phone', Component: AuthPhone },
{ path: 'auth/otp', Component: AuthOtp },
{ path: 'auth/loading', Component: AuthLoading },
+7 -2
View File
@@ -241,9 +241,14 @@ Pydantic `422` преобразуется в `400 validation_error`, чтобы
"auth": {"phone_enabled": true, "password_enabled": false},
"operator": {"call_phone": "+74999591007"},
"consents": {
"personal_data": {"required": true, "document_url": "https://...", "version": "2026-06-10"},
"personal_data": {
"required": true,
"document_url": "https://www.han0107.ru/privacy/persdata-agree-mobile",
"privacy_policy_document_url": "https://www.han0107.ru/privacy",
"version": "2026-06-10"
},
"user_agreement": {"required": true, "document_url": "https://...", "version": "2026-06-10"},
"marketing": {"required": false, "document_url": null, "version": "2026-06-10"}
"marketing": {"required": false, "document_url": "https://www.han0107.ru/privacy/ads-agree", "version": "2026-06-10"}
},
"attachments": {
"allowed_extensions": ["jpg", "jpeg", "png", "webp", "heic", "heif", "pdf"],
+1 -1
View File
@@ -74,7 +74,7 @@ Auth state machine: `guest → authorizing → bootstrapping → authenticated`;
### 5.2. Согласия и OTP
Modal согласий отображает актуальные URL/версии из config. `personal_data` и `user_agreement` обязательны, `marketing` необязателен. После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.
Modal согласий (макет AuthConsent) показывает три блока. В блоке `personal_data` — две ссылки: `document_url` (согласие на обработку ПД) и `privacy_policy_document_url` (политика ПД из `consent.privacy_policy.document_url`). В блоках `user_agreement` и `marketing` — по одной ссылке из `document_url` (`consent.marketing.document_url` для рекламы). Обязательность берётся из `consent.*.required` (`personal_data`/`user_agreement` обычно обязательны, `marketing` — нет). После подтверждения intent остаётся в памяти, начинается OIDC PKCE redirect.
OTP вводится на странице/теме Keycloak. В mock mode Keycloak сверяет secret-код; в real mode Keycloak генерирует и локально проверяет OTP, а доставку заказывает в `sms-service` по module-11. Frontend не вызывает `sms-service`/Direct, не получает provider status, service URL/token или mock secret.
+146 -6
View File
@@ -1,4 +1,6 @@
На ВМ выполните:
# Отправка СМС
## На ВМ выполните:
cd /opt/han-chat/backend
umask 077
@@ -46,7 +48,7 @@ with open(sys.argv[1], "w", encoding="utf-8") as file:
PY
Создайте функцию отправки:
## Создайте функцию отправки:
send_sms_smoke() {
docker compose --env-file .env --profile ops run --rm --no-deps \
@@ -66,13 +68,13 @@ send_sms_smoke() {
'
}
Отправка:
## Отправка:
send_sms_smoke
Ожидается:
HTTP 202 и JSON с sms_message_id.
Проверьте журнал:
## Проверьте журнал:
SELECT
id,
phone_masked,
@@ -86,6 +88,144 @@ FROM sms.sms_outbound_message
ORDER BY created_at DESC
LIMIT 5;
После проверки удалите секретные данные:
## После проверки удалите секретные данные:
shred -u "$REQUEST_FILE" 2>/dev/null || rm -f "$REQUEST_FILE"
unset SMS_TOKEN OTP_CODE TEST_PHONE CHALLENGE_ID REQUEST_FILE
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 -base64 16 | xclip -selection clipboard # Linux
Посмотреть состояние контейнеров
docker compose ps --format "table {{.Service}}\t{{.Status}}\t{{.Ports}}"
Туннель до БД: ssh -i C:\Users\MI\.ssh\hansel -L 5433:192.168.0.211:5432 root@135.106.164.58 -N
#Обновление проекта
@@ -49,6 +52,9 @@ rsync -rltD --no-perms --no-owner --no-group -ivc --delete \
/mnt/c/Users/MI/Documents/Assistent/HAN_chat_specification/codebase/backend/ \
root@135.106.164.58:/opt/han-chat/backend/
3. Копирование env (опционально)
scp -i C:\Users\MI\.ssh\hansel -r "C:\Users\MI\Documents\job\HAN_new_life\HANapp\Production\.env" root@135.106.164.58:/opt/han-chat/backend
cd /opt/han-chat/backend
find . -type f \( -name '*.sh' -o -name 'validate-env' \) -exec dos2unix {} +
chmod +x scripts/validate-env deployment/scripts/*.sh redis/scripts/*.sh nginx/scripts/*.sh