diff --git a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties index 988f125..8383777 100644 --- a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties +++ b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/messages/messages_ru.properties @@ -17,6 +17,8 @@ otpResendCountdown=Отправить повторно через otpResend=Отправить код снова authBack=Назад verifyOtp=Подтвердить +otpVerifying=Проверяем... +otpSubmitUnavailable=Не удалось отправить код. Проверьте соединение и попробуйте ещё раз. mockMode=Тестовый режим отправки кода phoneInvalid=Проверьте формат номера телефона. otpInvalid=Код неверен, истёк или уже использован. diff --git a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/otp.ftl b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/otp.ftl index 87814f6..b47209c 100644 --- a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/otp.ftl +++ b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/otp.ftl @@ -61,12 +61,18 @@ - - + <#if captchaEnabled!false> diff --git a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js index ea9e1e8..899df1d 100644 --- a/VM1_app/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js +++ b/VM1_app/codebase/backend/keycloak/themes/han-phone/login/resources/js/han-login.js @@ -56,16 +56,28 @@ function initOtpForm() { var fields = Array.prototype.slice.call(document.querySelectorAll(".han-otp-digit")); + var form = document.getElementById("kc-otp-form"); var hidden = document.getElementById("otp"); var submit = document.getElementById("han-otp-submit"); - if (!fields.length || !hidden || !submit) return; + var submitError = document.getElementById("han-otp-submit-error"); + if (!fields.length || !form || !hidden || !submit) return; + var submitLabel = submit.textContent; + var submitting = false; + + function submitForm() { + if (typeof form.requestSubmit === "function") { + form.requestSubmit(); + return; + } + HTMLFormElement.prototype.submit.call(form); + } function syncOtp() { fields.forEach(function (field) { field.classList.toggle("han-filled", Boolean(field.value)); }); hidden.value = fields.map(function (field) { return field.value; }).join(""); - submit.disabled = hidden.value.length !== fields.length; + submit.disabled = submitting || hidden.value.length !== fields.length; } fields.forEach(function (field, index) { @@ -106,6 +118,22 @@ }); syncOtp(); + submit.addEventListener("click", function (event) { + event.preventDefault(); + syncOtp(); + if (submit.disabled || submitting) return; + submitting = true; + submit.disabled = true; + submit.textContent = submit.getAttribute("data-submitting-label") || submitLabel; + if (submitError) submitError.hidden = true; + submitForm(); + window.setTimeout(function () { + submitting = false; + submit.textContent = submitLabel; + syncOtp(); + if (submitError) submitError.hidden = false; + }, 12000); + }); var countdown = document.getElementById("han-resend-countdown"); var countdownValue = countdown && countdown.querySelector("strong"); @@ -126,9 +154,17 @@ } updateCountdown(); if (expiresAt > Date.now()) timer = window.setInterval(updateCountdown, 1000); - resend.addEventListener("click", function () { + resend.addEventListener("click", function (event) { if (document.getElementById("han-captcha-container")) return; - window.setTimeout(function () { resend.disabled = true; }, 0); + event.preventDefault(); + resend.disabled = true; + var action = document.createElement("input"); + action.type = "hidden"; + action.name = "otp_action"; + action.value = "resend"; + form.appendChild(action); + submitForm(); + window.setTimeout(function () { resend.disabled = false; }, 12000); }); } @@ -142,8 +178,11 @@ var widgetId = null; var executing = false; var pendingSubmitter = null; + var executionTimeout = null; function showError() { + if (executionTimeout) window.clearTimeout(executionTimeout); + executionTimeout = null; executing = false; if (pendingSubmitter) pendingSubmitter.disabled = false; pendingSubmitter = null; @@ -162,6 +201,8 @@ invisible: true, hl: "ru", callback: function (token) { + if (executionTimeout) window.clearTimeout(executionTimeout); + executionTimeout = null; if (!token || !pendingSubmitter) { showError(); return; @@ -209,6 +250,7 @@ if (pendingSubmitter) pendingSubmitter.disabled = true; tokenInput.value = ""; try { + executionTimeout = window.setTimeout(showError, 12000); window.smartCaptcha.execute(widgetId); } catch (_error) { showError(); diff --git a/VM4_Expo-mobile/.assets/icons/adaptive-foreground.png b/VM4_Expo-mobile/.assets/icons/adaptive-foreground.png new file mode 100644 index 0000000..68a425d Binary files /dev/null and b/VM4_Expo-mobile/.assets/icons/adaptive-foreground.png differ diff --git a/VM4_Expo-mobile/.assets/icons/adaptive-monochrome.png b/VM4_Expo-mobile/.assets/icons/adaptive-monochrome.png new file mode 100644 index 0000000..1d6c51e Binary files /dev/null and b/VM4_Expo-mobile/.assets/icons/adaptive-monochrome.png differ diff --git a/VM4_Expo-mobile/.assets/icons/icon.png b/VM4_Expo-mobile/.assets/icons/icon.png new file mode 100644 index 0000000..5e6227d Binary files /dev/null and b/VM4_Expo-mobile/.assets/icons/icon.png differ diff --git a/VM4_Expo-mobile/.env.example b/VM4_Expo-mobile/.env.example new file mode 100644 index 0000000..10972b1 --- /dev/null +++ b/VM4_Expo-mobile/.env.example @@ -0,0 +1,7 @@ +EXPO_PUBLIC_API_BASE_URL=https://chat.example.ru +EXPO_PUBLIC_AUTH_BASE_URL=https://chat.example.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-код запрещены. diff --git a/VM4_Expo-mobile/.gitignore b/VM4_Expo-mobile/.gitignore new file mode 100644 index 0000000..ba26e7d --- /dev/null +++ b/VM4_Expo-mobile/.gitignore @@ -0,0 +1,16 @@ +node_modules/ +.expo/ +dist/ +.env +.env.* +!.env.example +*.log +android/ +ios/ +.DS_Store + +# @generated expo-cli sync-2b81b286409207a5da26e14c78851eb30d8ccbdb +# The following patterns were generated by expo-cli + +expo-env.d.ts +# @end expo-cli \ No newline at end of file diff --git a/VM4_Expo-mobile/.npmrc b/VM4_Expo-mobile/.npmrc new file mode 100644 index 0000000..521a9f7 --- /dev/null +++ b/VM4_Expo-mobile/.npmrc @@ -0,0 +1 @@ +legacy-peer-deps=true diff --git a/VM4_Expo-mobile/README.md b/VM4_Expo-mobile/README.md new file mode 100644 index 0000000..f087180 --- /dev/null +++ b/VM4_Expo-mobile/README.md @@ -0,0 +1,28 @@ +# HAN Chat Mobile + +Мобильный Android-клиент HAN Chat на Expo SDK 57. + +## Подготовка + +1. Установить Node.js и npm. +2. Скопировать `.env.example` в `.env` и указать адреса API и Keycloak. +3. Установить зависимости: + + ```bash + npm install + ``` + +4. Зарегистрировать `han-chat://auth/callback` как допустимый redirect URI клиента Keycloak. + +## Локальные проверки + +```bash +npm run typecheck +npm test +``` + +Запуск приложения выполняется отдельно командой `npm run android`. Проект использует managed Expo workflow и не хранит каталоги `android/` и `ios/`. + +## Сборка + +Профили development, preview и production определены в `eas.json`. Перед первой EAS-сборкой потребуется привязать Expo-проект и настроить signing credentials. diff --git a/VM4_Expo-mobile/app.config.ts b/VM4_Expo-mobile/app.config.ts new file mode 100644 index 0000000..cdc54e3 --- /dev/null +++ b/VM4_Expo-mobile/app.config.ts @@ -0,0 +1,50 @@ +import type { ExpoConfig } from "expo/config"; + +const config: ExpoConfig = { + name: "HAN Chat", + slug: "han-chat", + owner: "anzh", + version: "1.0.0", + scheme: "han-chat", + icon: "./.assets/icons/icon.png", + orientation: "portrait", + userInterfaceStyle: "light", + android: { + package: "ru.han.chat", + softwareKeyboardLayoutMode: "resize", + adaptiveIcon: { + foregroundImage: "./.assets/icons/adaptive-foreground.png", + monochromeImage: "./.assets/icons/adaptive-monochrome.png", + backgroundColor: "#4A0E1E", + }, + intentFilters: [ + { + action: "VIEW", + autoVerify: false, + data: [ + { + scheme: "han-chat", + host: "auth", + pathPrefix: "/callback", + }, + ], + category: ["BROWSABLE", "DEFAULT"], + }, + ], + }, + experiments: { + typedRoutes: true, + }, + extra: { + eas: { + projectId: "53d1a38c-f888-40ca-b485-58f5301e4dff", + }, + }, + plugins: [ + "expo-router", + "expo-secure-store", + "expo-document-picker", + ], +}; + +export default config; diff --git a/VM4_Expo-mobile/app/_layout.tsx b/VM4_Expo-mobile/app/_layout.tsx new file mode 100644 index 0000000..14a662b --- /dev/null +++ b/VM4_Expo-mobile/app/_layout.tsx @@ -0,0 +1,17 @@ +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"; +import { colors } from "../src/theme"; + +export default function RootLayout() { + return + + + + + + + ; +} diff --git a/VM4_Expo-mobile/app/auth/callback.tsx b/VM4_Expo-mobile/app/auth/callback.tsx new file mode 100644 index 0000000..13ba5dd --- /dev/null +++ b/VM4_Expo-mobile/app/auth/callback.tsx @@ -0,0 +1,63 @@ +import { useLocalSearchParams, useRouter } from "expo-router"; +import React, { useEffect, useRef, useState } from "react"; +import { Text, View } from "react-native"; +import { clearPendingConsents, loadPendingConsents, useApp } from "../../src/app-context"; +import { AuthLoadingView } from "../../src/components/AuthLoadingView"; +import { ScreenShell } from "../../src/components/ScreenShell"; +import { pendingDialogKey, restorePendingIntents } from "../../src/pending-intent"; +import { spacing } from "../../src/theme"; +import { Button, ErrorNotice, styles } from "../../src/ui"; + +export default function AuthCallbackScreen() { + const params = useLocalSearchParams<{ code?: string; state?: string; error?: string }>(); + const app = useApp(); + const router = useRouter(); + const [error, setError] = useState(); + const completionStarted = useRef(false); + + const complete = async () => { + if (!params.code || !params.state) return; + const consents = await loadPendingConsents(); + if (!consents) throw new Error("Не найдены локально принятые согласия. Начните вход заново."); + setError(undefined); + await app.finishCallback(params.code, params.state, consents); + await clearPendingConsents(); + await restorePendingIntents(); + + if (pendingDialogKey()) { + router.replace("/dialogs?pending=1"); + return; + } + + router.replace("/"); + }; + + useEffect(() => { + if (completionStarted.current) return; + if (params.error) { + completionStarted.current = true; + setError(new Error("Авторизация отменена или отклонена.")); + return; + } + if (!params.code || !params.state) return; + completionStarted.current = true; + void complete().catch(setError); + }, [params.code, params.state, params.error]); + + return ( + + {!error ? ( + + ) : ( + + Не удалось завершить вход + <> + +