Разработана первая версия приложений
This commit is contained in:
@@ -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>;
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user