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 99fda0a..0c7afd2 100644 --- a/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md +++ b/Dev_Docs/Pending_Features/2026-06-14_2035_ui_подключение_по_коду.md @@ -22,6 +22,9 @@ - убедиться, что без онлайн доверённой сессии новое устройство сразу получает явную ошибку и код вообще не создаётся; - убедиться, что countdown под кодом убывает в реальном времени; - убедиться, что кнопка `Отмена` на новом устройстве действительно снимает заявку и она пропадает у доверённого устройства без ожидания TTL. + - убедиться, что на экране `Подключить по коду` блок дополнительного пароля показывает два понятных состояния: пароль не задан / пароль установлен; + - убедиться, что `Задать пароль` и `Изменить пароль` открывают верхний диалог с двумя полями и кнопками-глазами; + - убедиться, что `Убрать пароль` не выключает pairing целиком, а переводит его в режим без дополнительного пароля. - ожидаемый результат: - новое устройство получает код, доверённое устройство видит ту же заявку и может её подтвердить или отклонить; diff --git a/VERSION.properties b/VERSION.properties index 5a753b1..5963aaf 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.200 -server.version=1.2.189 +client.version=1.2.201 +server.version=1.2.190 diff --git a/shine-UI/js/app.js b/shine-UI/js/app.js index 8948d07..35e6c55 100644 --- a/shine-UI/js/app.js +++ b/shine-UI/js/app.js @@ -55,7 +55,7 @@ import * as serverSettingsView from './pages/server-settings-view.js'; import * as toolsSettingsView from './pages/tools-settings-view.js'; import * as deviceView from './pages/device-view.js?v=202606131435'; import * as connectDeviceView from './pages/connect-device-view.js?v=202606142055'; -import * as devicePairingView from './pages/device-pairing-view.js?v=202606150045'; +import * as devicePairingView from './pages/device-pairing-view.js?v=202606151000'; import * as deviceQrView from './pages/device-qr-view.js'; import * as deviceCameraView from './pages/device-camera-view.js'; import * as showKeysView from './pages/show-keys-view.js'; diff --git a/shine-UI/js/pages/device-pairing-view.js b/shine-UI/js/pages/device-pairing-view.js index 1dd5bad..b3f3188 100644 --- a/shine-UI/js/pages/device-pairing-view.js +++ b/shine-UI/js/pages/device-pairing-view.js @@ -17,6 +17,8 @@ import { toUserMessage } from '../services/ui-error-texts.js'; export const pageMeta = { id: 'device-pairing-view', title: 'Подключить по коду' }; +const PAIRING_PASSWORD_STATE_PREFIX = 'shine_pairing_password_state_v1'; + function setStatus(statusEl, message, kind = 'info') { statusEl.classList.toggle('is-unavailable', kind === 'error'); statusEl.classList.toggle('is-available', kind !== 'error'); @@ -71,6 +73,48 @@ function requestCardHtml(request) { `; } +function makePasswordToggleIcons() { + return { + eye: ` + + `, + eyeOff: ` + + `, + }; +} + +function localPairingPasswordStateKey(login, serverUrl) { + return `${PAIRING_PASSWORD_STATE_PREFIX}:${String(serverUrl || '').trim()}:${String(login || '').trim().toLowerCase()}`; +} + +function loadLocalPairingPasswordState(login, serverUrl) { + try { + const raw = localStorage.getItem(localPairingPasswordStateKey(login, serverUrl)); + if (!raw) return false; + const parsed = JSON.parse(raw); + return !!parsed?.hasPassword; + } catch { + return false; + } +} + +function saveLocalPairingPasswordState(login, serverUrl, hasPassword) { + try { + localStorage.setItem(localPairingPasswordStateKey(login, serverUrl), JSON.stringify({ + hasPassword: !!hasPassword, + updatedAtMs: Date.now(), + })); + } catch {} +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -78,6 +122,9 @@ export function render({ navigate }) { let requests = []; let cleanupEvent = () => {}; let disposed = false; + let settingsBusy = false; + let pairingPasswordConfigured = loadLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer); + let dialogMode = ''; screen.append( renderHeader({ @@ -88,22 +135,7 @@ export function render({ navigate }) { const settingsCard = document.createElement('div'); settingsCard.className = 'card stack'; - settingsCard.innerHTML = ` -

Пароль подключения

- - -
- - -
-

Чтобы включить pairing без пароля: оставьте галочку выключенной и нажмите "Включить / обновить". Чтобы включить pairing с паролем: включите галочку, введите пароль и нажмите ту же кнопку.

- `; + const passwordIcons = makePasswordToggleIcons(); const keySummaryCard = document.createElement('div'); keySummaryCard.className = 'card stack'; @@ -129,26 +161,169 @@ export function render({ navigate }) { const status = document.createElement('p'); status.className = 'status-line is-unavailable'; status.style.display = 'none'; - - const passwordInput = settingsCard.querySelector('#pairing-password'); - const usePasswordInput = settingsCard.querySelector('#pairing-use-password'); - const passwordHelpEl = settingsCard.querySelector('#pairing-password-help'); - const enableBtn = settingsCard.querySelector('#enable-pairing-btn'); - const disableBtn = settingsCard.querySelector('#disable-pairing-btn'); const keySummaryEl = keySummaryCard.querySelector('#pairing-key-summary'); const codeFilterInput = requestsCard.querySelector('#pairing-code-filter'); const refreshBtn = requestsCard.querySelector('#refresh-pairing-requests'); const requestsListEl = requestsCard.querySelector('#pairing-requests-list'); - const syncPasswordUi = () => { - const usePassword = !!usePasswordInput.checked; - passwordInput.parentElement.style.display = usePassword ? '' : 'none'; - passwordHelpEl.textContent = usePassword - ? 'Если включено, новое устройство должно будет ввести этот пароль перед получением кода.' - : 'Если выключено, новое устройство сможет входить без доп. пароля.'; - if (!usePassword) { - passwordInput.value = ''; + const passwordDialog = document.createElement('div'); + passwordDialog.hidden = true; + passwordDialog.style.position = 'fixed'; + passwordDialog.style.inset = '0'; + passwordDialog.style.zIndex = '30'; + passwordDialog.innerHTML = ` +
+
+
+
+

Задать дополнительный пароль

+

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

+
+ +
+ + +
+ + +
+
+ `; + screen.append(passwordDialog); + + const dialogTitleEl = passwordDialog.querySelector('#pairing-dialog-title'); + const dialogTextEl = passwordDialog.querySelector('#pairing-dialog-text'); + const dialogPasswordInput = passwordDialog.querySelector('#pairing-dialog-password'); + const dialogPasswordConfirmInput = passwordDialog.querySelector('#pairing-dialog-password-confirm'); + const dialogSaveBtn = passwordDialog.querySelector('#pairing-dialog-save'); + const dialogPasswordToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-toggle'); + const dialogPasswordConfirmToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-confirm-toggle'); + + const bindPasswordToggle = (input, button) => { + button.addEventListener('click', () => { + if (input.type === 'password') { + input.type = 'text'; + button.innerHTML = passwordIcons.eye; + button.setAttribute('aria-label', 'Скрыть пароль'); + button.title = 'Скрыть пароль'; + } else { + input.type = 'password'; + button.innerHTML = passwordIcons.eyeOff; + button.setAttribute('aria-label', 'Показать пароль'); + button.title = 'Показать пароль'; + } + }); + }; + bindPasswordToggle(dialogPasswordInput, dialogPasswordToggleBtn); + bindPasswordToggle(dialogPasswordConfirmInput, dialogPasswordConfirmToggleBtn); + + const openPasswordDialog = (mode) => { + dialogMode = mode; + dialogTitleEl.textContent = mode === 'change' + ? 'Изменить дополнительный пароль' + : 'Задать дополнительный пароль'; + dialogTextEl.textContent = + 'Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.'; + dialogPasswordInput.value = ''; + dialogPasswordConfirmInput.value = ''; + dialogPasswordInput.type = 'password'; + dialogPasswordConfirmInput.type = 'password'; + dialogPasswordToggleBtn.innerHTML = passwordIcons.eyeOff; + dialogPasswordConfirmToggleBtn.innerHTML = passwordIcons.eyeOff; + passwordDialog.hidden = false; + }; + + const closePasswordDialog = () => { + dialogMode = ''; + passwordDialog.hidden = true; + }; + + const setSettingsBusy = (flag) => { + settingsBusy = flag; + renderSettingsCard(); + }; + + const renderSettingsCard = () => { + settingsCard.innerHTML = ''; + + const title = document.createElement('p'); + title.className = 'field-label'; + title.textContent = 'Дополнительный пароль'; + + const stateText = document.createElement('p'); + stateText.className = 'meta-muted'; + stateText.textContent = pairingPasswordConfigured + ? 'Установлен дополнительный пароль для подключения через другое устройство.' + : 'Дополнительный пароль для подключения через другое устройство не задан.'; + + const note = document.createElement('p'); + note.className = 'meta-muted'; + note.textContent = pairingPasswordConfigured + ? 'Этот пароль не даёт права на вход сам по себе. Он только отсекает лишние заявки до того, как пользователь подтвердит подключение на доверённом устройстве.' + : 'Сейчас подключение работает без дополнительного пароля. Обычно этого достаточно. Если хотите, можно добавить простой пароль только как защиту от лишних заявок.'; + + const actions = document.createElement('div'); + actions.className = 'row'; + actions.style.flexWrap = 'wrap'; + + if (pairingPasswordConfigured) { + const changeBtn = document.createElement('button'); + changeBtn.className = 'primary-btn'; + changeBtn.type = 'button'; + changeBtn.textContent = 'Изменить пароль'; + changeBtn.disabled = settingsBusy; + 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 { + const payload = await authService.upsertEspPairingSettings({ + enabled: true, + passwordHash: '', + ttlSeconds: 180, + }); + pairingPasswordConfigured = false; + saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, false); + setAuthInfo(`Подключение по коду без дополнительного пароля включено. TTL: ${payload?.ttlSeconds || 180} сек.`); + setStatus(status, 'Дополнительный пароль убран. Подключение по коду теперь работает без него.', 'info'); + } 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); }; const renderRequests = () => { @@ -201,10 +376,7 @@ export function render({ navigate }) { }; const setButtonsBusy = (flag) => { - enableBtn.disabled = flag; - disableBtn.disabled = flag; refreshBtn.disabled = flag; - usePasswordInput.disabled = flag; }; const approveRequest = async (request, mode) => { @@ -223,61 +395,48 @@ export function render({ navigate }) { await reloadRequests({ silent: true }); }; - usePasswordInput.addEventListener('change', syncPasswordUi); - - settingsCard.addEventListener('click', async (event) => { + passwordDialog.addEventListener('click', (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; + if (target.dataset.action === 'close-dialog') { + closePasswordDialog(); + } + }); - if (target.id === 'enable-pairing-btn') { - const usePassword = !!usePasswordInput.checked; - const password = String(passwordInput.value || ''); - if (usePassword && !password) { - setStatus(status, 'Введите pairing-пароль.', 'error'); - return; - } - setButtonsBusy(true); - try { - const passwordHash = usePassword - ? await deriveEspPairingPasswordHash(state.session.login, password) - : ''; - const payload = await authService.upsertEspPairingSettings({ - enabled: true, - passwordHash, - ttlSeconds: 180, - }); - setAuthInfo(`Подключение по коду включено. TTL: ${payload?.ttlSeconds || 180} сек.`); - setStatus(status, usePassword - ? 'Подключение по коду включено с доп. паролем.' - : 'Подключение по коду включено без доп. пароля.', 'info'); - passwordInput.value = ''; - } catch (error) { - const message = toUserMessage(error, 'Не удалось включить pairing.'); - setAuthError(message); - setStatus(status, message, 'error'); - } finally { - setButtonsBusy(false); - } + dialogSaveBtn.addEventListener('click', async () => { + const password = String(dialogPasswordInput.value || ''); + const confirm = String(dialogPasswordConfirmInput.value || ''); + const currentMode = dialogMode; + if (!password) { + setStatus(status, 'Введите дополнительный пароль.', 'error'); return; } - - if (target.id === 'disable-pairing-btn') { - setButtonsBusy(true); - try { - await authService.upsertEspPairingSettings({ - enabled: false, - passwordHash: '', - ttlSeconds: 180, - }); - setAuthInfo('Подключение по коду выключено.'); - setStatus(status, 'Подключение по коду выключено.', 'info'); - } catch (error) { - const message = toUserMessage(error, 'Не удалось выключить pairing.'); - setAuthError(message); - setStatus(status, message, 'error'); - } finally { - setButtonsBusy(false); - } + if (password !== confirm) { + setStatus(status, 'Пароли не совпадают.', 'error'); + return; + } + dialogSaveBtn.disabled = true; + try { + const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password); + const payload = await authService.upsertEspPairingSettings({ + enabled: true, + passwordHash, + ttlSeconds: 180, + }); + pairingPasswordConfigured = true; + saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServer, true); + closePasswordDialog(); + renderSettingsCard(); + setAuthInfo(`Подключение по коду включено с дополнительным паролем. TTL: ${payload?.ttlSeconds || 180} сек.`); + setStatus(status, currentMode === 'change' + ? 'Дополнительный пароль изменён.' + : 'Дополнительный пароль задан.', 'info'); + } catch (error) { + const message = toUserMessage(error, 'Не удалось сохранить дополнительный пароль.'); + setAuthError(message); + setStatus(status, message, 'error'); + } finally { + dialogSaveBtn.disabled = false; } }); @@ -322,7 +481,7 @@ export function render({ navigate }) { void (async () => { try { - syncPasswordUi(); + renderSettingsCard(); await loadSavedKeys(); await reloadRequests({ silent: true }); cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => {