Реализован интерфейс согласий
This commit is contained in:
@@ -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 в безопасном хранилище после авторизации;
|
||||
|
||||
@@ -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
@@ -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")
|
||||
@@ -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.
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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: 'Юрист сам подаст документы — вам только расписаться',
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
export type AuthStatus = 'authenticated' | 'guest' | null;
|
||||
|
||||
const KEY = 'han_auth_status';
|
||||
|
||||
export function getAuthStatus(): AuthStatus {
|
||||
return (localStorage.getItem(KEY) as AuthStatus) ?? null;
|
||||
}
|
||||
|
||||
export function setAuthStatus(status: AuthStatus) {
|
||||
if (status === null) localStorage.removeItem(KEY);
|
||||
else localStorage.setItem(KEY, status);
|
||||
}
|
||||
|
||||
export function isGuest() {
|
||||
return getAuthStatus() === 'guest';
|
||||
}
|
||||
|
||||
export function isAuthenticated() {
|
||||
return getAuthStatus() === 'authenticated';
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router';
|
||||
import { ArrowRight, Check, ExternalLink, ShieldCheck } from 'lucide-react';
|
||||
|
||||
interface ConsentLink {
|
||||
label: string;
|
||||
href: string;
|
||||
}
|
||||
|
||||
interface ConsentItem {
|
||||
id: string;
|
||||
required: boolean;
|
||||
text: string;
|
||||
links: ConsentLink[];
|
||||
}
|
||||
|
||||
const consents: ConsentItem[] = [
|
||||
{
|
||||
id: 'pdp',
|
||||
required: true,
|
||||
text: 'Я ознакомлен с Политикой обработки персональных данных ООО «ХАН» и даю своё Согласие на обработку моих персональных данных',
|
||||
links: [
|
||||
{ label: 'Согласие на обработку ПД', href: '#pdp-consent' },
|
||||
{ label: 'Политика обработки ПД', href: '#pdp-policy' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'terms',
|
||||
required: true,
|
||||
text: 'Я прочитал и соглашаюсь с Пользовательским соглашением',
|
||||
links: [{ label: 'Пользовательское соглашение', href: '#terms' }],
|
||||
},
|
||||
{
|
||||
id: 'marketing',
|
||||
required: false,
|
||||
text: 'Я даю своё согласие на получение рекламных и маркетинговых коммуникаций',
|
||||
links: [{ label: 'Условия получения коммуникаций', href: '#marketing' }],
|
||||
},
|
||||
];
|
||||
|
||||
function Checkbox({ checked, onChange }: { checked: boolean; onChange: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onChange}
|
||||
className={`w-5 h-5 rounded-md border-2 flex items-center justify-center flex-shrink-0 transition-all ${
|
||||
checked
|
||||
? 'bg-primary border-primary'
|
||||
: 'border-border bg-background hover:border-primary/50'
|
||||
}`}
|
||||
aria-checked={checked}
|
||||
role="checkbox"
|
||||
>
|
||||
{checked && <Check className="w-3 h-3 text-primary-foreground" strokeWidth={3} />}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function AuthConsent() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const returnTo = (location.state as { returnTo?: string })?.returnTo ?? '/auth/phone';
|
||||
|
||||
const [checked, setChecked] = useState<Record<string, boolean>>({
|
||||
pdp: false,
|
||||
terms: false,
|
||||
marketing: false,
|
||||
});
|
||||
|
||||
const toggle = (id: string) => setChecked(prev => ({ ...prev, [id]: !prev[id] }));
|
||||
|
||||
const requiredDone = consents.filter(c => c.required).every(c => checked[c.id]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (requiredDone) navigate(returnTo);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-background">
|
||||
<div className="flex flex-col h-full px-6 pt-14 pb-8">
|
||||
|
||||
{/* Иконка + заголовок */}
|
||||
<div className="mb-8">
|
||||
<div className="w-12 h-12 rounded-2xl bg-primary/10 flex items-center justify-center mb-5">
|
||||
<ShieldCheck className="w-6 h-6 text-primary" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-foreground mb-2">
|
||||
Перед началом работы
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
Для использования приложения ознакомьтесь со следующими документами и предоставьте необходимые согласия
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Форма согласий */}
|
||||
<form onSubmit={handleSubmit} className="flex flex-col flex-1">
|
||||
<div className="space-y-4 flex-1">
|
||||
{consents.map(consent => (
|
||||
<div
|
||||
key={consent.id}
|
||||
className={`rounded-xl border p-4 transition-colors cursor-pointer ${
|
||||
checked[consent.id]
|
||||
? 'border-primary/30 bg-primary/4'
|
||||
: 'border-border bg-card'
|
||||
}`}
|
||||
onClick={() => toggle(consent.id)}
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="mt-0.5">
|
||||
<Checkbox checked={checked[consent.id]} onChange={() => toggle(consent.id)} />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm text-foreground leading-relaxed mb-2">
|
||||
{consent.text}
|
||||
{consent.required && (
|
||||
<span className="text-destructive ml-1">*</span>
|
||||
)}
|
||||
</p>
|
||||
{/* Ссылки на документы */}
|
||||
<div className="flex flex-wrap gap-x-3 gap-y-1">
|
||||
{consent.links.map(link => (
|
||||
<a
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={e => e.stopPropagation()}
|
||||
className="inline-flex items-center gap-1 text-xs text-primary underline underline-offset-2 hover:opacity-75 transition-opacity"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
>
|
||||
<ExternalLink className="w-3 h-3" />
|
||||
{link.label}
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
<p className="text-xs text-muted-foreground px-1">
|
||||
<span className="text-destructive">*</span> — обязательные согласия
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Кнопка */}
|
||||
<div className="pt-6">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={!requiredDone}
|
||||
className="w-full h-14 bg-primary text-primary-foreground rounded-xl font-medium flex items-center justify-center gap-2.5 transition-all disabled:opacity-40 disabled:cursor-not-allowed hover:bg-primary/90 active:scale-[0.98]"
|
||||
>
|
||||
Продолжить
|
||||
<ArrowRight className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect } from 'react';
|
||||
import { 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]);
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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' };
|
||||
}
|
||||
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+145
-5
@@ -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
|
||||
|
||||
# Тесты
|
||||
|
||||
Выполняйте на ВМ из `/opt/han-chat/backend`.
|
||||
|
||||
### 1. Проверить запрет публичного internal API
|
||||
|
||||
```bash
|
||||
PUBLIC_WEB_URL=$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
if line.startswith("PUBLIC_WEB_URL="):
|
||||
print(line.split("=", 1)[1].strip().strip("\"'"))
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
|
||||
"$PUBLIC_WEB_URL/internal/sms/v1/messages/00000000-0000-0000-0000-000000000000"
|
||||
```
|
||||
|
||||
Ожидается:
|
||||
|
||||
```text
|
||||
HTTP 404
|
||||
```
|
||||
|
||||
### 2. Проверить callback с неправильного IP
|
||||
|
||||
```bash
|
||||
curl -sS -o /dev/null -w 'HTTP %{http_code}\n' \
|
||||
-X POST \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '[]' \
|
||||
"$PUBLIC_WEB_URL/callbacks/idgtl/sms"
|
||||
```
|
||||
|
||||
Ожидается:
|
||||
|
||||
```text
|
||||
HTTP 403
|
||||
```
|
||||
|
||||
Заголовок `X-Forwarded-For` не должен позволять обойти ограничение.
|
||||
|
||||
### 3. Проверить Basic auth внутри Docker-сети
|
||||
|
||||
Получите credentials из `.env`:
|
||||
|
||||
```bash
|
||||
CB_USER=$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
if line.startswith("IDGTL_SMS_CALLBACK_USERNAME="):
|
||||
print(line.split("=", 1)[1].strip().strip("\"'"))
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
CB_PASS=$(python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
for line in Path(".env").read_text().splitlines():
|
||||
if line.startswith("IDGTL_SMS_CALLBACK_PASSWORD="):
|
||||
print(line.split("=", 1)[1].strip().strip("\"'"))
|
||||
break
|
||||
PY
|
||||
)
|
||||
|
||||
export CB_USER CB_PASS
|
||||
```
|
||||
|
||||
Неверные credentials:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env --profile ops run --rm --no-deps \
|
||||
--entrypoint sh toolbox -ec '
|
||||
curl -sS -o /dev/null -w "HTTP %{http_code}\n" \
|
||||
-u invalid:invalid \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "[]" \
|
||||
http://sms-service:8080/callbacks/idgtl/sms
|
||||
'
|
||||
```
|
||||
|
||||
Ожидается `HTTP 401`.
|
||||
|
||||
Правильные credentials:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env --profile ops run --rm --no-deps \
|
||||
--entrypoint sh -e CB_USER -e CB_PASS toolbox -ec '
|
||||
curl -sS -o /dev/null -w "HTTP %{http_code}\n" \
|
||||
-u "$CB_USER:$CB_PASS" \
|
||||
-H "Content-Type: application/json" \
|
||||
--data "[]" \
|
||||
http://sms-service:8080/callbacks/idgtl/sms
|
||||
'
|
||||
```
|
||||
|
||||
Ожидается `HTTP 422`: авторизация прошла, но пустой callback-массив невалиден.
|
||||
|
||||
После проверки:
|
||||
|
||||
```bash
|
||||
unset CB_USER CB_PASS
|
||||
```
|
||||
|
||||
### 4. Проверить реальный callback Direct
|
||||
|
||||
После тестовой SMS:
|
||||
|
||||
```sql
|
||||
SELECT
|
||||
id,
|
||||
send_status,
|
||||
delivery_status,
|
||||
provider_message_id,
|
||||
callback_last_at,
|
||||
sent_at,
|
||||
delivered_at
|
||||
FROM sms.sms_outbound_message
|
||||
ORDER BY created_at DESC
|
||||
LIMIT 5;
|
||||
```
|
||||
|
||||
Успешный реальный callback подтверждается:
|
||||
|
||||
- `callback_last_at IS NOT NULL`;
|
||||
- `delivery_status = sent` или `delivered`;
|
||||
- заполняются `sent_at`/`delivered_at`.
|
||||
|
||||
Дополнительно:
|
||||
|
||||
```bash
|
||||
docker compose --env-file .env logs --since=30m nginx sms-service
|
||||
```
|
||||
|
||||
Для callback должен быть ответ `204`. Только реальный запрос Direct может полноценно подтвердить IP allowlist.
|
||||
@@ -8,6 +8,9 @@
|
||||
- (без спец.символов) openssl rand -hex 16
|
||||
- openssl rand -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
|
||||
|
||||
Reference in New Issue
Block a user