diff --git a/Dev_Docs/API/02_Authentication_API.md b/Dev_Docs/API/02_Authentication_API.md index d70c27b..6d11fdb 100644 --- a/Dev_Docs/API/02_Authentication_API.md +++ b/Dev_Docs/API/02_Authentication_API.md @@ -308,6 +308,7 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce} - `ListEspPairingRequests` - `ApproveEspPairing` - `RejectEspPairing` +- `CancelEspPairing` - `GetEspPairingStatus` В этом потоке: diff --git a/Dev_Docs/API/03_Session_Management_API.md b/Dev_Docs/API/03_Session_Management_API.md index 0c2a8df..0762e70 100644 --- a/Dev_Docs/API/03_Session_Management_API.md +++ b/Dev_Docs/API/03_Session_Management_API.md @@ -13,6 +13,7 @@ - `ListEspPairingRequests` - `ApproveEspPairing` - `RejectEspPairing` +- `CancelEspPairing` Анонимное новое устройство работает с двумя связанными операциями: @@ -261,7 +262,8 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M - `400 / BAD_SESSION_TYPE` - `400 / BAD_PAYLOAD_TYPE` - `422 / PAIRING_NOT_AVAILABLE` -- `422 / PAIRING_PASSWORD_INVALID` +- `422 / PAIRING_PASSWORD_INVALID` — pairing-пароль не подходит. Та же ошибка возвращается и если новое устройство ввело пароль, а у пользователя режим pairing включён без пароля. +- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся. - `429 / PAIRING_RATE_LIMITED` ### 5.3. `ListEspPairingRequests` @@ -388,4 +390,46 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M - `created` - `approved` - `rejected` +- `canceled` - `expired` + +### 5.7. `CancelEspPairing` + +Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL. + +### Запрос + +```json +{ + "op": "CancelEspPairing", + "requestId": "esp-cancel-001", + "payload": { + "pairingId": "base64url", + "requesterSessionKey": "ed25519/BASE64_PUBLIC_KEY" + } +} +``` + +### Успешный ответ + +```json +{ + "op": "CancelEspPairing", + "requestId": "esp-cancel-001", + "status": 200, + "ok": true, + "payload": { + "pairingId": "base64url", + "state": "canceled" + } +} +``` + +### Ошибки + +- `400 / EMPTY_PAIRING_ID` +- `400 / EMPTY_REQUESTER_SESSION_KEY` +- `400 / BAD_REQUESTER_SESSION_KEY` +- `404 / PAIRING_NOT_FOUND` +- `422 / PAIRING_OF_ANOTHER_REQUESTER` +- `422 / PAIRING_NOT_PENDING` diff --git a/Dev_Docs/API/09_Operations_Index.md b/Dev_Docs/API/09_Operations_Index.md index 83c7806..93702fa 100644 --- a/Dev_Docs/API/09_Operations_Index.md +++ b/Dev_Docs/API/09_Operations_Index.md @@ -24,6 +24,7 @@ | `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии | | `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией | | `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией | +| `CancelEspPairing` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства | | `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки | | `ListSessions` | `03_Session_Management_API.md` | список активных сессий | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | diff --git a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md index 5d326d1..99fda0a 100644 --- a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -18,6 +18,10 @@ - отдельно проверить отклонение заявки и истечение TTL. - при отклонении заявки убедиться, что на новом устройстве сразу исчезает карточка со старым 7-значным кодом и остаётся только сообщение об отклонении; - убедиться, что экран `Войти` больше не показывает неработающую QR-заглушку сверху. + - убедиться, что при неверном pairing-пароле и при попытке ввести пароль там, где он не включён, пользователь видит одинаковую ошибку `Пароль подключения не подходит.`; + - убедиться, что без онлайн доверённой сессии новое устройство сразу получает явную ошибку и код вообще не создаётся; + - убедиться, что countdown под кодом убывает в реальном времени; + - убедиться, что кнопка `Отмена` на новом устройстве действительно снимает заявку и она пропадает у доверённого устройства без ожидания TTL. - ожидаемый результат: - новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить; diff --git a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java index beff033..9c90c97 100644 --- a/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java +++ b/SHiNE-server/shine-server-db/src/main/java/shine/db/dao/EspPairingRequestsDAO.java @@ -199,6 +199,22 @@ public final class EspPairingRequestsDAO { }); } + public void markCanceled(String pairingId, String rejectReason, long updatedAtMs) throws SQLException { + updateSimple(pairingId, """ + UPDATE esp_pairing_requests + SET status = 'canceled', + reject_reason = ?, + approved_by_session_id = NULL, + encrypted_payload = NULL, + updated_at_ms = ? + WHERE pairing_id = ? + """, ps -> { + ps.setString(1, rejectReason); + ps.setLong(2, updatedAtMs); + ps.setString(3, pairingId); + }); + } + public int expirePending(long nowMs) throws SQLException { try (Connection c = db.getConnection(); PreparedStatement ps = c.prepareStatement(""" diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java index 295bcb3..2748f8f 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/JsonHandlerRegistry.java @@ -9,6 +9,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_CreateAuthSession__Handle import server.logic.ws_protocol.JSON.handlers.auth.Net_ListSessions_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_Handler; +import server.logic.ws_protocol.JSON.handlers.auth.Net_CancelEspPairing_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler; @@ -27,6 +28,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRe // --- NEW v2 entities --- import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request; @@ -138,6 +140,7 @@ public final class JsonHandlerRegistry { Map.entry("ListEspPairingRequests", new Net_ListEspPairingRequests_Handler()), Map.entry("ApproveEspPairing", new Net_ApproveEspPairing_Handler()), Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()), + Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()), Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()), // --- blockchain --- @@ -202,6 +205,7 @@ public final class JsonHandlerRegistry { Map.entry("ListEspPairingRequests", Net_ListEspPairingRequests_Request.class), Map.entry("ApproveEspPairing", Net_ApproveEspPairing_Request.class), Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class), + Map.entry("CancelEspPairing", Net_CancelEspPairing_Request.class), Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class), // --- blockchain --- diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java index 170f540..6d9efb6 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/EspPairingSupport.java @@ -27,6 +27,7 @@ final class EspPairingSupport { static final String STATE_CREATED = "created"; static final String STATE_APPROVED = "approved"; static final String STATE_REJECTED = "rejected"; + static final String STATE_CANCELED = "canceled"; static final String STATE_EXPIRED = "expired"; private static final SecureRandom RANDOM = new SecureRandom(); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java new file mode 100644 index 0000000..67b14cd --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_CancelEspPairing_Handler.java @@ -0,0 +1,60 @@ +package server.logic.ws_protocol.JSON.handlers.auth; + +import server.logic.ws_protocol.JSON.ConnectionContext; +import server.logic.ws_protocol.JSON.entyties.Net_Request; +import server.logic.ws_protocol.JSON.entyties.Net_Response; +import server.logic.ws_protocol.JSON.handlers.JsonMessageHandler; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Request; +import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_CancelEspPairing_Response; +import server.logic.ws_protocol.JSON.utils.AuthKeyUtils; +import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory; +import server.logic.ws_protocol.WireCodes; +import shine.db.dao.EspPairingRequestsDAO; +import shine.db.entities.EspPairingRequestEntry; + +public class Net_CancelEspPairing_Handler implements JsonMessageHandler { + + @Override + public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception { + Net_CancelEspPairing_Request req = (Net_CancelEspPairing_Request) baseReq; + + String pairingId = req.getPairingId() == null ? "" : req.getPairingId().trim(); + if (pairingId.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_PAIRING_ID", "Пустой pairingId"); + } + + String requesterSessionKey = req.getRequesterSessionKey(); + if (requesterSessionKey == null || requesterSessionKey.isBlank()) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "EMPTY_REQUESTER_SESSION_KEY", "Пустой requesterSessionKey"); + } + try { + requesterSessionKey = AuthKeyUtils.normalize(requesterSessionKey, "requesterSessionKey"); + AuthKeyUtils.parseEd25519PublicKey(requesterSessionKey, "requesterSessionKey"); + } catch (Exception e) { + return NetExceptionResponseFactory.error(req, WireCodes.Status.BAD_REQUEST, "BAD_REQUESTER_SESSION_KEY", "Некорректный requesterSessionKey"); + } + + long now = System.currentTimeMillis(); + EspPairingRequestsDAO.getInstance().expirePending(now); + EspPairingRequestEntry row = EspPairingRequestsDAO.getInstance().getByPairingId(pairingId); + if (row == null) { + return NetExceptionResponseFactory.error(req, 404, "PAIRING_NOT_FOUND", "Pairing-заявка не найдена"); + } + if (!requesterSessionKey.equals(row.getRequesterSessionKey())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_OF_ANOTHER_REQUESTER", "Нельзя отменять pairing другого устройства"); + } + if (!EspPairingSupport.STATE_CREATED.equals(row.getStatus())) { + return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_PENDING", "Заявка уже не находится в статусе created"); + } + + EspPairingRequestsDAO.getInstance().markCanceled(pairingId, "canceled_by_requester", now); + + Net_CancelEspPairing_Response resp = new Net_CancelEspPairing_Response(); + resp.setOp(req.getOp()); + resp.setRequestId(req.getRequestId()); + resp.setStatus(WireCodes.Status.OK); + resp.setPairingId(pairingId); + resp.setState(EspPairingSupport.STATE_CANCELED); + return resp; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java index f7b2c2f..b9d1e7a 100644 --- a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/Net_StartEspPairing_Handler.java @@ -83,15 +83,23 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { } String configuredPasswordHash = settings.getPasswordHash() == null ? "" : settings.getPasswordHash().trim(); boolean requiresPassword = !configuredPasswordHash.isBlank(); - if (requiresPassword && !configuredPasswordHash.equals(passwordHash)) { + boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank(); + if ((requiresPassword && !configuredPasswordHash.equals(passwordHash)) + || (!requiresPassword && suppliedPassword)) { return NetExceptionResponseFactory.error(req, 422, "PAIRING_PASSWORD_INVALID", "Неверный pairing-пароль"); } - if (!requiresPassword && passwordHash != null && !passwordHash.isBlank()) { - passwordHash = ""; - } String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds()); + List approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); + if (approverConnections.isEmpty()) { + return NetExceptionResponseFactory.error( + req, + 422, + "PAIRING_NO_TRUSTED_SESSION_ONLINE", + "Нет ни одной активной доверенной сессии пользователя в сети" + ); + } EspPairingSupport.PairingFingerprint fingerprint = EspPairingSupport.deriveFingerprint( canonicalLogin, requesterSessionKey, @@ -117,7 +125,6 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler { entry.setDeliveredToHomeserver(false); EspPairingRequestsDAO.getInstance().insert(entry); - List approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); boolean delivered = false; for (ConnectionContext targetCtx : approverConnections) { String eventId = NetIdGenerator.eventId("pair"); diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java new file mode 100644 index 0000000..24196ef --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Request.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Request; + +public class Net_CancelEspPairing_Request extends Net_Request { + private String pairingId; + private String requesterSessionKey; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getRequesterSessionKey() { + return requesterSessionKey; + } + + public void setRequesterSessionKey(String requesterSessionKey) { + this.requesterSessionKey = requesterSessionKey; + } +} diff --git a/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java new file mode 100644 index 0000000..575cb2a --- /dev/null +++ b/SHiNE-server/shine-server-net-protocol/src/main/java/server/logic/ws_protocol/JSON/handlers/auth/entyties/Net_CancelEspPairing_Response.java @@ -0,0 +1,24 @@ +package server.logic.ws_protocol.JSON.handlers.auth.entyties; + +import server.logic.ws_protocol.JSON.entyties.Net_Response; + +public class Net_CancelEspPairing_Response extends Net_Response { + private String pairingId; + private String state; + + public String getPairingId() { + return pairingId; + } + + public void setPairingId(String pairingId) { + this.pairingId = pairingId; + } + + public String getState() { + return state; + } + + public void setState(String state) { + this.state = state; + } +} diff --git a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java index 95970ad..7c9fa45 100644 --- a/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java +++ b/SHiNE-server/src/test/java/test/it/cases/IT_07_EspPairing.java @@ -94,6 +94,44 @@ public class IT_07_EspPairing { ); assertEquals(200, JsonParsers.status(startNoPasswordResp), "StartEspPairing without password must be 200"); + String startWrongPasswordResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, passwordHash, requesterNoPasswordMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertErrorFormat(startWrongPasswordResp, "StartEspPairing", "PAIRING_PASSWORD_INVALID"); + + SessionMaterial cancelMaterial = newSessionMaterial(); + String startCancelableResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", cancelMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertEquals(200, JsonParsers.status(startCancelableResp), "StartEspPairing for cancel must be 200"); + String cancelPairingId = JsonParsers.payloadText(startCancelableResp, "pairingId"); + String cancelResp = requesterWs.call( + "CancelEspPairing", + JsonBuilders.cancelEspPairing(cancelPairingId, cancelMaterial.sessionKey()), + t + ); + assertEquals(200, JsonParsers.status(cancelResp), "CancelEspPairing must be 200"); + assertEquals("canceled", JsonParsers.payloadText(cancelResp, "state")); + + String closeResp = clientWs.call( + "CloseActiveSession", + JsonBuilders.closeActiveSession(clientSession.sessionId(), 0, ""), + t + ); + assertEquals(200, JsonParsers.status(closeResp), "CloseActiveSession must be 200"); + + SessionMaterial requesterOfflineMaterial = newSessionMaterial(); + String startOfflineResp = requesterWs.call( + "StartEspPairing", + JsonBuilders.startEspPairing(LOGIN, "", requesterOfflineMaterial.sessionKey(), 1, "Android", 1), + t + ); + assertErrorFormat(startOfflineResp, "StartEspPairing", "PAIRING_NO_TRUSTED_SESSION_ONLINE"); + String forbiddenResp = requesterWs.call( "ListEspPairingRequests#anonymous", JsonBuilders.listEspPairingRequests(), diff --git a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java index f7ef249..3952366 100644 --- a/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java +++ b/SHiNE-server/src/test/java/test/it/utils/json/JsonBuilders.java @@ -333,6 +333,20 @@ public final class JsonBuilders { """.formatted(requestId, pairingId, reason == null ? "" : reason); } + public static String cancelEspPairing(String pairingId, String requesterSessionKey) { + String requestId = TestIds.next("esp-cancel"); + return """ + { + "op": "CancelEspPairing", + "requestId": "%s", + "payload": { + "pairingId": "%s", + "requesterSessionKey": "%s" + } + } + """.formatted(requestId, pairingId, requesterSessionKey); + } + public static String getEspPairingStatus(String pairingId) { String requestId = TestIds.next("esp-status"); return """ diff --git a/VERSION.properties b/VERSION.properties index d88584a..5a753b1 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.199 -server.version=1.2.188 +client.version=1.2.200 +server.version=1.2.189 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index d798963..8948d07 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -42,7 +42,7 @@ import * as topupView from './pages/topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginCameraView from './pages/login-camera-view.js'; -import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150110'; +import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606150215'; import * as loginPasswordView from './pages/login-password-view.js'; import * as keyStorageView from './pages/key-storage-view.js'; diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index 40cc7ae..32c2073 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -38,10 +38,11 @@ function codeCardHtml() { `; } -function formatExpiresAt(ms) { - const ts = Number(ms || 0); - if (!Number.isFinite(ts) || ts <= 0) return ''; - return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' }); +function formatRemaining(ms) { + const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000)); + const minutes = Math.floor(safe / 60); + const seconds = safe % 60; + return `${minutes} мин ${seconds} сек`; } function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) { @@ -56,8 +57,10 @@ export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; let pollTimer = 0; + let countdownTimer = 0; let activePairingId = ''; let requesterMaterial = null; + let activeExpiresAtMs = 0; let isDisposed = false; clearAuthMessages(); @@ -73,8 +76,8 @@ export function render({ navigate }) { formCard.className = 'card stack'; formCard.innerHTML = ` -

Сначала вводится ваш логин. Если на доверённом устройстве включён доп. пароль, включите галочку и введите его. После этого появится 7-значный код для подтверждения.

+

Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.

`; const status = document.createElement('p'); @@ -106,13 +109,18 @@ export function render({ navigate }) { const statusHintEl = resultWrap.querySelector('#pairing-status-hint'); const onlineHintEl = resultWrap.querySelector('#pairing-online-hint'); const expireHintEl = resultWrap.querySelector('#pairing-expire-hint'); + const cancelBtn = document.createElement('button'); + cancelBtn.className = 'ghost-btn'; + cancelBtn.type = 'button'; + cancelBtn.textContent = 'Отмена'; + cancelBtn.style.display = 'none'; const syncPasswordUi = () => { const usePassword = !!usePasswordInput.checked; passwordInput.parentElement.style.display = usePassword ? '' : 'none'; modeHintEl.textContent = usePassword - ? 'Введите логин и доп. пароль, который был задан на доверённом устройстве.' - : 'Введите логин. Если на доверённом устройстве пароль не задан, вход пойдёт без доп. пароля.'; + ? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.' + : 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.'; if (!usePassword) { passwordInput.value = ''; } @@ -125,6 +133,45 @@ export function render({ navigate }) { } }; + const stopCountdown = () => { + if (countdownTimer) { + window.clearInterval(countdownTimer); + countdownTimer = 0; + } + }; + + const updateCountdown = () => { + const leftMs = activeExpiresAtMs - Date.now(); + if (leftMs <= 0) { + stopPolling(); + stopCountdown(); + activePairingId = ''; + activeExpiresAtMs = 0; + startBtn.disabled = false; + cancelBtn.style.display = 'none'; + resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); + setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); + return; + } + expireHintEl.textContent = `Код действителен ещё ${formatRemaining(leftMs)}.`; + }; + + const startCountdown = (expiresAtMs) => { + activeExpiresAtMs = Number(expiresAtMs || 0); + stopCountdown(); + updateCountdown(); + countdownTimer = window.setInterval(updateCountdown, 1000); + }; + + const clearActivePairing = () => { + stopPolling(); + stopCountdown(); + activePairingId = ''; + activeExpiresAtMs = 0; + cancelBtn.style.display = 'none'; + resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); + }; + const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); await clearStoredMessages().catch(() => {}); @@ -159,6 +206,7 @@ export function render({ navigate }) { return; } if (stateValue === 'approved') { + stopCountdown(); setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info'); const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial); if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') { @@ -168,18 +216,20 @@ export function render({ navigate }) { return; } if (stateValue === 'rejected') { - stopPolling(); - activePairingId = ''; + clearActivePairing(); startBtn.disabled = false; - resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error'); return; } - if (stateValue === 'expired') { - stopPolling(); - activePairingId = ''; + if (stateValue === 'canceled') { + clearActivePairing(); + startBtn.disabled = false; + setStatus(status, 'Ожидание подключения отменено.', 'error'); + return; + } + if (stateValue === 'expired') { + clearActivePairing(); startBtn.disabled = false; - resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); return; } @@ -215,9 +265,7 @@ export function render({ navigate }) { setAuthError(''); setAuthInfo(''); setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info'); - stopPolling(); - activePairingId = ''; - resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); + clearActivePairing(); try { await authService.reconnect(state.entrySettings.shineServer); @@ -247,10 +295,9 @@ export function render({ navigate }) { onlineHintEl.textContent = payload?.trustedSessionOnline ? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.' : 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.'; - expireHintEl.textContent = payload?.expiresAtMs - ? `Код действует до ${formatExpiresAt(payload.expiresAtMs)}.` - : ''; resultWrap.style.display = ''; + cancelBtn.style.display = ''; + startCountdown(payload?.expiresAtMs); state.loginDraft.login = login; setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info'); schedulePoll(); @@ -264,11 +311,40 @@ export function render({ navigate }) { } }); + cancelBtn.addEventListener('click', async () => { + if (!activePairingId || !requesterMaterial?.sessionKey) { + clearActivePairing(); + startBtn.disabled = false; + cancelBtn.style.display = 'none'; + return; + } + cancelBtn.disabled = true; + try { + await authService.cancelEspPairing(activePairingId, requesterMaterial.sessionKey); + clearActivePairing(); + startBtn.disabled = false; + setStatus(status, 'Ожидание подключения отменено.', 'info'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось отменить ожидание подключения.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + cancelBtn.disabled = false; + cancelBtn.style.display = activePairingId ? '' : 'none'; + } + }); + screen.cleanup = () => { isDisposed = true; stopPolling(); + stopCountdown(); }; + const resultActions = document.createElement('div'); + resultActions.className = 'row'; + resultActions.append(cancelBtn); + resultWrap.append(resultActions); + screen.append(formCard, status, resultWrap); return screen; } diff --git a/shine-UI/js/services/auth-service.js b/shine-UI/js/services/auth-service.js index c453d7c..0491d1b 100644 --- a/shine-UI/js/services/auth-service.js +++ b/shine-UI/js/services/auth-service.js @@ -1020,6 +1020,15 @@ export class AuthService { return response.payload || {}; } + async cancelEspPairing(pairingId, requesterSessionKey) { + const response = await this.ws.request('CancelEspPairing', { + pairingId: String(pairingId || '').trim(), + requesterSessionKey: String(requesterSessionKey || '').trim(), + }); + if (response.status !== 200) throw opError('CancelEspPairing', response); + return response.payload || {}; + } + async getEspPairingStatus(pairingId) { const response = await this.ws.request('GetEspPairingStatus', { pairingId: String(pairingId || '').trim(), diff --git a/shine-UI/js/services/ui-error-texts.js b/shine-UI/js/services/ui-error-texts.js index 384b331..7e0b6d2 100644 --- a/shine-UI/js/services/ui-error-texts.js +++ b/shine-UI/js/services/ui-error-texts.js @@ -50,6 +50,14 @@ export function toUserMessage(error, fallback = 'Действие не выпо return 'Пользователь не найден. Проверьте логин.'; } + if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') { + return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.'; + } + + if (code === 'PAIRING_PASSWORD_INVALID') { + return 'Пароль подключения не подходит.'; + } + if ( code === 'PREMIUMLOGIN' || text.includes('premiumlogin') ||