Разработана первая версия приложений

This commit is contained in:
mi
2026-07-10 18:06:14 +03:00
parent aa8761d1b3
commit 8c7b4074c4
162 changed files with 12178 additions and 16 deletions
@@ -0,0 +1,7 @@
node_modules
dist
.expo
.git
.env*
playwright-report
test-results
@@ -0,0 +1,7 @@
EXPO_PUBLIC_API_BASE_URL=https://tohin.ru
EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth
EXPO_PUBLIC_KEYCLOAK_REALM=han-chat
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend
EXPO_PUBLIC_APP_ENV=production-like
# Только публичные значения. Service tokens, S3 credentials и OTP-код запрещены.
@@ -0,0 +1,8 @@
node_modules/
dist/
.expo/
playwright-report/
test-results/
.env
.env.local
*.log
@@ -0,0 +1,23 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
ARG EXPO_PUBLIC_API_BASE_URL
ARG EXPO_PUBLIC_AUTH_BASE_URL
ARG EXPO_PUBLIC_KEYCLOAK_REALM
ARG EXPO_PUBLIC_KEYCLOAK_CLIENT_ID
ARG EXPO_PUBLIC_APP_ENV=production
ENV EXPO_PUBLIC_API_BASE_URL=$EXPO_PUBLIC_API_BASE_URL \
EXPO_PUBLIC_AUTH_BASE_URL=$EXPO_PUBLIC_AUTH_BASE_URL \
EXPO_PUBLIC_KEYCLOAK_REALM=$EXPO_PUBLIC_KEYCLOAK_REALM \
EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=$EXPO_PUBLIC_KEYCLOAK_CLIENT_ID \
EXPO_PUBLIC_APP_ENV=$EXPO_PUBLIC_APP_ENV
RUN npm run build
# One-shot Compose init container copies the immutable export to nginx's volume.
FROM alpine:3.22 AS static
COPY --from=build /app/dist /dist
RUN mkdir /output
ENTRYPOINT ["/bin/sh", "-ec"]
CMD ["rm -rf /output/* /output/.[!.]* /output/..?* 2>/dev/null || true; cp -a /dist/. /output/"]
@@ -0,0 +1,31 @@
# HAN Chat frontend test site
Expo / React Native Web SPA для проверки публичных пользовательских потоков HAN Chat.
## Запуск
```bash
cp .env.example .env.local
npm install
npm run web
```
Проверки: `npm test`, `npm run typecheck`, `npm run build`, `npm run test:e2e`.
## Production
`npm run build` создаёт `dist/`. Каталог монтируется в корневой nginx системы; отдельный frontend nginx не используется. Для SPA nginx должен применять `try_files $uri /index.html`, не кэшировать `index.html` и бессрочно кэшировать hashed assets.
Dockerfile собирает статический OCI-артефакт `/dist` без runtime-сервера:
```bash
docker build --target static \
--build-arg EXPO_PUBLIC_API_BASE_URL=https://tohin.ru \
--build-arg EXPO_PUBLIC_AUTH_BASE_URL=https://tohin.ru/auth \
--build-arg EXPO_PUBLIC_KEYCLOAK_REALM=han-chat \
--build-arg EXPO_PUBLIC_KEYCLOAK_CLIENT_ID=han-chat-frontend .
```
Во frontend разрешены только публичные URL, realm, client id и имя среды. Service tokens, S3 credentials и mock OTP code добавлять запрещено. На web refresh token хранится в browser storage с известным XSS-риском; production требует строгой CSP и отсутствия сторонних scripts.
WebSocket использует subprotocols `han-chat-v1` и `bearer.<JWT>`. Они должны совпадать с реализацией api-backend. Query-token намеренно не используется.
@@ -0,0 +1,15 @@
import type { ExpoConfig } from "expo/config";
const config: ExpoConfig = {
name: "HAN Chat Test",
slug: "han-chat-test",
version: "1.0.0",
scheme: "han-chat",
orientation: "portrait",
userInterfaceStyle: "light",
experiments: { typedRoutes: true },
plugins: ["expo-router", "expo-secure-store"],
web: { bundler: "metro", output: "static" },
};
export default config;
@@ -0,0 +1,16 @@
import { Stack } from "expo-router";
import { StatusBar } from "expo-status-bar";
import React from "react";
import { SafeAreaProvider, SafeAreaView } from "react-native-safe-area-context";
import { AppProvider } from "../src/app-context";
export default function RootLayout() {
return <SafeAreaProvider>
<AppProvider>
<SafeAreaView style={{ flex: 1, backgroundColor: "#f6f8fb" }}>
<StatusBar style="dark" />
<Stack screenOptions={{ headerShown: false }} />
</SafeAreaView>
</AppProvider>
</SafeAreaProvider>;
}
@@ -0,0 +1,40 @@
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useState } from "react";
import { Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import type { Consents } from "../../src/types";
import { Button, ErrorNotice, Loading, styles } from "../../src/ui";
export default function AuthCallbackScreen() {
const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>();
const app = useApp();
const [error, setError] = useState<unknown>();
const retry = () => {
if (!params.code || !params.state || typeof window === "undefined") return;
const raw = window.sessionStorage.getItem("han.pending-consents");
if (!raw) return;
setError(undefined);
void app.finishCallback(params.code, params.state, JSON.parse(raw) as Consents).catch(setError);
};
useEffect(() => {
if (params.error) {
setError(new Error("Авторизация отменена или отклонена."));
return;
}
if (!params.code || !params.state) return;
const raw = typeof window !== "undefined" ? window.sessionStorage.getItem("han.pending-consents") : null;
if (!raw) {
setError(new Error("Не найдены локально принятые согласия. Начните вход заново."));
return;
}
const consents = JSON.parse(raw) as Consents;
void app.finishCallback(params.code, params.state, consents)
.then(() => window.sessionStorage.removeItem("han.pending-consents"))
.catch(setError);
}, [params.code, params.state, params.error]);
return <View style={styles.page}>
<Text accessibilityRole="header" style={styles.title}>Завершение входа</Text>
{!error && <Loading />}
{error && <><ErrorNotice error={error} /><Button title="Повторить bootstrap" onPress={retry} /></>}
</View>;
}
@@ -0,0 +1,46 @@
import React, { useEffect, useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { getDiagnostics, sessionMemory } from "../src/api";
import { getTokenInfo } from "../src/auth";
import { useApp } from "../src/app-context";
import { isProduction } from "../src/config";
import { getRealtimeDiagnostics } from "../src/realtime";
import { Header, styles } from "../src/ui";
export default function DiagnosticsScreen() {
const app = useApp();
const [, render] = useState(0);
useEffect(() => {
const timer = setInterval(() => render((value) => value + 1), 1000);
return () => clearInterval(timer);
}, []);
if (isProduction) return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>Диагностика</Text>
<Text style={styles.error}>Экран отключён в production.</Text>
</ScrollView>;
const token = getTokenInfo();
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
<Text accessibilityRole="header" style={styles.title}>Безопасная диагностика</Text>
<View style={styles.card}>
<Text style={styles.text}>Режим: {app.authStatus}</Text>
<Text style={styles.text}>Realtime: {app.realtimeState}</Text>
<Text style={styles.text}>UX-сессия: {sessionMemory.id ?? "нет"}</Text>
<Text style={styles.text}>Access token истекает: {token ? new Date(token.expiresAt).toLocaleString("ru-RU") : "нет"}</Text>
{getRealtimeDiagnostics().map((item) =>
<Text key={item.dialog} style={styles.text}>Cursor {item.dialog}: {item.cursor}</Text>,
)}
<Text style={styles.muted}>Токены, OTP, персональные данные, сообщения и presigned URL здесь никогда не отображаются.</Text>
</View>
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Последние запросы</Text>
{!getDiagnostics().length && <Text style={styles.muted}>Запросов ещё нет.</Text>}
{getDiagnostics().map((item) => <View key={`${item.at}-${item.requestId}`} style={styles.row}>
<Text style={styles.badge}>{item.status}</Text>
<Text style={styles.text}>{item.method} {item.path}</Text>
<Text style={styles.muted}>request_id: {item.requestId}</Text>
</View>)}
</View>
</ScrollView>;
}
@@ -0,0 +1,134 @@
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { useLocalSearchParams } from "expo-router";
import React, { useEffect, useMemo, useState } from "react";
import { Platform, ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { RealtimeClient, reconcileMessages } from "../../src/realtime";
import { dialogApi, profileApi, publicApi, uploadAttachment } from "../../src/services";
import type { Message } from "../../src/types";
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../../src/ui";
const statusLabel: Record<string, string> = {
open: "Открыт",
waiting_for_company: "Ожидает ответа компании",
waiting_for_client: "Ожидает вашего ответа",
closed: "Закрыт",
accepted: "Принято",
delivered: "Доставлено",
failed: "Ошибка доставки",
rejected: "Отклонено",
};
export default function ChatScreen() {
const { dialogId } = useLocalSearchParams<{ dialogId: string }>();
const app = useApp();
const client = useQueryClient();
const [text, setText] = useState("");
const [error, setError] = useState<unknown>();
const [sending, setSending] = useState(false);
const config = useQuery({ queryKey: ["public-config"], queryFn: publicApi.config });
const dialog = useQuery({ queryKey: ["dialog", dialogId], queryFn: () => dialogApi.get(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
const messages = useQuery({ queryKey: ["messages", dialogId], queryFn: () => dialogApi.messages(dialogId), enabled: Boolean(dialogId) && app.authStatus === "authenticated" });
const realtime = useMemo(() => new RealtimeClient(
dialogId ? [dialogId] : [],
(event) => {
if (event.type === "message.new") merge([event.message]);
if (event.type === "message.status") {
client.setQueryData(["messages", dialogId], (old: typeof messages.data) => old && ({
...old,
items: old.items.map((item) => item.message_id === event.message_id ? { ...item, safety_status: event.safety_status, delivery_status: event.delivery_status } : item),
}));
}
if (event.type === "dialog.status") void dialog.refetch();
},
(_, incoming) => merge(incoming),
app.setRealtimeState,
), [dialogId]);
function merge(incoming: Message[]) {
client.setQueryData(["messages", dialogId], (old: typeof messages.data) =>
old ? { ...old, items: reconcileMessages(old.items, incoming) } : { items: incoming, next_cursor: null },
);
}
useEffect(() => {
if (app.authStatus === "authenticated") realtime.start();
return () => realtime.stop();
}, [realtime, app.authStatus]);
const sendText = async () => {
const normalized = text.trim();
if (!normalized || !dialogId) return;
setSending(true); setError(undefined);
try {
const message = await dialogApi.sendText(dialogId, normalized, crypto.randomUUID());
merge([message]); setText("");
} catch (reason) { setError(reason); }
finally { setSending(false); }
};
const chooseFile = () => {
if (Platform.OS !== "web") {
setError(new Error("Выбор файла в этой тестовой сборке доступен в web."));
return;
}
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*,application/pdf";
input.onchange = () => { const file = input.files?.[0]; if (file) void sendFile(file); };
input.click();
};
const sendFile = async (file: File) => {
const limits = config.data?.attachments;
const max = (limits?.max_size_mb ?? 5) * 1024 * 1024;
const allowed = limits?.allowed_mime_types ?? ["image/jpeg", "image/png", "image/webp", "application/pdf"];
if (file.size > max || !allowed.includes(file.type)) {
setError(new Error("Недопустимый тип файла или превышен допустимый размер."));
return;
}
setSending(true); setError(undefined);
try {
const uploaded = await uploadAttachment(dialogId, file);
const message = await dialogApi.sendFile(dialogId, uploaded.attachmentId, uploaded.checksum, crypto.randomUUID());
merge([message]);
} catch (reason) { setError(reason); }
finally { setSending(false); }
};
const downloadAttachment = async (attachmentId: string) => {
try {
const result = await profileApi.attachmentUrl(dialogId, attachmentId);
if (typeof window !== "undefined") window.location.assign(result.download_url);
} catch (reason) { setError(reason); }
};
const closed = dialog.data?.status === "closed";
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={app.authStatus === "authenticated" ? () => void app.signOut() : undefined} />
<Text accessibilityRole="header" style={styles.title}>Чат</Text>
{app.authStatus !== "authenticated" && <Text style={styles.error}>Для просмотра чата требуется авторизация.</Text>}
{(dialog.isLoading || messages.isLoading) && <Loading />}
{(dialog.error || messages.error) && <ErrorNotice error={dialog.error ?? messages.error} retry={() => { void dialog.refetch(); void messages.refetch(); }} />}
<Text style={styles.badge}>Статус: {statusLabel[dialog.data?.status ?? ""] ?? "—"}</Text>
<View accessibilityLiveRegion="polite" style={{ gap: 10 }}>
{(messages.data?.items ?? []).map((message) => <View key={message.message_id} style={message.sender_type === "client" ? styles.messageClient : styles.messageCompany}>
<Text style={styles.text}>{message.content_kind === "file" ? `Файл: ${message.attachments[0]?.file_name ?? "вложение"}` : message.text}</Text>
{message.attachments.map((attachment) =>
<Button key={attachment.attachment_id} title="Скачать вложение" secondary onPress={() => void downloadAttachment(attachment.attachment_id)} />,
)}
<Text style={styles.muted}>{message.sender_type === "client" ? "Вы" : "Компания"} · {new Date(message.created_at).toLocaleString("ru-RU")} · {statusLabel[message.delivery_status] ?? message.delivery_status}</Text>
</View>)}
{!messages.isLoading && !messages.data?.items.length && <Text style={styles.muted}>Сообщений пока нет.</Text>}
</View>
{closed ? <Text style={styles.muted}>Диалог закрыт и доступен только для чтения.</Text> : <View style={styles.card}>
<Field label="Новое сообщение" multiline value={text} onChangeText={setText} />
<View style={styles.row}>
<Button title={sending ? "Отправка…" : "Отправить"} disabled={sending || !text.trim()} onPress={() => void sendText()} />
<Button title="Прикрепить изображение или PDF" secondary disabled={sending} onPress={chooseFile} />
</View>
{error && <ErrorNotice error={error} />}
</View>}
</ScrollView>;
}
@@ -0,0 +1,46 @@
import { useInfiniteQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import React from "react";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../../src/app-context";
import { dialogApi } from "../../src/services";
import { Button, ErrorNotice, Header, Loading, styles } from "../../src/ui";
const statusLabels = {
open: "Открыт",
waiting_for_company: "Ожидает ответа компании",
waiting_for_client: "Ожидает вашего ответа",
closed: "Закрыт",
};
export default function DialogsScreen() {
const app = useApp();
const dialogs = useInfiniteQuery({
queryKey: ["dialogs"],
queryFn: ({ pageParam }) => dialogApi.list(pageParam),
initialPageParam: undefined as string | undefined,
getNextPageParam: (page) => page.next_cursor ?? undefined,
enabled: app.authStatus === "authenticated",
});
if (app.authStatus !== "authenticated") return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
<Text style={styles.text}>История доступна после авторизации. Отправьте сообщение на главной странице, чтобы войти.</Text>
<Link href="/" style={styles.link}>На главную</Link>
</ScrollView>;
const items = dialogs.data?.pages.flatMap((page) => page.items) ?? [];
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
<Text accessibilityRole="header" style={styles.title}>История диалогов</Text>
{dialogs.isLoading && <Loading />}
{dialogs.error && <ErrorNotice error={dialogs.error} retry={() => void dialogs.refetch()} />}
{!dialogs.isLoading && !items.length && <Text style={styles.muted}>Диалогов пока нет.</Text>}
{items.map((dialog) => <View key={dialog.dialog_id} style={styles.card}>
<Text style={styles.heading}>{statusLabels[dialog.status]}</Text>
<Text style={styles.muted}>Диалог {dialog.dialog_id.slice(0, 8)}</Text>
<Link href={`/dialogs/${dialog.dialog_id}`} style={styles.link}>Открыть диалог</Link>
</View>)}
{dialogs.hasNextPage && <Button title="Показать ещё" secondary disabled={dialogs.isFetchingNextPage} onPress={() => void dialogs.fetchNextPage()} />}
</ScrollView>;
}
@@ -0,0 +1,127 @@
import { zodResolver } from "@hookform/resolvers/zod";
import { useQuery } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { useState } from "react";
import { Controller, useForm } from "react-hook-form";
import { Linking, ScrollView, Switch, Text, View } from "react-native";
import { z } from "zod";
import { useApp } from "../src/app-context";
import { dialogApi, publicApi } from "../src/services";
import type { Consents } from "../src/types";
import { Button, ErrorNotice, Field, Header, Loading, styles } from "../src/ui";
const schema = z.object({ text: z.string().trim().min(1, "Введите сообщение").max(4000, "Сообщение слишком длинное") });
type Form = z.infer<typeof schema>;
export default function HomeScreen() {
const { authStatus, realtimeState, authorize, signOut } = 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<string | null>(null);
const [sendError, setSendError] = useState<unknown>();
const [sending, setSending] = useState(false);
const router = useRouter();
const { control, handleSubmit, setValue, reset, formState: { errors } } = useForm<Form>({
defaultValues: { text: "" }, resolver: zodResolver(schema),
});
const sendAuthenticated = async (text: string) => {
setSending(true);
setSendError(undefined);
try {
const dialog = await dialogApi.create(crypto.randomUUID());
await dialogApi.sendText(dialog.dialog_id, text, crypto.randomUUID());
reset();
router.push(`/dialogs/${dialog.dialog_id}`);
} catch (error) {
setSendError(error);
} finally {
setSending(false);
}
};
const send = async (text: string) => {
if (authStatus !== "authenticated") {
setPending(text);
setConsentOpen(true);
return;
}
await sendAuthenticated(text);
};
const accept = async () => {
if (!required.personal || !required.agreement) return;
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" },
};
setConsentOpen(false);
try {
const authorized = await authorize(consents);
if (authorized && pending) await sendAuthenticated(pending);
} catch (error) {
setSendError(error);
}
};
const questions = content.data?.popular_questions ?? [];
return <ScrollView contentContainerStyle={styles.page}>
<Header status={authStatus} realtime={realtimeState} onLogout={authStatus === "authenticated" ? () => void signOut() : undefined} />
<Text accessibilityRole="header" style={styles.title}>Помощь мигрантам</Text>
<Text style={styles.text}>{content.data?.texts.welcome ?? "Задайте вопрос — оператор ответит в чате."}</Text>
{(config.isLoading || content.isLoading) && <Loading />}
{(config.error || content.error) && <ErrorNotice error={config.error ?? content.error} retry={() => { void config.refetch(); void content.refetch(); }} />}
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Популярные вопросы</Text>
<View style={styles.row}>
{questions.map((question, index) => {
const text = question.text;
return <Button key={question.id ?? index} title={text} secondary onPress={() => { setValue("text", text); void send(text); }} />;
})}
{!questions.length && <Text style={styles.muted}>Популярные вопросы пока не опубликованы.</Text>}
</View>
</View>
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Написать оператору</Text>
<Controller control={control} name="text" render={({ field }) =>
<Field label="Сообщение" multiline value={field.value} onChangeText={field.onChange} error={errors.text?.message} />
} />
<Button title={sending ? "Отправляем…" : "Отправить"} disabled={sending} onPress={() => void handleSubmit(({ text }) => send(text))()} />
{sendError && <ErrorNotice error={sendError} />}
</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); }} />
</View>
</View>
</View>}
</ScrollView>;
}
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,63 @@
import { useQuery } from "@tanstack/react-query";
import { Link } from "expo-router";
import React, { useState } from "react";
import { ScrollView, Text, View } from "react-native";
import { useApp } from "../src/app-context";
import { profileApi } from "../src/services";
import { Button, ErrorNotice, Header, Loading, styles } from "../src/ui";
export default function ProfileScreen() {
const app = useApp();
const enabled = app.authStatus === "authenticated";
const profile = useQuery({ queryKey: ["profile"], queryFn: profileApi.me, enabled });
const documents = useQuery({ queryKey: ["documents"], queryFn: profileApi.documents, enabled });
const [downloadError, setDownloadError] = useState<unknown>();
const download = async (id: string) => {
try {
const result = await profileApi.documentUrl(id);
if (typeof window !== "undefined") window.location.assign(result.download_url);
} catch (error) { setDownloadError(error); }
};
if (!enabled) return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} />
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
<Text style={styles.text}>Профиль доступен после авторизации.</Text>
<Link href="/" style={styles.link}>Перейти в чат для входа</Link>
</ScrollView>;
const personal = profile.data?.profile.personal_data;
return <ScrollView contentContainerStyle={styles.page}>
<Header status={app.authStatus} realtime={app.realtimeState} onLogout={() => void app.signOut()} />
<Text accessibilityRole="header" style={styles.title}>Профиль</Text>
{(profile.isLoading || documents.isLoading) && <Loading />}
{(profile.error || documents.error) && <ErrorNotice error={profile.error ?? documents.error} retry={() => { void profile.refetch(); void documents.refetch(); }} />}
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Личные данные</Text>
<Row label="ФИО" value={personal?.full_name} />
<Row label="Гражданство" value={personal?.citizenship} />
<Row label="Телефон в РФ" value={personal?.russian_phone} />
<Row label="Зарубежный телефон" value={personal?.foreign_phone} />
<Row label="Email" value={personal?.email} />
<Text style={styles.muted}>Редактирование профиля недоступно. Для изменения данных напишите оператору.</Text>
<Link href="/" style={styles.link}>Написать оператору</Link>
</View>
<View style={styles.card}>
<Text accessibilityRole="header" style={styles.heading}>Документы</Text>
{!documents.data?.items.length && <Text style={styles.muted}>Документов пока нет.</Text>}
{documents.data?.items.map((document) => <View key={document.document_id} style={styles.row}>
<View style={{ flex: 1 }}>
<Text style={styles.text}>{document.name}</Text>
<Text style={styles.muted}>{new Date(document.sent_at).toLocaleDateString("ru-RU")}</Text>
</View>
<Button title="Скачать" secondary onPress={() => void download(document.document_id)} />
</View>)}
{downloadError && <ErrorNotice error={downloadError} />}
</View>
</ScrollView>;
}
function Row({ label, value }: { label: string; value: string | null | undefined }) {
return <View><Text style={styles.muted}>{label}</Text><Text style={styles.text}>{value || "Не указано"}</Text></View>;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="expo/types" />
@@ -0,0 +1,48 @@
{
"name": "han-frontend-test-site",
"version": "1.0.0",
"private": true,
"main": "expo-router/entry",
"scripts": {
"start": "expo start",
"web": "expo start --web",
"typecheck": "tsc --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"test:e2e": "playwright test",
"build": "expo export --platform web",
"serve": "npx serve dist"
},
"dependencies": {
"@expo/metro-runtime": "57.0.3",
"@hookform/resolvers": "5.4.0",
"@tanstack/react-query": "5.101.2",
"expo": "57.0.4",
"expo-auth-session": "57.0.2",
"expo-constants": "57.0.3",
"expo-crypto": "57.0.0",
"expo-linking": "57.0.2",
"expo-router": "57.0.4",
"expo-secure-store": "57.0.0",
"expo-status-bar": "57.0.0",
"expo-web-browser": "57.0.0",
"react": "19.2.7",
"react-dom": "19.2.7",
"react-hook-form": "7.81.0",
"react-native": "0.86.0",
"react-native-safe-area-context": "5.8.0",
"react-native-screens": "4.26.0",
"react-native-web": "0.21.2",
"zod": "4.4.3"
},
"devDependencies": {
"jsdom": "29.1.1",
"@playwright/test": "1.61.1",
"@testing-library/react": "16.3.2",
"@types/react": "19.2.17",
"@vitejs/plugin-react": "6.0.3",
"typescript": "7.0.2",
"vite": "8.1.4",
"vitest": "4.1.10"
}
}
@@ -0,0 +1,20 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
fullyParallel: true,
retries: process.env.CI ? 2 : 0,
reporter: "list",
use: { baseURL: "http://127.0.0.1:4173", trace: "on-first-retry" },
webServer: {
command: "npm run build && npx serve dist -l 4173",
url: "http://127.0.0.1:4173",
reuseExistingServer: !process.env.CI,
timeout: 120_000,
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
{ name: "webkit-mobile", use: { ...devices["iPhone 13"] } },
],
});
@@ -0,0 +1,93 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
export { sessionMemory } from "./session";
import { sessionMemory } from "./session";
export type ApiErrorEnvelope = {
error: { code: string; message: string; request_id?: string; details?: Record<string, unknown> };
};
export class ApiError extends Error {
constructor(
readonly status: number,
readonly code: string,
message: string,
readonly requestId?: string,
readonly retryAfter?: number,
) {
super(message);
this.name = "ApiError";
}
}
export type Diagnostic = {
at: number;
method: string;
path: string;
status: number;
requestId: string;
};
const diagnostics: Diagnostic[] = [];
export const getDiagnostics = () => [...diagnostics];
function traceparent() {
const traceId = crypto.randomUUID().replaceAll("-", "") + crypto.randomUUID().replaceAll("-", "").slice(0, 16);
const spanId = crypto.randomUUID().replaceAll("-", "").slice(0, 16);
return `00-${traceId.slice(0, 32)}-${spanId}-01`;
}
function safePath(path: string) {
return path.split("?")[0] ?? path;
}
async function parseError(response: Response, requestId: string) {
let envelope: ApiErrorEnvelope | undefined;
try { envelope = (await response.json()) as ApiErrorEnvelope; } catch { /* intentionally empty */ }
const code = envelope?.error?.code ?? `http_${response.status}`;
const retry = Number(response.headers.get("Retry-After"));
return new ApiError(
response.status,
code,
envelope?.error?.message ?? "Запрос не выполнен",
envelope?.error?.request_id ?? requestId,
Number.isFinite(retry) ? retry : undefined,
);
}
export async function apiRequest<T>(
path: string,
init: RequestInit & { protected?: boolean } = {},
replayed = false,
): Promise<T> {
const requestId = crypto.randomUUID();
const isProtected = init.protected ?? false;
const headers = new Headers(init.headers);
headers.set("Accept", "application/json");
headers.set("X-Request-ID", requestId);
headers.set("traceparent", traceparent());
if (init.body && !headers.has("Content-Type")) headers.set("Content-Type", "application/json");
if (isProtected) {
const token = getAccessToken();
if (!token) throw new ApiError(401, "unauthorized", "Требуется авторизация", requestId);
headers.set("Authorization", `Bearer ${token}`);
if (sessionMemory.id) headers.set("X-Ux-Session-Id", sessionMemory.id);
}
const response = await fetch(`${env.apiBaseUrl}${path}`, { ...init, headers });
diagnostics.unshift({
at: Date.now(), method: init.method ?? "GET", path: safePath(path),
status: response.status, requestId: response.headers.get("X-Request-ID") ?? requestId,
});
diagnostics.splice(20);
if (response.status === 401 && isProtected && !replayed) {
await refreshTokens();
return apiRequest<T>(path, init, true);
}
if (!response.ok) throw await parseError(response, requestId);
if (response.status === 204) return undefined as T;
return response.json() as Promise<T>;
}
export const json = (value: unknown) => JSON.stringify(value);
export const idempotencyHeaders = (key: string) => ({ "Idempotency-Key": key });
@@ -0,0 +1,121 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useRouter } from "expo-router";
import React, { createContext, useCallback, useContext, useEffect, useMemo, useState } from "react";
import { AppState, Platform } from "react-native";
import { beginAuthorization, clearTokens, completeAuthorization, configureAuthFailure, getAccessToken, logout, refreshTokens } from "./auth";
import { sessionMemory } from "./api";
import { authApi, publicApi } from "./services";
import type { Consents } from "./types";
type AuthStatus = "guest" | "authorizing" | "bootstrapping" | "authenticated";
type AppContextValue = {
authStatus: AuthStatus;
realtimeState: string;
setRealtimeState: (value: string) => void;
authorize: (consents: Consents) => Promise<boolean>;
finishCallback: (code: string, state: string, consents: Consents) => Promise<void>;
signOut: () => Promise<void>;
ensureSession: () => Promise<void>;
};
const Context = createContext<AppContextValue | null>(null);
const queryClient = new QueryClient({
defaultOptions: { queries: { retry: 1, staleTime: 15_000 } },
});
export function AppProvider({ children }: { children: React.ReactNode }) {
const [authStatus, setAuthStatus] = useState<AuthStatus>("guest");
const [realtimeState, setRealtimeState] = useState("idle");
const [idleTimeoutMs, setIdleTimeoutMs] = useState<number | null>(null);
const router = useRouter();
const toGuest = useCallback(() => {
sessionMemory.clear();
setRealtimeState("idle");
setAuthStatus("guest");
queryClient.clear();
}, []);
useEffect(() => {
configureAuthFailure(toGuest);
void publicApi.config().then((config) => {
const minutes = config.ux.idle_timeout_minutes;
if (minutes > 0) setIdleTimeoutMs(minutes * 60_000);
}).catch(() => undefined);
void refreshTokens()
.then(() => {
setAuthStatus("authenticated");
void authApi.startSession("cold_start").catch(() => undefined);
})
.catch(() => setAuthStatus("guest"));
}, [toGuest]);
const finishCallback = useCallback(async (code: string, state: string, consents: Consents) => {
setAuthStatus("bootstrapping");
if (!getAccessToken()) await completeAuthorization(code, state);
await authApi.bootstrap(consents);
await authApi.startSession("first_launch");
if (typeof window !== "undefined") window.sessionStorage.removeItem("han.pending-consents");
setAuthStatus("authenticated");
router.replace("/");
}, [router]);
const authorize = useCallback(async (consents: Consents) => {
setAuthStatus("authorizing");
if (typeof window !== "undefined") window.sessionStorage.setItem("han.pending-consents", JSON.stringify(consents));
const result = await beginAuthorization();
if (result.type !== "success" || typeof result.params.code !== "string" || typeof result.params.state !== "string") {
setAuthStatus("guest");
if (result.type !== "dismiss" && result.type !== "cancel") throw new Error("authorization_failed");
return false;
}
await finishCallback(result.params.code, result.params.state, consents);
return true;
}, [finishCallback]);
const ensureSession = useCallback(async () => {
if (authStatus !== "authenticated") return;
if (!sessionMemory.id) await authApi.startSession("cold_start");
else if (idleTimeoutMs !== null && Date.now() - sessionMemory.lastActivityAt > idleTimeoutMs) {
await authApi.startSession("idle_timeout");
}
sessionMemory.touch();
}, [authStatus, idleTimeoutMs]);
useEffect(() => {
if (Platform.OS === "web") {
const activity = () => sessionMemory.touch();
const resume = () => { if (!document.hidden) void ensureSession(); };
window.addEventListener("pointerdown", activity);
window.addEventListener("keydown", activity);
document.addEventListener("visibilitychange", resume);
return () => {
window.removeEventListener("pointerdown", activity);
window.removeEventListener("keydown", activity);
document.removeEventListener("visibilitychange", resume);
};
}
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") void ensureSession();
});
return () => subscription.remove();
}, [ensureSession]);
const value = useMemo<AppContextValue>(() => ({
authStatus, realtimeState, setRealtimeState, authorize, finishCallback, ensureSession,
signOut: async () => { await logout(); toGuest(); router.replace("/"); },
}), [authStatus, realtimeState, authorize, finishCallback, ensureSession, toGuest, router]);
return <QueryClientProvider client={queryClient}><Context.Provider value={value}>{children}</Context.Provider></QueryClientProvider>;
}
export function useApp() {
const value = useContext(Context);
if (!value) throw new Error("AppProvider is missing");
return value;
}
export async function resetAuthForTests() {
await clearTokens();
queryClient.clear();
}
@@ -0,0 +1,194 @@
import * as AuthSession from "expo-auth-session";
import * as Crypto from "expo-crypto";
import * as SecureStore from "expo-secure-store";
import * as WebBrowser from "expo-web-browser";
import { Platform } from "react-native";
import { env, oidcIssuer } from "./config";
import { SingleFlight } from "./single-flight";
import type { TokenSet } from "./types";
WebBrowser.maybeCompleteAuthSession();
const REFRESH_KEY = "han.refresh-token";
const PKCE_KEY = "han.pkce";
const PKCE_TTL_MS = 10 * 60_000;
let tokens: TokenSet | null = null;
const refreshFlight = new SingleFlight<TokenSet>();
let refreshTimer: ReturnType<typeof setTimeout> | undefined;
let authFailure: (() => void) | undefined;
const browserStore = {
async get(key: string) {
if (typeof window === "undefined") return null;
return window.localStorage.getItem(key);
},
async set(key: string, value: string) {
if (typeof window !== "undefined") window.localStorage.setItem(key, value);
},
async del(key: string) {
if (typeof window !== "undefined") window.localStorage.removeItem(key);
},
};
const secureStore = {
get: (key: string) =>
Platform.OS === "web" ? browserStore.get(key) : SecureStore.getItemAsync(key),
set: (key: string, value: string) =>
Platform.OS === "web" ? browserStore.set(key, value) : SecureStore.setItemAsync(key, value),
del: (key: string) =>
Platform.OS === "web" ? browserStore.del(key) : SecureStore.deleteItemAsync(key),
};
const random = () => Crypto.randomUUID().replaceAll("-", "") + Crypto.randomUUID().replaceAll("-", "");
const redirectUri = AuthSession.makeRedirectUri({ scheme: "han-chat", path: "auth/callback" });
const tokenEndpoint = `${oidcIssuer}/protocol/openid-connect/token`;
export function configureAuthFailure(callback: () => void) {
authFailure = callback;
}
export function getAccessToken() {
return tokens?.accessToken ?? null;
}
export function getTokenInfo() {
return tokens ? { expiresAt: tokens.expiresAt } : null;
}
async function persist(next: TokenSet) {
tokens = next;
await secureStore.set(REFRESH_KEY, next.refreshToken);
if (refreshTimer) clearTimeout(refreshTimer);
const delay = Math.max(1_000, next.expiresAt - Date.now() - 60_000);
refreshTimer = setTimeout(() => void refreshTokens().catch(() => undefined), delay);
}
async function parseTokenResponse(response: Response): Promise<TokenSet> {
const body = (await response.json()) as Record<string, unknown>;
if (!response.ok || typeof body.access_token !== "string" || typeof body.refresh_token !== "string") {
throw new Error(typeof body.error === "string" ? body.error : "token_exchange_failed");
}
return {
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + Number(body.expires_in ?? 300) * 1000,
...(typeof body.id_token === "string" ? { idToken: body.id_token } : {}),
};
}
export async function beginAuthorization() {
const verifier = random();
const state = random();
const nonce = random();
const digest = await Crypto.digestStringAsync(Crypto.CryptoDigestAlgorithm.SHA256, verifier, {
encoding: Crypto.CryptoEncoding.BASE64,
});
const challenge = digest.replaceAll("+", "-").replaceAll("/", "_").replaceAll("=", "");
await secureStore.set(PKCE_KEY, JSON.stringify({ verifier, state, nonce, createdAt: Date.now() }));
const url = `${oidcIssuer}/protocol/openid-connect/auth?${new URLSearchParams({
client_id: env.clientId,
redirect_uri: redirectUri,
response_type: "code",
scope: "openid profile offline_access",
code_challenge: challenge,
code_challenge_method: "S256",
state,
nonce,
})}`;
const result = await WebBrowser.openAuthSessionAsync(url, redirectUri);
if (result.type !== "success") return { type: result.type as "cancel" | "dismiss" };
const callback = new URL(result.url);
return {
type: "success" as const,
params: {
code: callback.searchParams.get("code"),
state: callback.searchParams.get("state"),
error: callback.searchParams.get("error"),
},
};
}
export async function completeAuthorization(code: string, state: string) {
const raw = await secureStore.get(PKCE_KEY);
await secureStore.del(PKCE_KEY);
if (!raw) throw new Error("pkce_state_missing");
const saved = JSON.parse(raw) as { verifier: string; state: string; nonce: string; createdAt: number };
if (saved.state !== state || Date.now() - saved.createdAt > PKCE_TTL_MS) {
throw new Error("pkce_state_invalid");
}
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: env.clientId,
redirect_uri: redirectUri,
code,
code_verifier: saved.verifier,
}).toString(),
});
const next = await parseTokenResponse(response);
if (!next.idToken || readJwtClaim(next.idToken, "nonce") !== saved.nonce) {
await clearTokens();
throw new Error("oidc_nonce_invalid");
}
await persist(next);
return next;
}
function readJwtClaim(token: string, claim: string) {
const payload = token.split(".")[1];
if (!payload) return undefined;
try {
const normalized = payload.replaceAll("-", "+").replaceAll("_", "/");
const decoded = decodeURIComponent(
Array.from(atob(normalized), (character) => `%${character.charCodeAt(0).toString(16).padStart(2, "0")}`).join(""),
);
return (JSON.parse(decoded) as Record<string, unknown>)[claim];
} catch {
return undefined;
}
}
export async function refreshTokens(): Promise<TokenSet> {
return refreshFlight.run(async () => {
const refreshToken = tokens?.refreshToken ?? (await secureStore.get(REFRESH_KEY));
if (!refreshToken) throw new Error("refresh_token_missing");
try {
const response = await fetch(tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: env.clientId,
refresh_token: refreshToken,
}).toString(),
});
const next = await parseTokenResponse(response);
await persist(next);
return next;
} catch (error) {
await clearTokens();
authFailure?.();
throw error;
}
});
}
export async function clearTokens() {
tokens = null;
if (refreshTimer) clearTimeout(refreshTimer);
await secureStore.del(REFRESH_KEY);
}
export async function logout() {
const idToken = tokens?.idToken;
await clearTokens();
if (idToken) {
void fetch(`${oidcIssuer}/protocol/openid-connect/logout`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({ client_id: env.clientId, id_token_hint: idToken }).toString(),
}).catch(() => undefined);
}
}
@@ -0,0 +1,13 @@
const required = (name: string, fallback: string) =>
(process.env[name] ?? fallback).replace(/\/$/, "");
export const env = Object.freeze({
apiBaseUrl: required("EXPO_PUBLIC_API_BASE_URL", "http://localhost:8000"),
authBaseUrl: required("EXPO_PUBLIC_AUTH_BASE_URL", "http://localhost:8080/auth"),
realm: process.env.EXPO_PUBLIC_KEYCLOAK_REALM ?? "han-chat",
clientId: process.env.EXPO_PUBLIC_KEYCLOAK_CLIENT_ID ?? "han-chat-frontend",
appEnv: process.env.EXPO_PUBLIC_APP_ENV ?? "development",
});
export const oidcIssuer = `${env.authBaseUrl}/realms/${encodeURIComponent(env.realm)}`;
export const isProduction = env.appEnv === "production";
@@ -0,0 +1,145 @@
import { env } from "./config";
import { getAccessToken, refreshTokens } from "./auth";
import { dialogApi } from "./services";
import type { Message } from "./types";
export { reconcileMessages } from "./reconcile";
export type RealtimeState = "idle" | "connecting" | "websocket" | "polling";
export type RealtimeEvent =
| { type: "message.new"; dialog_id: string; message: Message; cursor?: string }
| { type: "message.status"; dialog_id: string; message_id: string; safety_status: Message["safety_status"]; delivery_status: Message["delivery_status"]; cursor?: string }
| { type: "dialog.status"; dialog_id: string; status: string; cursor?: string };
const safeCursors = new Map<string, string>();
export const getRealtimeDiagnostics = () =>
[...safeCursors.entries()].map(([dialogId, cursor]) => ({
dialog: `${dialogId.slice(0, 8)}`,
cursor,
}));
export function websocketJwtProtocol(token: string) {
const bytes = new TextEncoder().encode(token);
let binary = "";
bytes.forEach((byte) => { binary += String.fromCharCode(byte); });
return `han.jwt.${btoa(binary).replaceAll("+", "-").replaceAll("/", "_").replace(/=+$/, "")}`;
}
export class RealtimeClient {
private socket?: WebSocket;
private reconnectTimer?: ReturnType<typeof setTimeout>;
private pollingTimer?: ReturnType<typeof setTimeout>;
private disconnectedAt = 0;
private attempt = 0;
private stopped = true;
private cursors = new Map<string, string>();
constructor(
private dialogIds: string[],
private readonly onEvent: (event: RealtimeEvent) => void,
private readonly onMessages: (dialogId: string, messages: Message[]) => void,
private readonly onState: (state: RealtimeState) => void,
) {}
updateDialogs(ids: string[]) {
this.dialogIds = [...new Set(ids)];
if (this.socket?.readyState === WebSocket.OPEN) this.subscribe();
}
start() {
if (!this.stopped) return;
this.stopped = false;
this.connect();
}
stop() {
this.stopped = true;
if (this.reconnectTimer) clearTimeout(this.reconnectTimer);
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.socket?.close();
this.onState("idle");
}
private connect() {
if (this.stopped) return;
const token = getAccessToken();
if (!token) return;
this.onState("connecting");
const url = env.apiBaseUrl.replace(/^http/, "ws") + "/api/v1/realtime";
this.socket = new WebSocket(url, ["han-chat-v1", websocketJwtProtocol(token)]);
this.socket.onopen = () => {
this.attempt = 0;
// Сначала подписываемся, затем читаем REST-gap: события в этом окне
// уже попадут в merge, а дубли устраняются по message_id.
this.subscribe();
void this.reconcileAll().then(() => {
this.stopPolling();
this.onState("websocket");
});
};
this.socket.onmessage = ({ data }) => {
try {
const event = JSON.parse(String(data)) as RealtimeEvent | { type: string };
if (event.type === "ping") return this.socket?.send(JSON.stringify({ type: "pong" }));
if (event.type === "message.new" || event.type === "message.status" || event.type === "dialog.status") {
const known = event as RealtimeEvent;
if (known.cursor) {
this.cursors.set(known.dialog_id, known.cursor);
safeCursors.set(known.dialog_id, known.cursor);
}
this.onEvent(known);
}
} catch { /* unknown and malformed events are safely ignored */ }
};
this.socket.onclose = (event) => {
if (this.stopped) return;
if (!this.disconnectedAt) this.disconnectedAt = Date.now();
if (event.code === 4401 || event.code === 1008) {
void refreshTokens().finally(() => this.scheduleReconnect());
} else {
this.scheduleReconnect();
}
};
this.socket.onerror = () => this.socket?.close();
}
private subscribe() {
this.socket?.send(JSON.stringify({ type: "subscribe", dialog_ids: this.dialogIds }));
}
private scheduleReconnect() {
if (this.stopped) return;
if (Date.now() - this.disconnectedAt >= 30_000) this.startPolling();
const base = Math.min(30_000, 1000 * 2 ** this.attempt++);
const delay = Math.round(base * (0.8 + Math.random() * 0.4));
this.reconnectTimer = setTimeout(() => this.connect(), delay);
}
private async reconcileAll() {
await Promise.all(this.dialogIds.map(async (id) => {
const page = await dialogApi.messages(id, this.cursors.get(id));
if (page.items.length) this.onMessages(id, page.items);
if (page.next_cursor) {
this.cursors.set(id, page.next_cursor);
safeCursors.set(id, page.next_cursor);
}
}));
this.disconnectedAt = 0;
}
private startPolling() {
if (this.pollingTimer) return;
this.onState("polling");
const poll = async () => {
if (this.stopped) return;
await this.reconcileAll().catch(() => undefined);
const delay = typeof document !== "undefined" && document.hidden ? 15_000 : 5_000;
this.pollingTimer = setTimeout(poll, delay);
};
void poll();
}
private stopPolling() {
if (this.pollingTimer) clearTimeout(this.pollingTimer);
this.pollingTimer = undefined;
}
}
@@ -0,0 +1,7 @@
import type { Message } from "./types";
export function reconcileMessages(current: Message[], incoming: Message[]) {
const byId = new Map(current.map((message) => [message.message_id, message]));
for (const message of incoming) byId.set(message.message_id, { ...byId.get(message.message_id), ...message });
return [...byId.values()].sort((a, b) => a.created_at.localeCompare(b.created_at));
}
@@ -0,0 +1,108 @@
import { apiRequest, idempotencyHeaders, json, sessionMemory } from "./api";
import { Platform } from "react-native";
import type {
Consents, Dialog, DocumentItem, Message, Page, Profile, PublicConfig, PublicContent,
} from "./types";
export const publicApi = {
config: () => apiRequest<PublicConfig>("/api/v1/public/app-config"),
content: () => apiRequest<PublicContent>("/api/v1/public/content"),
};
export const authApi = {
bootstrap: (consents: Consents) =>
apiRequest<{ user_id: string; profile_ready: boolean }>("/api/v1/auth/bootstrap", {
method: "POST", protected: true,
body: json({ consents, device: deviceMetadata() }),
}),
startSession: async (reason: "first_launch" | "cold_start" | "idle_timeout") => {
const result = await apiRequest<{ ux_session_id: string; started_at: string }>(
"/api/v1/analytics/session-start",
{ method: "POST", protected: true, body: json({ start_reason: reason, device: deviceMetadata() }) },
);
sessionMemory.set(result.ux_session_id);
return result;
},
saveConsents: (consents: Consents) =>
apiRequest<void>("/api/v1/consents", {
method: "POST", protected: true, body: json({ consents }),
}),
};
function deviceMetadata() {
const platform = Platform.OS;
if (platform !== "ios" && platform !== "android" && platform !== "web") {
throw new Error(`Unsupported platform: ${platform}`);
}
return {
platform,
app_version: "1.0.0",
device_id: "frontend-test-site",
};
}
export const dialogApi = {
list: (cursor?: string) =>
apiRequest<Page<Dialog>>(`/api/v1/dialogs${cursor ? `?cursor=${encodeURIComponent(cursor)}` : ""}`, { protected: true }),
get: (id: string) => apiRequest<Dialog>(`/api/v1/dialogs/${encodeURIComponent(id)}`, { protected: true }),
create: (key: string) =>
apiRequest<Dialog>("/api/v1/dialogs", {
method: "POST", protected: true, headers: idempotencyHeaders(key), body: "{}",
}),
messages: (id: string, after?: string) =>
apiRequest<Page<Message>>(
`/api/v1/dialogs/${encodeURIComponent(id)}/messages?limit=50${after ? `&after=${encodeURIComponent(after)}` : ""}`,
{ protected: true },
),
sendText: (id: string, text: string, key: string) =>
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ content_kind: "text", text }),
}),
sendFile: (id: string, attachmentId: string, checksum: string, key: string) =>
apiRequest<Message>(`/api/v1/dialogs/${encodeURIComponent(id)}/messages`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ content_kind: "file", attachment_id: attachmentId, checksum }),
}),
};
export async function uploadAttachment(dialogId: string, file: File, key = crypto.randomUUID()) {
const checksum = await sha256(file);
const init = await apiRequest<{
attachment_id: string;
upload_url: string;
upload_headers?: Record<string, string>;
expires_at: string;
}>(`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/init`, {
method: "POST", protected: true, headers: idempotencyHeaders(key),
body: json({ file_name: file.name, mime_type: file.type, size_bytes: file.size }),
});
const upload = await fetch(init.upload_url, {
method: "PUT",
headers: init.upload_headers ?? { "Content-Type": file.type },
body: file,
});
if (!upload.ok) throw new Error("Не удалось загрузить файл в хранилище");
await apiRequest<void>(
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(init.attachment_id)}/complete`,
{ method: "POST", protected: true, headers: idempotencyHeaders(key), body: json({ checksum }) },
);
return { attachmentId: init.attachment_id, checksum };
}
async function sha256(file: Blob) {
const digest = await crypto.subtle.digest("SHA-256", await file.arrayBuffer());
return `sha256:${Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, "0")).join("")}`;
}
export const profileApi = {
me: () => apiRequest<Profile>("/api/v1/me", { protected: true }),
documents: () => apiRequest<Page<DocumentItem>>("/api/v1/me/documents", { protected: true }),
documentUrl: (id: string) =>
apiRequest<{ download_url: string }>(`/api/v1/documents/${encodeURIComponent(id)}/download-url`, { protected: true }),
attachmentUrl: (dialogId: string, id: string) =>
apiRequest<{ download_url: string }>(
`/api/v1/dialogs/${encodeURIComponent(dialogId)}/attachments/${encodeURIComponent(id)}/download-url`,
{ protected: true },
),
};
@@ -0,0 +1,10 @@
let uxSessionId: string | null = null;
let lastActivityAt = Date.now();
export const sessionMemory = {
get id() { return uxSessionId; },
get lastActivityAt() { return lastActivityAt; },
touch() { lastActivityAt = Date.now(); },
set(id: string) { uxSessionId = id; lastActivityAt = Date.now(); },
clear() { uxSessionId = null; lastActivityAt = Date.now(); },
};
@@ -0,0 +1,11 @@
export class SingleFlight<T> {
private running: Promise<T> | null = null;
run(operation: () => Promise<T>): Promise<T> {
if (this.running) return this.running;
this.running = operation().finally(() => {
this.running = null;
});
return this.running;
}
}
@@ -0,0 +1,76 @@
export type Consent = { accepted: boolean; version: string };
export type Consents = {
personal_data: Consent;
user_agreement: Consent;
marketing: Consent;
};
export type TokenSet = {
accessToken: string;
refreshToken: string;
expiresAt: number;
idToken?: string;
};
export type DialogStatus = "open" | "waiting_for_company" | "waiting_for_client" | "closed";
export type Dialog = { dialog_id: string; status: DialogStatus; updated_at?: string };
export type Attachment = {
attachment_id: string;
file_name: string;
mime_type: string;
size_bytes: number;
scan_status: "pending" | "clean" | "infected" | "failed";
};
export type Message = {
message_id: string;
dialog_id: string;
sender_type: "client" | "company";
content_kind: "text" | "file";
text: string;
attachments: Attachment[];
safety_status: "pending" | "allowed" | "blocked";
delivery_status: "accepted" | "delivered" | "failed" | "rejected";
created_at: string;
};
export type Page<T> = { items: T[]; next_cursor: string | null };
export type Profile = {
user_id: string;
profile: {
personal_data: {
full_name: string | null;
citizenship: string | null;
russian_phone: string | null;
foreign_phone: string | null;
email: string | null;
};
documents: { count: number };
};
};
export type DocumentItem = {
document_id: string;
name: string;
sent_at: string;
};
export type PublicConfig = {
auth: { phone_enabled: boolean; password_enabled: boolean };
operator: { call_phone: string };
consents: Record<string, {
required: boolean;
document_url: string | null;
version: string;
}>;
attachments: {
max_size_mb: number;
allowed_extensions: string[];
allowed_mime_types: string[];
};
ux: { idle_timeout_minutes: number };
};
export type PublicContent = {
locale: string;
texts: Record<string, string>;
popular_questions: Array<{ id: string; mnemonic: string; text: string }>;
version: string;
};
@@ -0,0 +1,81 @@
import { Link } from "expo-router";
import React from "react";
import { ActivityIndicator, Pressable, StyleSheet, Text, TextInput, View } from "react-native";
import { ApiError } from "./api";
export const styles = StyleSheet.create({
page: { flex: 1, width: "100%", maxWidth: 920, alignSelf: "center", padding: 20, gap: 16 },
header: { flexDirection: "row", flexWrap: "wrap", alignItems: "center", gap: 12, paddingBottom: 12, borderBottomWidth: 1, borderColor: "#d7dee8" },
title: { fontSize: 28, lineHeight: 34, fontWeight: "700", color: "#12233f" },
heading: { fontSize: 20, lineHeight: 26, fontWeight: "700", color: "#12233f" },
text: { fontSize: 16, lineHeight: 23, color: "#233653" },
muted: { fontSize: 14, lineHeight: 20, color: "#5f6f85" },
card: { padding: 16, gap: 10, borderWidth: 1, borderColor: "#d7dee8", borderRadius: 12, backgroundColor: "#fff" },
input: { minHeight: 48, borderWidth: 1, borderColor: "#8493a8", borderRadius: 8, padding: 12, fontSize: 16, backgroundColor: "#fff" },
textarea: { minHeight: 104, textAlignVertical: "top" },
row: { flexDirection: "row", flexWrap: "wrap", gap: 10, alignItems: "center" },
button: { minHeight: 44, justifyContent: "center", paddingHorizontal: 18, borderRadius: 8, backgroundColor: "#185abd" },
buttonSecondary: { backgroundColor: "#e8eef8" },
buttonDanger: { backgroundColor: "#b42318" },
buttonDisabled: { opacity: 0.5 },
buttonText: { color: "#fff", fontWeight: "700", fontSize: 15 },
buttonTextSecondary: { color: "#173b70" },
link: { color: "#075db7", fontSize: 16, textDecorationLine: "underline", paddingVertical: 10 },
badge: { borderRadius: 20, backgroundColor: "#edf2f8", color: "#263b58", paddingHorizontal: 10, paddingVertical: 5, fontSize: 13 },
error: { borderLeftWidth: 4, borderColor: "#b42318", backgroundColor: "#fff1f0", padding: 12, color: "#7a271a" },
success: { borderLeftWidth: 4, borderColor: "#16803c", backgroundColor: "#edfdf2", padding: 12, color: "#14532d" },
modalBackdrop: { position: "absolute", top: 0, right: 0, bottom: 0, left: 0, zIndex: 10, backgroundColor: "rgba(10,25,45,.45)", alignItems: "center", justifyContent: "center", padding: 20 },
modal: { width: "100%", maxWidth: 560, borderRadius: 14, backgroundColor: "#fff", padding: 20, gap: 14 },
messageClient: { alignSelf: "flex-end", maxWidth: "82%", backgroundColor: "#e4efff", padding: 12, borderRadius: 12 },
messageCompany: { alignSelf: "flex-start", maxWidth: "82%", backgroundColor: "#f0f2f5", padding: 12, borderRadius: 12 },
});
export function Header({ status, realtime, onLogout }: { status: string; realtime: string; onLogout?: () => void }) {
return <View style={styles.header}>
<Link href="/" style={styles.link}>HAN Chat</Link>
<Link href="/dialogs" style={styles.link}>Диалоги</Link>
<Link href="/profile" style={styles.link}>Профиль</Link>
<Link href="/diagnostics" style={styles.link}>Диагностика</Link>
<Text style={styles.badge}>{status === "authenticated" ? "Авторизован" : "Гость"} · {realtime}</Text>
{onLogout && <Button title="Выйти" secondary onPress={onLogout} />}
</View>;
}
export function Button({ title, onPress, disabled, secondary, danger }: {
title: string; onPress: () => void; disabled?: boolean; secondary?: boolean; danger?: boolean;
}) {
return <Pressable
accessibilityRole="button"
disabled={disabled}
onPress={onPress}
style={({ focused }) => [
styles.button, secondary && styles.buttonSecondary, danger && styles.buttonDanger,
disabled && styles.buttonDisabled, focused && { borderWidth: 3, borderColor: "#ffbf47" },
]}
>
<Text style={[styles.buttonText, secondary && styles.buttonTextSecondary]}>{title}</Text>
</Pressable>;
}
export function Field(props: React.ComponentProps<typeof TextInput> & { label: string; error?: string }) {
return <View style={{ gap: 6 }}>
<Text style={styles.text}>{props.label}</Text>
<TextInput accessibilityLabel={props.label} {...props} style={[styles.input, props.multiline && styles.textarea, props.style]} />
{props.error && <Text accessibilityRole="alert" style={styles.error}>{props.error}</Text>}
</View>;
}
export function Loading() {
return <View accessibilityRole="progressbar" style={styles.row}><ActivityIndicator /><Text style={styles.muted}>Загрузка</Text></View>;
}
export function ErrorNotice({ error, retry }: { error: unknown; retry?: () => void }) {
const requestId = error instanceof ApiError ? error.requestId : undefined;
return <View style={{ gap: 8 }}>
<Text accessibilityRole="alert" style={styles.error}>
{error instanceof ApiError ? error.message : error instanceof Error ? error.message : "Произошла ошибка"}
{requestId ? `\nКод обращения: ${requestId}` : ""}
</Text>
{retry && <Button title="Повторить" secondary onPress={retry} />}
</View>;
}
@@ -0,0 +1,49 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.route("**/api/v1/public/app-config", (route) => route.fulfill({
json: {
auth: { phone_enabled: true, password_enabled: false },
operator: { call_phone: "+74950000000" },
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" },
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" },
},
},
}));
await page.route("**/api/v1/public/content", (route) => route.fulfill({
json: {
locale: "ru",
texts: { welcome: "Добро пожаловать в HAN Chat" },
popular_questions: [{ id: "visa", mnemonic: "visa", text: "Как оформить визу?" }],
version: "1",
},
}));
});
test("гостевой экран загружает публичный контент", async ({ page }) => {
await page.goto("/");
await expect(page.getByRole("heading", { name: "Помощь мигрантам" })).toBeVisible();
await expect(page.getByText("Добро пожаловать в HAN Chat")).toBeVisible();
await expect(page.getByRole("button", { name: "Как оформить визу?" })).toBeVisible();
await expect(page.getByText(/Гость/)).toBeVisible();
});
test("первое сообщение требует обязательные согласия", async ({ page }) => {
await page.goto("/");
await page.getByLabel("Сообщение").fill("Здравствуйте");
await page.getByRole("button", { name: "Отправить" }).click();
await expect(page.getByRole("heading", { name: "Согласия перед входом" })).toBeVisible();
await expect(page.getByRole("button", { name: "Продолжить" })).toBeDisabled();
});
test("профиль гостя не делает защищённый запрос", async ({ page }) => {
let protectedCalls = 0;
await page.route("**/api/v1/me", (route) => { protectedCalls++; return route.abort(); });
await page.goto("/profile");
await expect(page.getByText("Профиль доступен после авторизации.")).toBeVisible();
expect(protectedCalls).toBe(0);
});
@@ -0,0 +1,64 @@
import { describe, expect, it } from "vitest";
import { reconcileMessages } from "../../src/reconcile";
import { sessionMemory } from "../../src/session";
import { SingleFlight } from "../../src/single-flight";
import { websocketJwtProtocol } from "../../src/realtime";
import type { Message } from "../../src/types";
const message = (id: string, createdAt: string, status: Message["delivery_status"] = "accepted"): Message => ({
message_id: id,
dialog_id: "dialog",
sender_type: "client",
content_kind: "text",
text: "Тест",
attachments: [],
safety_status: "allowed",
delivery_status: status,
created_at: createdAt,
});
describe("reconcileMessages", () => {
it("устраняет дубли, обновляет статус и сортирует сообщения", () => {
const result = reconcileMessages(
[message("2", "2026-01-02T00:00:00Z"), message("1", "2026-01-01T00:00:00Z")],
[message("2", "2026-01-02T00:00:00Z", "delivered"), message("3", "2026-01-03T00:00:00Z")],
);
expect(result.map((item) => item.message_id)).toEqual(["1", "2", "3"]);
expect(result[1]?.delivery_status).toBe("delivered");
});
});
describe("UX session memory", () => {
it("хранит идентификатор только в памяти и очищает его", () => {
sessionMemory.set("ux-test");
expect(sessionMemory.id).toBe("ux-test");
sessionMemory.clear();
expect(sessionMemory.id).toBeNull();
expect(localStorage.getItem("ux_session_id")).toBeNull();
});
});
describe("SingleFlight", () => {
it("объединяет параллельные refresh операции", async () => {
const flight = new SingleFlight<number>();
let calls = 0;
const operation = async () => {
calls++;
await Promise.resolve();
return 42;
};
const [first, second, third] = await Promise.all([
flight.run(operation), flight.run(operation), flight.run(operation),
]);
expect([first, second, third]).toEqual([42, 42, 42]);
expect(calls).toBe(1);
});
});
describe("WebSocket authentication protocol", () => {
it("кодирует JWT как canonical han.jwt.<base64url(jwt)>", () => {
const protocol = websocketJwtProtocol("header.payload.signature");
expect(protocol).toBe("han.jwt.aGVhZGVyLnBheWxvYWQuc2lnbmF0dXJl");
expect(protocol).not.toContain("=");
});
});
@@ -0,0 +1,14 @@
{
"extends": "expo/tsconfig.base",
"compilerOptions": {
"strict": true,
"noUncheckedIndexedAccess": true,
"exactOptionalPropertyTypes": true,
"noImplicitOverride": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": { "@/*": ["src/*"] },
"types": ["vitest/globals"]
},
"include": ["app", "src", "tests", "app.config.ts", "expo-env.d.ts"]
}
@@ -0,0 +1,11 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
test: {
environment: "jsdom",
include: ["tests/unit/**/*.test.{ts,tsx}"],
clearMocks: true,
},
});