TrustedDeviceLogin API и настройки входа через устройство

Что сделано:\n- публичный API сценария входа через доверенное устройство переведён на TrustedDeviceLogin\n- добавлен GetTrustedDeviceLoginSettings\n- отсутствие записи настроек на сервере теперь трактуется как enabled=true и hasPassword=false\n- ttlSeconds убран из клиентского API, TTL заявки фиксирован на сервере: 300 секунд\n- в shine-UI добавлен отдельный экран настроек входа через устройство и статус на основном экране\n- browser wallet переведён на новые TrustedDeviceLogin операции\n- в wallet добавлен выбор rootKey/deviceKey для будущего запроса подписи\n- документация API обновлена\n\nЧто ещё не проверено вручную end-to-end:\n- полный сценарий UI/plugin после этого деплоя не прогонялся руками до конца\n- сам signaling подписи в wallet всё ещё не реализован
This commit is contained in:
AidarKC 2026-06-18 14:19:31 +04:00
parent cf2152dcfc
commit 56db6d0add
29 changed files with 689 additions and 241 deletions

View File

@ -303,13 +303,14 @@ SESSION_LOGIN:{sessionId}:{timeMs}:{nonce}
Новые `op`, относящиеся к этому сценарию: Новые `op`, относящиеся к этому сценарию:
- `UpsertEspPairingSettings` - `GetTrustedDeviceLoginSettings`
- `StartEspPairing` - `UpsertTrustedDeviceLoginSettings`
- `ListEspPairingRequests` - `StartTrustedDeviceLogin`
- `ApproveEspPairing` - `ListTrustedDeviceLoginRequests`
- `RejectEspPairing` - `ApproveTrustedDeviceLogin`
- `CancelEspPairing` - `RejectTrustedDeviceLogin`
- `GetEspPairingStatus` - `CancelTrustedDeviceLogin`
- `GetTrustedDeviceLoginStatus`
В этом потоке: В этом потоке:

View File

@ -9,16 +9,17 @@
Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя: Дополнительно в этом же слое управления сессиями появился сценарий pairing через доверенную уже авторизованную сессию пользователя:
- `UpsertEspPairingSettings` - `GetTrustedDeviceLoginSettings`
- `ListEspPairingRequests` - `UpsertTrustedDeviceLoginSettings`
- `ApproveEspPairing` - `ListTrustedDeviceLoginRequests`
- `RejectEspPairing` - `ApproveTrustedDeviceLogin`
- `CancelEspPairing` - `RejectTrustedDeviceLogin`
- `CancelTrustedDeviceLogin`
Анонимное новое устройство работает с двумя связанными операциями: Анонимное новое устройство работает с двумя связанными операциями:
- `StartEspPairing` - `StartTrustedDeviceLogin`
- `GetEspPairingStatus` - `GetTrustedDeviceLoginStatus`
Логика раздела такая: Логика раздела такая:
@ -167,11 +168,11 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
--- ---
## 5. ESP pairing через доверенную сессию ## 5. TrustedDeviceLogin через доверенную сессию
Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя. Этот блок относится к сценарию добавления новой сессии через доверенное устройство пользователя.
### 5.1. `UpsertEspPairingSettings` ### 5.1. `GetTrustedDeviceLoginSettings`
Доступно для любой уже авторизованной доверенной сессии пользователя. Доступно для любой уже авторизованной доверенной сессии пользователя.
@ -179,17 +180,57 @@ K9v3nQ4u8jYk0a2p7cD4mLx1zR0sT5wV6bN8eH3fQ1M
```json ```json
{ {
"op": "UpsertEspPairingSettings", "op": "GetTrustedDeviceLoginSettings",
"requestId": "esp-set-001", "requestId": "trusted-login-get-001",
"payload": { "payload": {
"enabled": true,
"passwordHash": "sha256$0123abcd...",
"ttlSeconds": 180
} }
} }
``` ```
Если pairing должен работать **без доп. пароля**, клиент может включить его с пустым `passwordHash`. ### Успешный ответ
```json
{
"op": "GetTrustedDeviceLoginSettings",
"requestId": "trusted-login-get-001",
"status": 200,
"ok": true,
"payload": {
"enabled": true,
"hasPassword": false
}
}
```
Если отдельной записи настроек на сервере ещё нет, сервер считает состояние по умолчанию таким:
- `enabled = true`
- `hasPassword = false`
### Ошибки
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
### 5.2. `UpsertTrustedDeviceLoginSettings`
Доступно для любой уже авторизованной доверенной сессии пользователя.
### Запрос
```json
{
"op": "UpsertTrustedDeviceLoginSettings",
"requestId": "esp-set-001",
"payload": {
"enabled": true,
"passwordHash": "sha256$0123abcd..."
}
}
```
Если вход через доверенное устройство должен работать **без доп. пароля**, клиент включает его с пустым `passwordHash`.
Если `enabled = false`, сервер автоматически удаляет пароль и запрещает вход через другое устройство.
Формат непустого `passwordHash`: Формат непустого `passwordHash`:
@ -201,13 +242,13 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "UpsertEspPairingSettings", "op": "UpsertTrustedDeviceLoginSettings",
"requestId": "esp-set-001", "requestId": "esp-set-001",
"status": 200, "status": 200,
"ok": true, "ok": true,
"payload": { "payload": {
"enabled": true, "enabled": true,
"ttlSeconds": 180 "hasPassword": true
} }
} }
``` ```
@ -216,7 +257,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
- `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя. - `463 / PAIRING_REQUIRES_AUTH_SESSION` — операция вызвана без уже авторизованной доверенной сессии пользователя.
### 5.2. `StartEspPairing` ### 5.3. `StartTrustedDeviceLogin`
Эта операция доступна без уже существующей пользовательской сессии. Эта операция доступна без уже существующей пользовательской сессии.
@ -224,7 +265,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "StartEspPairing", "op": "StartTrustedDeviceLogin",
"requestId": "esp-start-001", "requestId": "esp-start-001",
"payload": { "payload": {
"login": "alice", "login": "alice",
@ -237,15 +278,17 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
} }
``` ```
Если на доверённом устройстве pairing включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`. Если на доверённом устройстве вход включён **без доп. пароля**, новое устройство может отправить пустой `passwordHash`.
Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку. Поле `trustedSessionOnline` показывает, что у пользователя сейчас есть хотя бы одна онлайн доверенная сессия, способная принять pairing-заявку.
TTL заявки фиксирован на сервере и сейчас всегда равен `300` секундам.
### Успешный ответ ### Успешный ответ
```json ```json
{ {
"op": "StartEspPairing", "op": "StartTrustedDeviceLogin",
"requestId": "esp-start-001", "requestId": "esp-start-001",
"status": 200, "status": 200,
"ok": true, "ok": true,
@ -272,7 +315,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
- `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся. - `422 / PAIRING_NO_TRUSTED_SESSION_ONLINE` — сейчас нет ни одной онлайн доверённой сессии пользователя, поэтому код не создаётся.
- `429 / PAIRING_RATE_LIMITED` - `429 / PAIRING_RATE_LIMITED`
### 5.3. `ListEspPairingRequests` ### 5.4. `ListTrustedDeviceLoginRequests`
Доступно для любой уже авторизованной доверенной сессии пользователя. Доступно для любой уже авторизованной доверенной сессии пользователя.
Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают. Возвращает только реально активные pending-заявки со `state = created`. Уже `approved` и `rejected` заявки в этот список больше не попадают.
@ -281,7 +324,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "ListEspPairingRequests", "op": "ListTrustedDeviceLoginRequests",
"requestId": "esp-list-001", "requestId": "esp-list-001",
"status": 200, "status": 200,
"ok": true, "ok": true,
@ -309,7 +352,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
- `463 / PAIRING_REQUIRES_AUTH_SESSION` - `463 / PAIRING_REQUIRES_AUTH_SESSION`
### 5.4. `ApproveEspPairing` ### 5.5. `ApproveTrustedDeviceLogin`
Доступно для любой уже авторизованной доверенной сессии пользователя. Доступно для любой уже авторизованной доверенной сессии пользователя.
@ -317,7 +360,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "ApproveEspPairing", "op": "ApproveTrustedDeviceLogin",
"requestId": "esp-approve-001", "requestId": "esp-approve-001",
"payload": { "payload": {
"pairingId": "base64url", "pairingId": "base64url",
@ -330,7 +373,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "ApproveEspPairing", "op": "ApproveTrustedDeviceLogin",
"requestId": "esp-approve-001", "requestId": "esp-approve-001",
"status": 200, "status": 200,
"ok": true, "ok": true,
@ -351,11 +394,11 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
- `422 / PAIRING_EXPIRED` - `422 / PAIRING_EXPIRED`
- `463 / PAIRING_REQUIRES_AUTH_SESSION` - `463 / PAIRING_REQUIRES_AUTH_SESSION`
### 5.5. `RejectEspPairing` ### 5.6. `RejectTrustedDeviceLogin`
Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`. Доступно для любой уже авторизованной доверенной сессии пользователя. Похоже на approve, но переводит заявку в `state=rejected`.
### 5.6. `GetEspPairingStatus` ### 5.7. `GetTrustedDeviceLoginStatus`
Операция для нового устройства. Операция для нового устройства.
@ -363,7 +406,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "GetEspPairingStatus", "op": "GetTrustedDeviceLoginStatus",
"requestId": "esp-status-001", "requestId": "esp-status-001",
"payload": { "payload": {
"pairingId": "base64url" "pairingId": "base64url"
@ -375,7 +418,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "GetEspPairingStatus", "op": "GetTrustedDeviceLoginStatus",
"requestId": "esp-status-001", "requestId": "esp-status-001",
"status": 200, "status": 200,
"ok": true, "ok": true,
@ -399,7 +442,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
- `canceled` - `canceled`
- `expired` - `expired`
### 5.7. `CancelEspPairing` ### 5.8. `CancelTrustedDeviceLogin`
Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL. Операция для нового устройства, которое уже создало pairing-заявку и хочет принудительно снять ожидание до истечения TTL.
@ -407,7 +450,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "CancelEspPairing", "op": "CancelTrustedDeviceLogin",
"requestId": "esp-cancel-001", "requestId": "esp-cancel-001",
"payload": { "payload": {
"pairingId": "base64url", "pairingId": "base64url",
@ -420,7 +463,7 @@ sha256$<hex( SHA-256("shine-pairing|" + lower(login.trim()) + "|" + password) )>
```json ```json
{ {
"op": "CancelEspPairing", "op": "CancelTrustedDeviceLogin",
"requestId": "esp-cancel-001", "requestId": "esp-cancel-001",
"status": 200, "status": 200,
"ok": true, "ok": true,

View File

@ -19,13 +19,14 @@
| `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии | | `CreateAuthSession` | `02_Authentication_API.md` | создание новой авторизованной сессии |
| `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию | | `SessionChallenge` | `02_Authentication_API.md` | challenge для входа в существующую сессию |
| `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию | | `SessionLogin` | `02_Authentication_API.md` | вход в существующую сессию |
| `UpsertEspPairingSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией | | `GetTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | чтение текущего режима входа через доверенное устройство |
| `StartEspPairing` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства | | `UpsertTrustedDeviceLoginSettings` | `03_Session_Management_API.md` | включение/обновление pairing-настроек доверенной сессией |
| `ListEspPairingRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии | | `StartTrustedDeviceLogin` | `03_Session_Management_API.md` | создание pairing-заявки для нового устройства |
| `ApproveEspPairing` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией | | `ListTrustedDeviceLoginRequests` | `03_Session_Management_API.md` | список активных pairing-заявок для доверенной сессии |
| `RejectEspPairing` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией | | `ApproveTrustedDeviceLogin` | `03_Session_Management_API.md` | подтверждение pairing-заявки доверенной сессией |
| `CancelEspPairing` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства | | `RejectTrustedDeviceLogin` | `03_Session_Management_API.md` | отклонение pairing-заявки доверенной сессией |
| `GetEspPairingStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки | | `CancelTrustedDeviceLogin` | `03_Session_Management_API.md` | отмена pairing-заявки со стороны нового ожидающего устройства |
| `GetTrustedDeviceLoginStatus` | `03_Session_Management_API.md` | чтение статуса и результата pairing-заявки |
| `ListSessions` | `03_Session_Management_API.md` | список активных сессий | | `ListSessions` | `03_Session_Management_API.md` | список активных сессий |
| `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии | | `CloseActiveSession` | `03_Session_Management_API.md` | закрытие активной сессии |
| `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн | | `AddBlock` | `04_Add_Block_to_Blockchain_API.md` | добавление блока в блокчейн |

View File

@ -4,10 +4,11 @@
`SHiNE-browser-plugin-wallet` после session-only подключения сохраняет `login`, публичные `root/device/blockchain` ключи из PDA и список опубликованных `homeserver`-сессий. Постоянное подключение не удерживается: plugin остаётся офлайн, а список trusted devices обновляет по запросу. `SHiNE-browser-plugin-wallet` после session-only подключения сохраняет `login`, публичные `root/device/blockchain` ключи из PDA и список опубликованных `homeserver`-сессий. Постоянное подключение не удерживается: plugin остаётся офлайн, а список trusted devices обновляет по запросу.
- что проверять: - что проверять:
0. На стартовом экране plugin для подключения запрашивается только логин пользователя; серверный логин вручную не вводится, а показывается как информационная строка после чтения PDA.
1. После успешного pairing plugin показывает сохранённую wallet-session без автоматического постоянного подключения. 1. После успешного pairing plugin показывает сохранённую wallet-session без автоматического постоянного подключения.
2. В карточке wallet-session виден сокращённый `deviceKey`. 2. В карточке wallet-session виден сокращённый `deviceKey`.
3. Кнопка `Обновить устройства` подтягивает homeserver-сессии из PDA и показывает их список со статусом `online/offline/unknown`. 3. Кнопка `Обновить устройства` подтягивает homeserver-сессии из PDA и показывает их список со статусом `online/offline/unknown`.
4. В селекте ключа подписи доступен `deviceKey (ed25519, ...)`. 4. В селекте ключа подписи доступны `rootKey (ed25519, ...)` и `deviceKey (ed25519, ...)`.
5. Кнопка `Запросить подпись` не падает и честно сообщает, что signaling подписи ещё не доделан. 5. Кнопка `Запросить подпись` не падает и честно сообщает, что signaling подписи ещё не доделан.
- ожидаемый результат: - ожидаемый результат:

View File

@ -0,0 +1,24 @@
# TrustedDeviceLogin settings и новый режим по умолчанию
- краткое описание:
серверный API сценария входа через доверенное устройство переименован в `TrustedDeviceLogin`, добавлен `GetTrustedDeviceLoginSettings`, а отсутствие серверной записи настроек теперь трактуется как `enabled = true` и `hasPassword = false`. В UI вынесен отдельный экран настроек входа через доверенное устройство.
- что проверять:
1. Для логина без записи в `esp_pairing_settings` `StartTrustedDeviceLogin` работает без предварительного ручного включения.
2. Экран `Подключить по коду` показывает один из трёх статусов:
- вход запрещён;
- вход разрешён без пароля;
- вход разрешён только с паролем.
3. Кнопка `Изменить настройки входа` открывает отдельный экран.
4. На отдельном экране:
- можно запретить вход;
- можно разрешить вход;
- можно задать новый пароль;
- можно сделать вход без пароля.
5. `Войти через другое устройство` в основном UI и в browser wallet работает через новые `TrustedDeviceLogin`-операции.
- ожидаемый результат:
вход через доверенное устройство по умолчанию доступен без лишнего ручного включения, а текущий режим и пароль управляются с отдельного экрана настроек.
- статус:
pending

View File

@ -4,7 +4,7 @@ Chrome-compatible Manifest V3 plugin for SHiNE wallet-session login.
## Что уже умеет ## Что уже умеет
- создать `wallet-session` через `StartEspPairing`; - создать `wallet-session` через `StartTrustedDeviceLogin`;
- показать код подключения; - показать код подключения;
- дождаться подтверждения на доверенном устройстве; - дождаться подтверждения на доверенном устройстве;
- принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`; - принять `session-only` payload без передачи `deviceKey/rootKey/blockchainKey`;

View File

@ -4,9 +4,8 @@ import { ShineApiClient } from './js/lib/shine-api.js';
import { import {
DEFAULT_SHINE_SERVER_LOGIN, DEFAULT_SHINE_SERVER_LOGIN,
buildHttpBase, buildHttpBase,
normalizeServerLogin,
readWalletProfileByLogin, readWalletProfileByLogin,
resolveShineServerByServerLogin, resolveShineServerByUserLogin,
} from './js/lib/shine-server-resolver.js'; } from './js/lib/shine-server-resolver.js';
const state = { const state = {
@ -65,23 +64,10 @@ function ensureApi(serverUrl = state.settings.serverUrl) {
return state.api; return state.api;
} }
async function resolveSettingsServer(nextSettings = {}) {
const serverLogin = normalizeServerLogin(nextSettings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN)
|| DEFAULT_SHINE_SERVER_LOGIN;
const resolved = await resolveShineServerByServerLogin(serverLogin);
return {
serverLogin: resolved.serverLogin,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
}
async function loadStateFromStorage() { async function loadStateFromStorage() {
const settings = await loadPluginSettings(); const settings = await loadPluginSettings();
const storedServerLogin = normalizeServerLogin(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN)
|| DEFAULT_SHINE_SERVER_LOGIN;
state.settings = { state.settings = {
serverLogin: storedServerLogin, serverLogin: String(settings?.serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(),
serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'), serverHttp: String(settings?.serverHttp || state.settings.serverHttp || buildHttpBase('shineup.me')).trim() || buildHttpBase('shineup.me'),
serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws', serverUrl: String(settings?.serverUrl || state.settings.serverUrl || 'wss://shineup.me/ws').trim() || 'wss://shineup.me/ws',
login: String(settings?.login || '').trim(), login: String(settings?.login || '').trim(),
@ -96,16 +82,38 @@ async function loadStateFromStorage() {
} }
async function persistSettings(nextSettings = {}) { async function persistSettings(nextSettings = {}) {
const resolved = await resolveSettingsServer(nextSettings);
state.settings = { state.settings = {
...state.settings, ...state.settings,
...nextSettings, ...nextSettings,
...resolved,
}; };
await savePluginSettings(state.settings); await savePluginSettings(state.settings);
return state.settings; return state.settings;
} }
async function resolveServerForLogin(login) {
const cleanLogin = String(login || state.settings.login || '').trim();
if (!cleanLogin) {
state.settings = {
...state.settings,
login: '',
serverLogin: '',
};
await savePluginSettings(state.settings);
return { ok: true, resolved: false };
}
const resolved = await resolveShineServerByUserLogin(cleanLogin);
state.settings = {
...state.settings,
login: cleanLogin,
serverLogin: resolved.serverLogin,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
await savePluginSettings(state.settings);
return { ok: true, resolved: true, ...resolved };
}
async function saveActiveSessionRecord() { async function saveActiveSessionRecord() {
if (!state.activeSession) return; if (!state.activeSession) return;
const nextRecord = { const nextRecord = {
@ -124,15 +132,47 @@ function shortKey(value = '', size = 10) {
return raw ? raw.slice(0, size) : ''; return raw ? raw.slice(0, size) : '';
} }
function extractErrorCode(message = '') {
const match = String(message || '').match(/\(([A-Z0-9_]+)\)\s*$/i);
return match ? String(match[1]).toUpperCase() : '';
}
function toWalletErrorMessage(error, fallback = 'Не удалось выполнить операцию кошелька.') {
const raw = String(error?.message || '').trim();
const code = String(error?.code || extractErrorCode(raw) || '').toUpperCase();
if (code === 'PAIRING_NOT_AVAILABLE') {
return 'Для этого логина ещё не включено подключение по коду. На доверенном устройстве откройте «Подключить по коду» и нажмите «Включить подключение по коду» или задайте дополнительный пароль.';
}
if (code === 'PAIRING_NO_TRUSTED_SESSION_ONLINE') {
return 'Сейчас нет ни одной онлайн доверенной сессии этого пользователя. Откройте SHiNE на другом уже подключённом устройстве и держите его в сети.';
}
if (code === 'PAIRING_PASSWORD_INVALID') {
return 'Дополнительный пароль подключения не подходит.';
}
return raw || fallback;
}
function buildSigningKeyOptions(walletProfile) { function buildSigningKeyOptions(walletProfile) {
const rootKey = String(walletProfile?.publicKeys?.rootKeyBase58 || '').trim();
const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim(); const deviceKey = String(walletProfile?.publicKeys?.deviceKeyBase58 || '').trim();
if (!deviceKey) return []; const options = [];
return [{ if (rootKey) {
id: 'device', options.push({
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`, id: 'root',
keyType: 'ed25519', label: `rootKey (ed25519, ${shortKey(rootKey)})`,
publicKeyBase58: deviceKey, keyType: 'ed25519',
}]; publicKeyBase58: rootKey,
});
}
if (deviceKey) {
options.push({
id: 'device',
label: `deviceKey (ed25519, ${shortKey(deviceKey)})`,
keyType: 'ed25519',
publicKeyBase58: deviceKey,
});
}
return options;
} }
function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) { function mergeHomeserverStatuses(publishedHomeservers = [], serverSessions = []) {
@ -248,6 +288,8 @@ async function attachApprovedSession(payload) {
await saveActiveSessionRecord(); await saveActiveSessionRecord();
await persistSettings({ await persistSettings({
login: sessionRecord.login, login: sessionRecord.login,
serverLogin: sessionRecord.serverLogin,
serverHttp: sessionRecord.serverHttp,
serverUrl: sessionRecord.serverUrl, serverUrl: sessionRecord.serverUrl,
}); });
state.connectionOnline = false; state.connectionOnline = false;
@ -256,7 +298,7 @@ async function attachApprovedSession(payload) {
async function pollPairingStatus() { async function pollPairingStatus() {
if (!state.pairingId || !state.requesterMaterial) return; if (!state.pairingId || !state.requesterMaterial) return;
try { try {
const payload = await ensureApi().getEspPairingStatus(state.pairingId); const payload = await ensureApi().getTrustedDeviceLoginStatus(state.pairingId);
const stateValue = String(payload?.state || ''); const stateValue = String(payload?.state || '');
if (stateValue === 'created') { if (stateValue === 'created') {
state.pollTimer = setTimeout(() => { state.pollTimer = setTimeout(() => {
@ -290,16 +332,14 @@ async function pollPairingStatus() {
} }
} }
async function startPairing({ login, usePassword, password, serverLogin }) { async function startPairing({ login, usePassword, password }) {
const cleanLogin = String(login || '').trim(); const cleanLogin = String(login || '').trim();
if (!cleanLogin) { if (!cleanLogin) {
throw new Error('Введите логин.'); throw new Error('Введите логин.');
} }
await persistSettings({ await persistSettings({ login: cleanLogin });
serverLogin: String(serverLogin || state.settings.serverLogin || DEFAULT_SHINE_SERVER_LOGIN).trim(), await resolveServerForLogin(cleanLogin);
login: cleanLogin,
});
clearPairingState(); clearPairingState();
setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info'); setStatus('Проверяем пользователя и создаём wallet-session заявку...', 'info');
@ -313,7 +353,7 @@ async function startPairing({ login, usePassword, password, serverLogin }) {
const passwordHash = usePassword const passwordHash = usePassword
? await deriveEspPairingPasswordHash(cleanLogin, String(password || '')) ? await deriveEspPairingPasswordHash(cleanLogin, String(password || ''))
: ''; : '';
const payload = await api.startEspPairing({ const payload = await api.startTrustedDeviceLogin({
login: cleanLogin, login: cleanLogin,
passwordHash, passwordHash,
requesterSessionKey: state.requesterMaterial.sessionKey, requesterSessionKey: state.requesterMaterial.sessionKey,
@ -346,7 +386,7 @@ async function cancelPairing() {
clearPairingState(); clearPairingState();
return { ok: true }; return { ok: true };
} }
await ensureApi().cancelEspPairing(state.pairingId, state.requesterMaterial.sessionKey); await ensureApi().cancelTrustedDeviceLogin(state.pairingId, state.requesterMaterial.sessionKey);
clearPairingState(); clearPairingState();
setStatus('Ожидание подключения отменено.', 'info'); setStatus('Ожидание подключения отменено.', 'info');
return { ok: true }; return { ok: true };
@ -468,6 +508,11 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
sendResponse({ ok: true, state: snapshot() }); sendResponse({ ok: true, state: snapshot() });
return; return;
} }
if (type === 'wallet:resolveServerInfo') {
const result = await resolveServerForLogin(String(message?.payload?.login || '').trim());
sendResponse({ ok: true, result, state: snapshot() });
return;
}
if (type === 'wallet:startPairing') { if (type === 'wallet:startPairing') {
const result = await startPairing(message?.payload || {}); const result = await startPairing(message?.payload || {});
sendResponse({ ok: true, result, state: snapshot() }); sendResponse({ ok: true, result, state: snapshot() });
@ -505,8 +550,9 @@ chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
} }
sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' }); sendResponse({ ok: false, error: 'UNKNOWN_MESSAGE' });
})().catch((error) => { })().catch((error) => {
setStatus(error?.message || 'Unknown error', 'error'); const message = toWalletErrorMessage(error, 'Unknown error');
sendResponse({ ok: false, error: error?.message || 'Unknown error', state: snapshot() }); setStatus(message, 'error');
sendResponse({ ok: false, error: message, state: snapshot() });
}); });
return true; return true;
}); });

View File

@ -39,8 +39,8 @@ export class ShineApiClient {
return response.payload || {}; return response.payload || {};
} }
async startEspPairing({ login, passwordHash, requesterSessionKey, payloadType = 1 }) { async startTrustedDeviceLogin({ login, passwordHash, requesterSessionKey, payloadType = 1 }) {
const response = await this.ws.request('StartEspPairing', { const response = await this.ws.request('StartTrustedDeviceLogin', {
login: String(login || '').trim(), login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(), passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(), requesterSessionKey: String(requesterSessionKey || '').trim(),
@ -48,24 +48,24 @@ export class ShineApiClient {
requesterClientPlatform: 'Chrome Extension Wallet', requesterClientPlatform: 'Chrome Extension Wallet',
payloadType: Number(payloadType) || 1, payloadType: Number(payloadType) || 1,
}); });
if (response.status !== 200) throw opError('StartEspPairing', response); if (response.status !== 200) throw opError('StartTrustedDeviceLogin', response);
return response.payload || {}; return response.payload || {};
} }
async getEspPairingStatus(pairingId) { async getTrustedDeviceLoginStatus(pairingId) {
const response = await this.ws.request('GetEspPairingStatus', { const response = await this.ws.request('GetTrustedDeviceLoginStatus', {
pairingId: String(pairingId || '').trim(), pairingId: String(pairingId || '').trim(),
}); });
if (response.status !== 200) throw opError('GetEspPairingStatus', response); if (response.status !== 200) throw opError('GetTrustedDeviceLoginStatus', response);
return response.payload || {}; return response.payload || {};
} }
async cancelEspPairing(pairingId, requesterSessionKey) { async cancelTrustedDeviceLogin(pairingId, requesterSessionKey) {
const response = await this.ws.request('CancelEspPairing', { const response = await this.ws.request('CancelTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(), pairingId: String(pairingId || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(), requesterSessionKey: String(requesterSessionKey || '').trim(),
}); });
if (response.status !== 200) throw opError('CancelEspPairing', response); if (response.status !== 200) throw opError('CancelTrustedDeviceLogin', response);
return response.payload || {}; return response.payload || {};
} }

View File

@ -204,6 +204,25 @@ export async function resolveShineServerByServerLogin(serverLogin, solanaEndpoin
}; };
} }
export async function resolveShineServerByUserLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login);
if (!cleanLogin) throw new Error('Не указан логин пользователя.');
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);
const serverLogin = normalizeServerLogin(parsed.accessServers?.[0] || '');
if (!serverLogin) {
throw new Error(`У пользователя @${cleanLogin} в PDA не найден первый сервер доступа.`);
}
const resolved = await resolveShineServerByServerLogin(serverLogin, solanaEndpoint);
return {
login: cleanLogin,
accessServers: parsed.accessServers,
serverLogin: resolved.serverLogin,
serverAddress: resolved.serverAddress,
serverHttp: resolved.serverHttp,
serverUrl: resolved.serverUrl,
};
}
export async function readWalletProfileByLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) { export async function readWalletProfileByLogin(login, solanaEndpoint = SOLANA_ENDPOINT_DEFAULT) {
const cleanLogin = normalizeServerLogin(login); const cleanLogin = normalizeServerLogin(login);
const parsed = await fetchUserPda(cleanLogin, solanaEndpoint); const parsed = await fetchUserPda(cleanLogin, solanaEndpoint);

View File

@ -17,11 +17,8 @@
<span id="connection-pill" class="pill pill-offline">offline</span> <span id="connection-pill" class="pill pill-offline">offline</span>
</div> </div>
<label class="field"> <p id="server-login-info" class="muted small">Сервер SHiNE: —</p>
<span>Логин сервера SHiNE</span> <p id="server-address" class="muted small">Адрес: —</p>
<input id="server-url" type="text" placeholder="shineupme" />
</label>
<p id="server-address" class="muted small">Текущий адрес: https://shineup.me</p>
<div id="session-card" class="card hidden"> <div id="session-card" class="card hidden">
<div class="card-title">Подключённая wallet-session</div> <div class="card-title">Подключённая wallet-session</div>

View File

@ -1,5 +1,5 @@
const els = { const els = {
serverUrl: document.querySelector('#server-url'), serverLoginInfo: document.querySelector('#server-login-info'),
serverAddress: document.querySelector('#server-address'), serverAddress: document.querySelector('#server-address'),
loginInput: document.querySelector('#login-input'), loginInput: document.querySelector('#login-input'),
usePassword: document.querySelector('#use-password'), usePassword: document.querySelector('#use-password'),
@ -99,13 +99,16 @@ function renderHomeserverList(items = []) {
function applyState(nextState) { function applyState(nextState) {
state = nextState || state; state = nextState || state;
const serverValue = String(state?.settings?.serverLogin || 'shineupme');
const serverAddressValue = String(state?.settings?.serverHttp || 'https://shineup.me');
const loginValue = String(state?.settings?.login || ''); const loginValue = String(state?.settings?.login || '');
if (document.activeElement !== els.serverUrl) { const resolvedServerLogin = String(state?.settings?.serverLogin || '').trim();
els.serverUrl.value = serverValue; const resolvedServerAddress = String(state?.settings?.serverHttp || '').trim();
if (loginValue && resolvedServerLogin && resolvedServerAddress) {
els.serverLoginInfo.textContent = `Сервер SHiNE: ${resolvedServerLogin}`;
els.serverAddress.textContent = `Адрес: ${resolvedServerAddress}`;
} else {
els.serverLoginInfo.textContent = 'Сервер SHiNE: —';
els.serverAddress.textContent = 'Адрес: —';
} }
els.serverAddress.textContent = `Текущий адрес: ${serverAddressValue}`;
if (document.activeElement !== els.loginInput) { if (document.activeElement !== els.loginInput) {
els.loginInput.value = loginValue; els.loginInput.value = loginValue;
} }
@ -202,11 +205,23 @@ async function refreshState() {
async function saveSettings() { async function saveSettings() {
await sendMessage('wallet:saveSettings', { await sendMessage('wallet:saveSettings', {
serverLogin: String(els.serverUrl.value || '').trim(),
login: String(els.loginInput.value || '').trim(), login: String(els.loginInput.value || '').trim(),
}); });
} }
async function resolveServerInfo() {
const login = String(els.loginInput.value || '').trim();
if (!login) {
await sendMessage('wallet:saveSettings', { login: '' });
return;
}
try {
await sendMessage('wallet:resolveServerInfo', { login });
} catch (error) {
setStatus(error.message || 'Не удалось определить сервер SHiNE по PDA.', 'error');
}
}
function scheduleSaveSettings() { function scheduleSaveSettings() {
if (saveSettingsTimer) { if (saveSettingsTimer) {
window.clearTimeout(saveSettingsTimer); window.clearTimeout(saveSettingsTimer);
@ -230,7 +245,6 @@ async function startPairing() {
login, login,
usePassword: !!els.usePassword.checked, usePassword: !!els.usePassword.checked,
password: String(els.passwordInput.value || ''), password: String(els.passwordInput.value || ''),
serverLogin: String(els.serverUrl.value || '').trim(),
}); });
applyState(response.state); applyState(response.state);
} catch (error) { } catch (error) {
@ -314,10 +328,11 @@ function bindUi() {
els.passwordInput.value = ''; els.passwordInput.value = '';
} }
}); });
els.serverUrl.addEventListener('input', () => { scheduleSaveSettings(); });
els.serverUrl.addEventListener('change', () => { void saveSettings(); });
els.loginInput.addEventListener('input', () => { scheduleSaveSettings(); }); els.loginInput.addEventListener('input', () => { scheduleSaveSettings(); });
els.loginInput.addEventListener('change', () => { void saveSettings(); }); els.loginInput.addEventListener('change', () => {
void saveSettings();
void resolveServerInfo();
});
els.startBtn.addEventListener('click', () => { void startPairing(); }); els.startBtn.addEventListener('click', () => { void startPairing(); });
els.cancelBtn.addEventListener('click', () => { void cancelPairing(); }); els.cancelBtn.addEventListener('click', () => { void cancelPairing(); });
els.resumeBtn.addEventListener('click', () => { void resumeSession(); }); els.resumeBtn.addEventListener('click', () => { void resumeSession(); });

View File

@ -11,6 +11,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.Net_ListEspPairingRequests_Ha
import server.logic.ws_protocol.JSON.handlers.auth.Net_ApproveEspPairing_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_CancelEspPairing_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_GetEspPairingStatus_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_GetTrustedDeviceLoginSettings_Handler;
import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler; import server.logic.ws_protocol.JSON.handlers.auth.Net_RejectEspPairing_Handler;
// --- NEW v2 session login --- // --- NEW v2 session login ---
@ -30,6 +31,7 @@ import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ListEspPairingRe
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_ApproveEspPairing_Request; 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_CancelEspPairing_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetEspPairingStatus_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetTrustedDeviceLoginSettings_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_RejectEspPairing_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; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionChallenge_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request; import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_SessionLogin_Request;
@ -142,6 +144,14 @@ public final class JsonHandlerRegistry {
Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()), Map.entry("RejectEspPairing", new Net_RejectEspPairing_Handler()),
Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()), Map.entry("CancelEspPairing", new Net_CancelEspPairing_Handler()),
Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()), Map.entry("GetEspPairingStatus", new Net_GetEspPairingStatus_Handler()),
Map.entry("GetTrustedDeviceLoginSettings", new Net_GetTrustedDeviceLoginSettings_Handler()),
Map.entry("UpsertTrustedDeviceLoginSettings", new Net_UpsertEspPairingSettings_Handler()),
Map.entry("StartTrustedDeviceLogin", new Net_StartEspPairing_Handler()),
Map.entry("ListTrustedDeviceLoginRequests", new Net_ListEspPairingRequests_Handler()),
Map.entry("ApproveTrustedDeviceLogin", new Net_ApproveEspPairing_Handler()),
Map.entry("RejectTrustedDeviceLogin", new Net_RejectEspPairing_Handler()),
Map.entry("CancelTrustedDeviceLogin", new Net_CancelEspPairing_Handler()),
Map.entry("GetTrustedDeviceLoginStatus", new Net_GetEspPairingStatus_Handler()),
// --- blockchain --- // --- blockchain ---
Map.entry("AddBlock", new Net_AddBlock_Handler()), Map.entry("AddBlock", new Net_AddBlock_Handler()),
@ -207,6 +217,14 @@ public final class JsonHandlerRegistry {
Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class), Map.entry("RejectEspPairing", Net_RejectEspPairing_Request.class),
Map.entry("CancelEspPairing", Net_CancelEspPairing_Request.class), Map.entry("CancelEspPairing", Net_CancelEspPairing_Request.class),
Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class), Map.entry("GetEspPairingStatus", Net_GetEspPairingStatus_Request.class),
Map.entry("GetTrustedDeviceLoginSettings", Net_GetTrustedDeviceLoginSettings_Request.class),
Map.entry("UpsertTrustedDeviceLoginSettings", Net_UpsertEspPairingSettings_Request.class),
Map.entry("StartTrustedDeviceLogin", Net_StartEspPairing_Request.class),
Map.entry("ListTrustedDeviceLoginRequests", Net_ListEspPairingRequests_Request.class),
Map.entry("ApproveTrustedDeviceLogin", Net_ApproveEspPairing_Request.class),
Map.entry("RejectTrustedDeviceLogin", Net_RejectEspPairing_Request.class),
Map.entry("CancelTrustedDeviceLogin", Net_CancelEspPairing_Request.class),
Map.entry("GetTrustedDeviceLoginStatus", Net_GetEspPairingStatus_Request.class),
// --- blockchain --- // --- blockchain ---
Map.entry("AddBlock", Net_AddBlock_Request.class), Map.entry("AddBlock", Net_AddBlock_Request.class),

View File

@ -0,0 +1,43 @@
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_GetTrustedDeviceLoginSettings_Request;
import server.logic.ws_protocol.JSON.handlers.auth.entyties.Net_GetTrustedDeviceLoginSettings_Response;
import server.logic.ws_protocol.JSON.utils.NetExceptionResponseFactory;
import shine.db.dao.EspPairingSettingsDAO;
import shine.db.entities.EspPairingSettingsEntry;
public class Net_GetTrustedDeviceLoginSettings_Handler implements JsonMessageHandler {
@Override
public Net_Response handle(Net_Request baseReq, ConnectionContext ctx) throws Exception {
Net_GetTrustedDeviceLoginSettings_Request req = (Net_GetTrustedDeviceLoginSettings_Request) baseReq;
if (!EspPairingSupport.isTrustedUserSession(ctx)) {
return NetExceptionResponseFactory.error(
req,
EspPairingSupport.STATUS_PAIRING_REQUIRES_AUTH_SESSION,
"PAIRING_REQUIRES_AUTH_SESSION",
"Операция доступна только для авторизованной доверенной сессии пользователя"
);
}
EspPairingSettingsEntry entry = EspPairingSettingsDAO.getInstance().getByLogin(ctx.getLogin());
boolean enabled = entry == null || entry.isEnabled();
boolean hasPassword = enabled
&& entry != null
&& entry.getPasswordHash() != null
&& !entry.getPasswordHash().trim().isBlank();
Net_GetTrustedDeviceLoginSettings_Response resp = new Net_GetTrustedDeviceLoginSettings_Response();
resp.setOp(req.getOp());
resp.setRequestId(req.getRequestId());
resp.setStatus(200);
resp.setEnabled(enabled);
resp.setHasPassword(hasPassword);
return resp;
}
}

View File

@ -67,7 +67,8 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
String canonicalLogin = user.getLogin(); String canonicalLogin = user.getLogin();
EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin); EspPairingSettingsEntry settings = EspPairingSettingsDAO.getInstance().getByLogin(canonicalLogin);
if (settings == null || !settings.isEnabled()) { boolean enabled = settings == null || settings.isEnabled();
if (!enabled) {
return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен"); return NetExceptionResponseFactory.error(req, 422, "PAIRING_NOT_AVAILABLE", "Для этого login pairing недоступен");
} }
@ -85,7 +86,9 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) { if (recentAttempts >= EspPairingSupport.REQUEST_RATE_LIMIT) {
return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время"); return NetExceptionResponseFactory.error(req, EspPairingSupport.STATUS_PAIRING_RATE_LIMIT, "PAIRING_RATE_LIMITED", "Слишком много pairing-запросов за короткое время");
} }
String configuredPasswordHash = settings.getPasswordHash() == null ? "" : settings.getPasswordHash().trim(); String configuredPasswordHash = settings == null || settings.getPasswordHash() == null
? ""
: settings.getPasswordHash().trim();
boolean requiresPassword = !configuredPasswordHash.isBlank(); boolean requiresPassword = !configuredPasswordHash.isBlank();
boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank(); boolean suppliedPassword = passwordHash != null && !passwordHash.isBlank();
if ((requiresPassword && !configuredPasswordHash.equals(passwordHash)) if ((requiresPassword && !configuredPasswordHash.equals(passwordHash))
@ -94,7 +97,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
} }
String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform()); String clientPlatform = AuthSessionTypeSupport.normalizeClientPlatform(req.getRequesterClientPlatform());
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(settings.getTtlSeconds()); int ttlSeconds = EspPairingSupport.DEFAULT_TTL_SECONDS;
List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin); List<ConnectionContext> approverConnections = EspPairingSupport.findOnlineTrustedConnections(canonicalLogin);
if (approverConnections.isEmpty()) { if (approverConnections.isEmpty()) {
return NetExceptionResponseFactory.error( return NetExceptionResponseFactory.error(
@ -143,7 +146,7 @@ public class Net_StartEspPairing_Handler implements JsonMessageHandler {
payload.put("fingerprintB58", entry.getFingerprintB58()); payload.put("fingerprintB58", entry.getFingerprintB58());
payload.put("createdAtMs", entry.getCreatedAtMs()); payload.put("createdAtMs", entry.getCreatedAtMs());
payload.put("expiresAtMs", entry.getExpiresAtMs()); payload.put("expiresAtMs", entry.getExpiresAtMs());
delivered |= WsEventSender.sendEvent(targetCtx, "IncomingEspPairingRequest", eventId, payload); delivered |= WsEventSender.sendEvent(targetCtx, "IncomingTrustedDeviceLoginRequest", eventId, payload);
} }
if (delivered) { if (delivered) {
EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis()); EspPairingRequestsDAO.getInstance().updateDeliveryFlag(entry.getPairingId(), true, System.currentTimeMillis());

View File

@ -37,14 +37,12 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler
"passwordHash должен быть пустым или иметь формат sha256$<64 hex>" "passwordHash должен быть пустым или иметь формат sha256$<64 hex>"
); );
} }
int ttlSeconds = EspPairingSupport.normalizeTtlSeconds(req.getTtlSeconds());
long now = System.currentTimeMillis(); long now = System.currentTimeMillis();
EspPairingSettingsEntry entry = new EspPairingSettingsEntry(); EspPairingSettingsEntry entry = new EspPairingSettingsEntry();
entry.setLogin(ctx.getLogin()); entry.setLogin(ctx.getLogin());
entry.setEnabled(enabled); entry.setEnabled(enabled);
entry.setPasswordHash(passwordHash == null ? "" : passwordHash); entry.setPasswordHash(enabled && passwordHash != null ? passwordHash : "");
entry.setTtlSeconds(ttlSeconds); entry.setTtlSeconds(EspPairingSupport.DEFAULT_TTL_SECONDS);
entry.setFailedAttempts(0); entry.setFailedAttempts(0);
entry.setFirstFailedAtMs(0L); entry.setFirstFailedAtMs(0L);
entry.setBlockedUntilMs(0L); entry.setBlockedUntilMs(0L);
@ -56,7 +54,7 @@ public class Net_UpsertEspPairingSettings_Handler implements JsonMessageHandler
resp.setRequestId(req.getRequestId()); resp.setRequestId(req.getRequestId());
resp.setStatus(WireCodes.Status.OK); resp.setStatus(WireCodes.Status.OK);
resp.setEnabled(enabled); resp.setEnabled(enabled);
resp.setTtlSeconds(ttlSeconds); resp.setHasPassword(enabled && passwordHash != null && !passwordHash.isBlank());
return resp; return resp;
} }
} }

View File

@ -0,0 +1,6 @@
package server.logic.ws_protocol.JSON.handlers.auth.entyties;
import server.logic.ws_protocol.JSON.entyties.Net_Request;
public class Net_GetTrustedDeviceLoginSettings_Request extends Net_Request {
}

View File

@ -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_GetTrustedDeviceLoginSettings_Response extends Net_Response {
private boolean enabled;
private boolean hasPassword;
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public boolean isHasPassword() {
return hasPassword;
}
public void setHasPassword(boolean hasPassword) {
this.hasPassword = hasPassword;
}
}

View File

@ -4,7 +4,7 @@ import server.logic.ws_protocol.JSON.entyties.Net_Response;
public class Net_UpsertEspPairingSettings_Response extends Net_Response { public class Net_UpsertEspPairingSettings_Response extends Net_Response {
private boolean enabled; private boolean enabled;
private int ttlSeconds; private boolean hasPassword;
public boolean isEnabled() { public boolean isEnabled() {
return enabled; return enabled;
@ -14,11 +14,11 @@ public class Net_UpsertEspPairingSettings_Response extends Net_Response {
this.enabled = enabled; this.enabled = enabled;
} }
public int getTtlSeconds() { public boolean isHasPassword() {
return ttlSeconds; return hasPassword;
} }
public void setTtlSeconds(int ttlSeconds) { public void setHasPassword(boolean hasPassword) {
this.ttlSeconds = ttlSeconds; this.hasPassword = hasPassword;
} }
} }

View File

@ -1,2 +1,2 @@
client.version=1.2.211 client.version=1.2.212
server.version=1.2.199 server.version=1.2.200

View File

@ -35,14 +35,14 @@ import {
import * as startView from './pages/start-view.js?v=202606142105'; import * as startView from './pages/start-view.js?v=202606142105';
import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240'; import * as entrySettingsView from './pages/entry-settings-view.js?v=202606161240';
import * as registerView from './pages/register-view.js'; import * as registerView from './pages/register-view.js';
import * as registrationPaymentView from './pages/registration-payment-view.js'; import * as registrationPaymentView from './pages/registration-payment-view.js?v=202606180940';
import * as registrationKeysView from './pages/registration-keys-view.js'; import * as registrationKeysView from './pages/registration-keys-view.js';
import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js'; import * as registrationDraftKeysView from './pages/registration-draft-keys-view.js';
import * as topupView from './pages/topup-view.js'; import * as topupView from './pages/topup-view.js';
import * as devnetTopupView from './pages/devnet-topup-view.js'; import * as devnetTopupView from './pages/devnet-topup-view.js';
import * as loginView from './pages/login-view.js?v=202606150110'; import * as loginView from './pages/login-view.js?v=202606150110';
import * as loginCameraView from './pages/login-camera-view.js'; import * as loginCameraView from './pages/login-camera-view.js';
import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606160915'; import * as loginOtherDeviceView from './pages/login-other-device-view.js?v=202606180940';
import * as loginPasswordView from './pages/login-password-view.js'; import * as loginPasswordView from './pages/login-password-view.js';
import * as keyStorageView from './pages/key-storage-view.js'; import * as keyStorageView from './pages/key-storage-view.js';
@ -55,7 +55,8 @@ import * as serverSettingsView from './pages/server-settings-view.js?v=202606161
import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js';
import * as deviceView from './pages/device-view.js?v=202606131435'; import * as deviceView from './pages/device-view.js?v=202606131435';
import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055';
import * as devicePairingView from './pages/device-pairing-view.js?v=202606160915'; import * as devicePairingView from './pages/device-pairing-view.js?v=202606180940';
import * as trustedDeviceLoginSettingsView from './pages/trusted-device-login-settings-view.js?v=202606180930';
import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceQrView from './pages/device-qr-view.js';
import * as deviceCameraView from './pages/device-camera-view.js'; import * as deviceCameraView from './pages/device-camera-view.js';
import * as showKeysView from './pages/show-keys-view.js'; import * as showKeysView from './pages/show-keys-view.js';
@ -100,6 +101,7 @@ const routes = {
'device-view': deviceView, 'device-view': deviceView,
'connect-device-view': connectDeviceView, 'connect-device-view': connectDeviceView,
'device-pairing-view': devicePairingView, 'device-pairing-view': devicePairingView,
'trusted-device-login-settings-view': trustedDeviceLoginSettingsView,
'device-qr-view': deviceQrView, 'device-qr-view': deviceQrView,
'device-camera-view': deviceCameraView, 'device-camera-view': deviceCameraView,
'show-keys-view': showKeysView, 'show-keys-view': showKeysView,

View File

@ -172,8 +172,9 @@ export function render({ navigate }) {
let requests = []; let requests = [];
let cleanupEvent = () => {}; let cleanupEvent = () => {};
let disposed = false; let disposed = false;
let trustedDeviceLoginSettings = { enabled: true, hasPassword: false };
let settingsBusy = false; let settingsBusy = false;
let pairingPasswordConfigured = loadLocalPairingPasswordState(state.session.login, state.entrySettings.shineServerLogin || state.entrySettings.shineServerHttp); let pairingPasswordConfigured = false;
let dialogMode = ''; let dialogMode = '';
screen.append( screen.append(
@ -350,15 +351,19 @@ export function render({ navigate }) {
passwordDialog.hidden = true; passwordDialog.hidden = true;
}; };
const removeAdditionalPassword = async () => { const enablePairingWithoutPassword = async () => {
const payload = await runPairingOpWithSessionRestore(() => authService.upsertEspPairingSettings({ const payload = await runPairingOpWithSessionRestore(() => authService.upsertTrustedDeviceLoginSettings({
enabled: true, enabled: true,
passwordHash: '', passwordHash: '',
ttlSeconds: 180,
})); }));
pairingPasswordConfigured = false; pairingPasswordConfigured = false;
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServerLogin || state.entrySettings.shineServerHttp, false); saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServerLogin || state.entrySettings.shineServerHttp, false);
setAuthInfo(`Подключение по коду без дополнительного пароля включено. TTL: ${payload?.ttlSeconds || 180} сек.`); return payload;
};
const removeAdditionalPassword = async () => {
await enablePairingWithoutPassword();
setAuthInfo('Подключение по коду без дополнительного пароля включено.');
setStatus(status, 'Дополнительный пароль убран. Подключение по коду теперь работает без него.', 'info'); setStatus(status, 'Дополнительный пароль убран. Подключение по коду теперь работает без него.', 'info');
}; };
@ -367,65 +372,42 @@ export function render({ navigate }) {
renderSettingsCard(); renderSettingsCard();
}; };
const formatTrustedDeviceLoginState = () => {
if (!trustedDeviceLoginSettings.enabled) return 'Вход через другое устройство запрещён.';
if (trustedDeviceLoginSettings.hasPassword) return 'Вход через другое устройство разрешён только с дополнительным паролем.';
return 'Вход через другое устройство разрешён без дополнительного пароля.';
};
const reloadTrustedDeviceLoginSettings = async () => {
trustedDeviceLoginSettings = await runPairingOpWithSessionRestore(() => authService.getTrustedDeviceLoginSettings());
pairingPasswordConfigured = !!trustedDeviceLoginSettings.hasPassword;
};
const renderSettingsCard = () => { const renderSettingsCard = () => {
settingsCard.innerHTML = ''; settingsCard.innerHTML = '';
const title = document.createElement('p'); const title = document.createElement('p');
title.className = 'field-label'; title.className = 'field-label';
title.textContent = 'Дополнительный пароль'; title.textContent = 'Статус входа через другое устройство';
const stateText = document.createElement('p'); const stateText = document.createElement('p');
stateText.className = 'meta-muted'; stateText.className = 'meta-muted';
stateText.textContent = pairingPasswordConfigured stateText.textContent = formatTrustedDeviceLoginState();
? 'Установлен дополнительный пароль для подключения через другое устройство.'
: 'Дополнительный пароль для подключения через другое устройство не задан.';
const note = document.createElement('p'); const note = document.createElement('p');
note.className = 'meta-muted'; note.className = 'meta-muted';
note.textContent = pairingPasswordConfigured note.textContent = 'Открыть подробные настройки можно на отдельном экране.';
? 'Этот пароль не даёт права на вход сам по себе. Он только отсекает лишние заявки до того, как пользователь подтвердит подключение на доверённом устройстве.'
: 'Сейчас подключение работает без дополнительного пароля. Обычно этого достаточно. Если хотите, можно добавить простой пароль только как защиту от лишних заявок.';
const actions = document.createElement('div'); const actions = document.createElement('div');
actions.className = 'row'; actions.className = 'row';
actions.style.flexWrap = 'wrap'; actions.style.flexWrap = 'wrap';
const openSettingsBtn = document.createElement('button');
if (pairingPasswordConfigured) { openSettingsBtn.className = 'primary-btn';
const changeBtn = document.createElement('button'); openSettingsBtn.type = 'button';
changeBtn.className = 'primary-btn'; openSettingsBtn.textContent = 'Изменить настройки входа';
changeBtn.type = 'button'; openSettingsBtn.disabled = settingsBusy;
changeBtn.textContent = 'Изменить пароль'; openSettingsBtn.addEventListener('click', () => navigate('trusted-device-login-settings-view'));
changeBtn.disabled = settingsBusy; actions.append(openSettingsBtn);
changeBtn.addEventListener('click', () => openPasswordDialog('change'));
const removeBtn = document.createElement('button');
removeBtn.className = 'ghost-btn';
removeBtn.type = 'button';
removeBtn.textContent = 'Убрать пароль';
removeBtn.disabled = settingsBusy;
removeBtn.addEventListener('click', async () => {
setSettingsBusy(true);
try {
await removeAdditionalPassword();
} catch (error) {
const message = toUserMessage(error, 'Не удалось убрать дополнительный пароль.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setSettingsBusy(false);
}
});
actions.append(changeBtn, removeBtn);
} else {
const setBtn = document.createElement('button');
setBtn.className = 'primary-btn';
setBtn.type = 'button';
setBtn.textContent = 'Задать дополнительный пароль';
setBtn.disabled = settingsBusy;
setBtn.addEventListener('click', () => openPasswordDialog('set'));
actions.append(setBtn);
}
settingsCard.append(title, stateText, note, actions); settingsCard.append(title, stateText, note, actions);
}; };
@ -467,13 +449,15 @@ export function render({ navigate }) {
const reloadRequests = async ({ silent = false } = {}) => { const reloadRequests = async ({ silent = false } = {}) => {
try { try {
requests = await runPairingOpWithSessionRestore(() => authService.listEspPairingRequests()); await reloadTrustedDeviceLoginSettings();
renderSettingsCard();
requests = await runPairingOpWithSessionRestore(() => authService.listTrustedDeviceLoginRequests());
renderRequests(); renderRequests();
if (!silent) { if (!silent) {
setStatus(status, 'Список pairing-заявок обновлён.', 'info'); setStatus(status, 'Список заявок на вход обновлён.', 'info');
} }
} catch (error) { } catch (error) {
const message = toUserMessage(error, 'Не удалось загрузить pairing-заявки.'); const message = toUserMessage(error, 'Не удалось загрузить заявки на вход.');
setAuthError(message); setAuthError(message);
setStatus(status, message, 'error'); setStatus(status, message, 'error');
} }
@ -508,7 +492,7 @@ export function render({ navigate }) {
}); });
} }
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload); const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await runPairingOpWithSessionRestore(() => authService.approveEspPairing(request?.pairingId, encryptedPayload)); await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === 50; const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === 50;
showToast( showToast(
withExtras withExtras
@ -592,16 +576,15 @@ export function render({ navigate }) {
dialogSaveBtn.disabled = true; dialogSaveBtn.disabled = true;
try { try {
const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password); const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password);
const payload = await runPairingOpWithSessionRestore(() => authService.upsertEspPairingSettings({ await runPairingOpWithSessionRestore(() => authService.upsertTrustedDeviceLoginSettings({
enabled: true, enabled: true,
passwordHash, passwordHash,
ttlSeconds: 180,
})); }));
pairingPasswordConfigured = true; pairingPasswordConfigured = true;
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServerLogin || state.entrySettings.shineServerHttp, true); saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServerLogin || state.entrySettings.shineServerHttp, true);
closePasswordDialog(); closePasswordDialog();
renderSettingsCard(); renderSettingsCard();
setAuthInfo(`Подключение по коду включено с дополнительным паролем. TTL: ${payload?.ttlSeconds || 180} сек.`); setAuthInfo('Подключение по коду включено с дополнительным паролем.');
setStatus(status, currentMode === 'change' setStatus(status, currentMode === 'change'
? 'Дополнительный пароль изменён.' ? 'Дополнительный пароль изменён.'
: 'Дополнительный пароль задан.', 'info'); : 'Дополнительный пароль задан.', 'info');
@ -641,7 +624,7 @@ export function render({ navigate }) {
} else if (action === 'approve-full') { } else if (action === 'approve-full') {
await approveRequest(request, 'with-extras'); await approveRequest(request, 'with-extras');
} else if (action === 'reject') { } else if (action === 'reject') {
await runPairingOpWithSessionRestore(() => authService.rejectEspPairing(pairingId, 'rejected_by_user')); await runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(pairingId, 'rejected_by_user'));
showToast('Заявка отклонена', { kind: 'error' }); showToast('Заявка отклонена', { kind: 'error' });
await reloadRequests({ silent: true }); await reloadRequests({ silent: true });
} }
@ -658,9 +641,9 @@ export function render({ navigate }) {
renderSettingsCard(); renderSettingsCard();
await loadSavedKeys(); await loadSavedKeys();
await reloadRequests({ silent: true }); await reloadRequests({ silent: true });
cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => { cleanupEvent = authService.onEvent('IncomingTrustedDeviceLoginRequest', () => {
if (disposed) return; if (disposed) return;
showToast('Пришла новая заявка на подключение устройства'); showToast('Пришла новая заявка на вход через доверенное устройство');
void reloadRequests({ silent: true }); void reloadRequests({ silent: true });
}); });
} catch (error) { } catch (error) {

View File

@ -231,7 +231,7 @@ export function render({ navigate }) {
if (!activePairingId || isDisposed) return; if (!activePairingId || isDisposed) return;
pollTimer = window.setTimeout(async () => { pollTimer = window.setTimeout(async () => {
try { try {
const payload = await authService.getEspPairingStatus(activePairingId); const payload = await authService.getTrustedDeviceLoginStatus(activePairingId);
const stateValue = String(payload?.state || ''); const stateValue = String(payload?.state || '');
if (stateValue === 'created') { if (stateValue === 'created') {
schedulePoll(); schedulePoll();
@ -315,7 +315,7 @@ export function render({ navigate }) {
const passwordHash = usePassword const passwordHash = usePassword
? await deriveEspPairingPasswordHash(login, password) ? await deriveEspPairingPasswordHash(login, password)
: ''; : '';
const payload = await authService.startEspPairing({ const payload = await authService.startTrustedDeviceLogin({
login, login,
passwordHash, passwordHash,
requesterSessionKey: requesterMaterial.sessionKey, requesterSessionKey: requesterMaterial.sessionKey,
@ -357,7 +357,7 @@ export function render({ navigate }) {
} }
cancelBtn.disabled = true; cancelBtn.disabled = true;
try { try {
await authService.cancelEspPairing(activePairingId, requesterMaterial.sessionKey); await authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey);
clearActivePairing(); clearActivePairing();
startBtn.disabled = false; startBtn.disabled = false;
setStatus(status, 'Ожидание подключения отменено.', 'info'); setStatus(status, 'Ожидание подключения отменено.', 'info');

View File

@ -194,6 +194,7 @@ export function render({ navigate }) {
login: state.registrationDraft.login, login: state.registrationDraft.login,
keyBundle, keyBundle,
solanaEndpoint: state.entrySettings.solanaServer, solanaEndpoint: state.entrySettings.solanaServer,
accessServers: [state.entrySettings.shineServerLogin || 'shineupme'],
}); });
} catch (solanaError) { } catch (solanaError) {
const solanaMsg = formatSolanaErrorDetails(solanaError); const solanaMsg = formatSolanaErrorDetails(solanaError);
@ -307,28 +308,33 @@ function renderSolanaDoneStage({ navigate, status, keyBundle }) {
tryLoginBtn.addEventListener('click', async () => { tryLoginBtn.addEventListener('click', async () => {
if (!canTryLogin) return; if (!canTryLogin) return;
status.style.display = 'none'; status.style.display = 'none';
tryLoginBtn.disabled = true; tryLoginBtn.disabled = true;
tryLoginBtn.textContent = 'Вход...'; tryLoginBtn.textContent = 'Вход...';
try { try {
await authService.reconnect(state.entrySettings.shineServer); await authService.reconnect(state.entrySettings.shineServer);
const result = await authService.createSessionForExistingUser( const result = await authService.createSessionForExistingUser(
state.registrationDraft.login, state.registrationDraft.login,
state.registrationDraft.password, state.registrationDraft.password,
); );
state.registrationDraft.flowType = 'registration'; await authService.persistSessionMaterial(
state.registrationDraft.sessionId = result.sessionId; result.login,
state.registrationDraft.storagePwd = result.storagePwd; result.sessionMaterial,
state.registrationDraft.pendingKeyBundle = keyBundle; );
state.registrationDraft.pendingSessionMaterial = result.sessionMaterial; const resumed = await authService.resumeSession(result.login, result.sessionId);
setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`); state.registrationDraft.flowType = 'registration';
navigate('registration-keys-view'); state.registrationDraft.sessionId = resumed.sessionId || result.sessionId;
} catch (error) { state.registrationDraft.storagePwd = resumed.storagePwd || result.storagePwd;
status.className = 'status-line is-unavailable'; state.registrationDraft.pendingKeyBundle = keyBundle;
status.textContent = toUserMessage(error, 'Пока не удалось войти. Попробуйте ещё раз через несколько секунд.'); state.registrationDraft.pendingSessionMaterial = result.sessionMaterial;
status.style.display = ''; setAuthInfo(`Регистрация завершена. Вы вошли как @${result.login}. Далее откройте вкладку «Каналы».`);
tryLoginBtn.disabled = false; navigate('registration-keys-view');
tryLoginBtn.textContent = 'Попробовать войти на сервер'; } catch (error) {
} status.className = 'status-line is-unavailable';
status.textContent = toUserMessage(error, 'Пока не удалось войти. Попробуйте ещё раз через несколько секунд.');
status.style.display = '';
tryLoginBtn.disabled = false;
tryLoginBtn.textContent = 'Попробовать войти на сервер';
}
}); });
card.innerHTML = ''; card.innerHTML = '';

View File

@ -0,0 +1,200 @@
import { renderHeader } from '../components/header.js';
import { authService, setAuthError, setAuthInfo, state } from '../state.js';
import { deriveEspPairingPasswordHash } from '../services/device-pairing-service.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'trusted-device-login-settings-view', title: 'Настройки входа через устройство' };
function setStatus(statusEl, message, kind = 'info') {
statusEl.classList.toggle('is-unavailable', kind === 'error');
statusEl.classList.toggle('is-available', kind !== 'error');
statusEl.textContent = message;
statusEl.style.display = message ? '' : 'none';
}
function describeState(settings) {
if (!settings?.enabled) return 'Вход через другое устройство запрещён.';
if (settings?.hasPassword) return 'Вход через другое устройство разрешён только с дополнительным паролем.';
return 'Вход через другое устройство разрешён без дополнительного пароля.';
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
const card = document.createElement('div');
card.className = 'card stack';
const summary = document.createElement('p');
summary.className = 'auth-copy';
summary.textContent = 'Загружаем текущие настройки...';
const hint = document.createElement('p');
hint.className = 'meta-muted';
hint.textContent = 'Дополнительный пароль не даёт права на вход сам по себе. Он только отсекает лишние заявки до подтверждения на доверенном устройстве.';
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const actions = document.createElement('div');
actions.className = 'row';
actions.style.flexWrap = 'wrap';
const enableToggleBtn = document.createElement('button');
enableToggleBtn.className = 'primary-btn';
enableToggleBtn.type = 'button';
const noPasswordBtn = document.createElement('button');
noPasswordBtn.className = 'ghost-btn';
noPasswordBtn.type = 'button';
noPasswordBtn.textContent = 'Сделать вход без пароля';
const passwordForm = document.createElement('div');
passwordForm.className = 'stack';
passwordForm.innerHTML = `
<label class="stack">
<span class="field-label">Новый дополнительный пароль</span>
<input class="input" id="trusted-login-password" type="password" autocomplete="new-password" placeholder="Введите пароль" />
</label>
<label class="stack">
<span class="field-label">Подтверждение пароля</span>
<input class="input" id="trusted-login-password-confirm" type="password" autocomplete="new-password" placeholder="Повторите пароль" />
</label>
<button class="primary-btn" type="button" id="trusted-login-password-save">Сохранить новый пароль</button>
`;
const passwordInput = passwordForm.querySelector('#trusted-login-password');
const passwordConfirmInput = passwordForm.querySelector('#trusted-login-password-confirm');
const savePasswordBtn = passwordForm.querySelector('#trusted-login-password-save');
card.append(summary, hint, actions, passwordForm, status);
let settings = { enabled: true, hasPassword: false };
let busy = false;
const setBusy = (flag) => {
busy = flag;
enableToggleBtn.disabled = flag;
noPasswordBtn.disabled = flag || !settings.enabled || !settings.hasPassword;
savePasswordBtn.disabled = flag;
passwordInput.disabled = flag;
passwordConfirmInput.disabled = flag;
};
const renderUi = () => {
summary.textContent = describeState(settings);
enableToggleBtn.textContent = settings.enabled
? 'Запретить вход через другое устройство'
: 'Разрешить вход через другое устройство';
actions.innerHTML = '';
actions.append(enableToggleBtn);
if (settings.enabled) {
actions.append(noPasswordBtn);
}
passwordForm.style.display = settings.enabled ? '' : 'none';
noPasswordBtn.disabled = busy || !settings.hasPassword;
setBusy(busy);
};
const reloadSettings = async () => {
settings = await authService.getTrustedDeviceLoginSettings();
renderUi();
};
enableToggleBtn.addEventListener('click', async () => {
setStatus(status, '', 'info');
setBusy(true);
try {
settings = await authService.upsertTrustedDeviceLoginSettings({
enabled: !settings.enabled,
passwordHash: '',
});
renderUi();
setAuthInfo(settings.enabled
? 'Вход через другое устройство разрешён.'
: 'Вход через другое устройство запрещён.');
setStatus(status, describeState(settings), 'info');
} catch (error) {
const message = toUserMessage(error, 'Не удалось изменить режим входа.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setBusy(false);
}
});
noPasswordBtn.addEventListener('click', async () => {
setStatus(status, '', 'info');
setBusy(true);
try {
settings = await authService.upsertTrustedDeviceLoginSettings({
enabled: true,
passwordHash: '',
});
renderUi();
setAuthInfo('Вход через другое устройство теперь работает без дополнительного пароля.');
setStatus(status, 'Вход теперь работает без дополнительного пароля.', 'info');
} catch (error) {
const message = toUserMessage(error, 'Не удалось убрать дополнительный пароль.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setBusy(false);
}
});
savePasswordBtn.addEventListener('click', async () => {
setStatus(status, '', 'info');
const password = String(passwordInput.value || '');
const confirm = String(passwordConfirmInput.value || '');
if (!password || !confirm) {
setStatus(status, 'Заполните пароль и подтверждение.', 'error');
return;
}
if (password !== confirm) {
setStatus(status, 'Пароли не совпадают.', 'error');
return;
}
setBusy(true);
try {
const finalHash = await deriveEspPairingPasswordHash(
String(state.session.login || ''),
password,
);
settings = await authService.upsertTrustedDeviceLoginSettings({
enabled: true,
passwordHash: finalHash,
});
passwordInput.value = '';
passwordConfirmInput.value = '';
renderUi();
setAuthInfo('Дополнительный пароль для входа через другое устройство сохранён.');
setStatus(status, 'Дополнительный пароль сохранён.', 'info');
} catch (error) {
const message = toUserMessage(error, 'Не удалось сохранить дополнительный пароль.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setBusy(false);
}
});
screen.append(
renderHeader({
title: 'Настройки входа через устройство',
leftAction: { label: '←', onClick: () => navigate('device-pairing-view') },
}),
card,
);
void reloadSettings().catch((error) => {
const message = toUserMessage(error, 'Не удалось загрузить настройки входа через устройство.');
setAuthError(message);
setStatus(status, message, 'error');
});
return screen;
}

View File

@ -180,6 +180,7 @@ export function resolveToolbarActive(pageId) {
pageId === 'device-view' || pageId === 'device-view' ||
pageId === 'connect-device-view' || pageId === 'connect-device-view' ||
pageId === 'device-pairing-view' || pageId === 'device-pairing-view' ||
pageId === 'trusted-device-login-settings-view' ||
pageId === 'device-qr-view' || pageId === 'device-qr-view' ||
pageId === 'device-camera-view' || pageId === 'device-camera-view' ||
pageId === 'show-keys-view' || pageId === 'show-keys-view' ||

View File

@ -1091,17 +1091,22 @@ export class AuthService {
if (response.status !== 200) throw opError('CloseActiveSession', response); if (response.status !== 200) throw opError('CloseActiveSession', response);
} }
async upsertEspPairingSettings({ enabled, passwordHash = '', ttlSeconds = 180 }) { async getTrustedDeviceLoginSettings() {
const response = await this.ws.request('UpsertEspPairingSettings', { const response = await this.ws.request('GetTrustedDeviceLoginSettings', {});
enabled: !!enabled, if (response.status !== 200) throw opError('GetTrustedDeviceLoginSettings', response);
passwordHash: String(passwordHash || '').trim(),
ttlSeconds: Number(ttlSeconds) || 180,
});
if (response.status !== 200) throw opError('UpsertEspPairingSettings', response);
return response.payload || {}; return response.payload || {};
} }
async startEspPairing({ async upsertTrustedDeviceLoginSettings({ enabled, passwordHash = '' }) {
const response = await this.ws.request('UpsertTrustedDeviceLoginSettings', {
enabled: !!enabled,
passwordHash: String(passwordHash || '').trim(),
});
if (response.status !== 200) throw opError('UpsertTrustedDeviceLoginSettings', response);
return response.payload || {};
}
async startTrustedDeviceLogin({
login, login,
passwordHash, passwordHash,
requesterSessionKey, requesterSessionKey,
@ -1109,7 +1114,7 @@ export class AuthService {
requesterClientPlatform = makeClientPlatform(), requesterClientPlatform = makeClientPlatform(),
payloadType = 3, payloadType = 3,
}) { }) {
const response = await this.ws.request('StartEspPairing', { const response = await this.ws.request('StartTrustedDeviceLogin', {
login: String(login || '').trim(), login: String(login || '').trim(),
passwordHash: String(passwordHash || '').trim(), passwordHash: String(passwordHash || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(), requesterSessionKey: String(requesterSessionKey || '').trim(),
@ -1117,51 +1122,59 @@ export class AuthService {
requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(), requesterClientPlatform: String(requesterClientPlatform || '').trim() || makeClientPlatform(),
payloadType: Number(payloadType) || 3, payloadType: Number(payloadType) || 3,
}); });
if (response.status !== 200) throw opError('StartEspPairing', response); if (response.status !== 200) throw opError('StartTrustedDeviceLogin', response);
return response.payload || {}; return response.payload || {};
} }
async listEspPairingRequests() { async listTrustedDeviceLoginRequests() {
const response = await this.ws.request('ListEspPairingRequests', {}); const response = await this.ws.request('ListTrustedDeviceLoginRequests', {});
if (response.status !== 200) throw opError('ListEspPairingRequests', response); if (response.status !== 200) throw opError('ListTrustedDeviceLoginRequests', response);
return Array.isArray(response?.payload?.requests) ? response.payload.requests : []; return Array.isArray(response?.payload?.requests) ? response.payload.requests : [];
} }
async approveEspPairing(pairingId, encryptedPayload) { async approveTrustedDeviceLogin(pairingId, encryptedPayload) {
const response = await this.ws.request('ApproveEspPairing', { const response = await this.ws.request('ApproveTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(), pairingId: String(pairingId || '').trim(),
encryptedPayload: String(encryptedPayload || '').trim(), encryptedPayload: String(encryptedPayload || '').trim(),
}); });
if (response.status !== 200) throw opError('ApproveEspPairing', response); if (response.status !== 200) throw opError('ApproveTrustedDeviceLogin', response);
return response.payload || {}; return response.payload || {};
} }
async rejectEspPairing(pairingId, reason = '') { async rejectTrustedDeviceLogin(pairingId, reason = '') {
const response = await this.ws.request('RejectEspPairing', { const response = await this.ws.request('RejectTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(), pairingId: String(pairingId || '').trim(),
reason: String(reason || '').trim(), reason: String(reason || '').trim(),
}); });
if (response.status !== 200) throw opError('RejectEspPairing', response); if (response.status !== 200) throw opError('RejectTrustedDeviceLogin', response);
return response.payload || {}; return response.payload || {};
} }
async cancelEspPairing(pairingId, requesterSessionKey) { async cancelTrustedDeviceLogin(pairingId, requesterSessionKey) {
const response = await this.ws.request('CancelEspPairing', { const response = await this.ws.request('CancelTrustedDeviceLogin', {
pairingId: String(pairingId || '').trim(), pairingId: String(pairingId || '').trim(),
requesterSessionKey: String(requesterSessionKey || '').trim(), requesterSessionKey: String(requesterSessionKey || '').trim(),
}); });
if (response.status !== 200) throw opError('CancelEspPairing', response); if (response.status !== 200) throw opError('CancelTrustedDeviceLogin', response);
return response.payload || {}; return response.payload || {};
} }
async getEspPairingStatus(pairingId) { async getTrustedDeviceLoginStatus(pairingId) {
const response = await this.ws.request('GetEspPairingStatus', { const response = await this.ws.request('GetTrustedDeviceLoginStatus', {
pairingId: String(pairingId || '').trim(), pairingId: String(pairingId || '').trim(),
}); });
if (response.status !== 200) throw opError('GetEspPairingStatus', response); if (response.status !== 200) throw opError('GetTrustedDeviceLoginStatus', response);
return response.payload || {}; return response.payload || {};
} }
async upsertEspPairingSettings(args) { return this.upsertTrustedDeviceLoginSettings(args); }
async startEspPairing(args) { return this.startTrustedDeviceLogin(args); }
async listEspPairingRequests() { return this.listTrustedDeviceLoginRequests(); }
async approveEspPairing(pairingId, encryptedPayload) { return this.approveTrustedDeviceLogin(pairingId, encryptedPayload); }
async rejectEspPairing(pairingId, reason = '') { return this.rejectTrustedDeviceLogin(pairingId, reason); }
async cancelEspPairing(pairingId, requesterSessionKey) { return this.cancelTrustedDeviceLogin(pairingId, requesterSessionKey); }
async getEspPairingStatus(pairingId) { return this.getTrustedDeviceLoginStatus(pairingId); }
async listSubscriptionsFeed(login, limit = 200) { async listSubscriptionsFeed(login, limit = 200) {
const response = await this.ws.request('ListSubscriptionsFeed', { login, limit }); const response = await this.ws.request('ListSubscriptionsFeed', { login, limit });
if (response.status !== 200) throw opError('ListSubscriptionsFeed', response); if (response.status !== 200) throw opError('ListSubscriptionsFeed', response);

View File

@ -811,13 +811,13 @@ async function createShineUserPdaOnSolana({
}; };
} }
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) { export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers = [] }) {
return createShineUserPdaOnSolana({ return createShineUserPdaOnSolana({
login, login,
keyBundle, keyBundle,
solanaEndpoint, solanaEndpoint,
isServer: false, isServer: false,
accessServers: ['shineup.me'], accessServers: Array.isArray(accessServers) ? accessServers : [],
}); });
} }

View File

@ -138,6 +138,6 @@ export async function checkLoginExistsOnSolana({ login, solanaEndpoint }) {
return { exists: !!ai, userPda: userPda.toBase58() }; return { exists: !!ai, userPda: userPda.toBase58() };
} }
export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint }) { export async function registerUserOnSolana({ login, keyBundle, solanaEndpoint, accessServers }) {
return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint }); return registerUserOnSolanaShared({ login, keyBundle, solanaEndpoint, accessServers });
} }

View File

@ -54,6 +54,10 @@ export function toUserMessage(error, fallback = 'Действие не выпо
return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.'; return 'К сожалению сейчас нет ни одного активного устройства этого пользователя, подключенного к этому серверу в сети, и поэтому вход таким образом выполнить невозможно.';
} }
if (code === 'PAIRING_NOT_AVAILABLE') {
return 'Для этого логина ещё не включено подключение по коду. На доверенном устройстве откройте «Подключить по коду» и нажмите «Включить подключение по коду» или задайте дополнительный пароль.';
}
if (code === 'PAIRING_PASSWORD_INVALID') { if (code === 'PAIRING_PASSWORD_INVALID') {
return 'Пароль подключения не подходит.'; return 'Пароль подключения не подходит.';
} }