import { renderHeader } from '../components/header.js'; import { authService, refreshSessions, setAuthError, setAuthInfo, state, } from '../state.js'; import { formatRelativeTime, showToast } from '../services/channels-ux.js'; import { buildSecretsPayload, deriveEspPairingPasswordHash, encryptPairingPayloadForRequester, } from '../services/device-pairing-service.js'; import { loadEncryptedUserSecrets } from '../services/key-vault.js'; import { toUserMessage } from '../services/ui-error-texts.js'; export const pageMeta = { id: 'device-pairing-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 normalizeCode(value) { return String(value || '').replace(/\D+/g, '').slice(0, 7); } function buildTransferKeys(savedKeys, { withExtras = false }) { const keys = { deviceKey: String(savedKeys?.deviceKey || '').trim(), blockchainKey: '', rootKey: '', }; if (!keys.deviceKey) { throw new Error('На этом устройстве нет сохранённого device key для передачи.'); } if (withExtras) { if (state.deviceConnect.blockchain && savedKeys?.blockchainKey) { keys.blockchainKey = String(savedKeys.blockchainKey || '').trim(); } if (state.deviceConnect.root && savedKeys?.rootKey) { keys.rootKey = String(savedKeys.rootKey || '').trim(); } } return keys; } function requestCardHtml(request) { const shortCode = String(request?.shortCode || '').trim() || '0000000'; const client = String(request?.requesterClientPlatform || 'unknown'); const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—'; return `
${shortCode}
Платформа: ${client} Тип payload: ${Number(request?.payloadType || 0)} Истекает: ${expiresText}
`; } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; let savedKeys = null; let requests = []; let cleanupEvent = () => {}; let disposed = false; screen.append( renderHeader({ title: 'Подключить по коду', leftAction: { label: '←', onClick: () => navigate('connect-device-view') }, }), ); const settingsCard = document.createElement('div'); settingsCard.className = 'card stack'; settingsCard.innerHTML = `

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

Пароль хранится на сервере только в виде hash. После включения можно переходить к заявкам ниже.

`; const keySummaryCard = document.createElement('div'); keySummaryCard.className = 'card stack'; keySummaryCard.innerHTML = `

Что передаётся при расширенном подключении

Проверяем локальные ключи...

`; const requestsCard = document.createElement('div'); requestsCard.className = 'card stack'; requestsCard.innerHTML = `

Если код не введён, показываются все активные заявки. Для подключения без доп. ключей передаётся только device key.

`; 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 renderRequests = () => { const filterCode = normalizeCode(codeFilterInput.value); const filtered = filterCode ? requests.filter((item) => normalizeCode(item?.shortCode) === filterCode) : requests; requestsListEl.innerHTML = ''; if (!filtered.length) { const empty = document.createElement('p'); empty.className = 'meta-muted'; empty.textContent = filterCode ? 'Заявка с таким кодом пока не найдена.' : 'Активных заявок сейчас нет.'; requestsListEl.append(empty); return; } filtered.forEach((request) => { const wrapper = document.createElement('div'); wrapper.innerHTML = requestCardHtml(request); requestsListEl.append(wrapper.firstElementChild); }); }; const loadSavedKeys = async () => { savedKeys = await loadEncryptedUserSecrets(state.session.login, state.session.storagePwdInMemory); const available = []; if (savedKeys?.deviceKey) available.push('device'); if (savedKeys?.blockchainKey && state.deviceConnect.blockchain) available.push('blockchain'); if (savedKeys?.rootKey && state.deviceConnect.root) available.push('root'); keySummaryEl.textContent = available.length ? `При расширенном подключении будут переданы: ${available.join(', ')}.` : 'На этом устройстве доступен только device key.'; }; const reloadRequests = async ({ silent = false } = {}) => { try { requests = await authService.listEspPairingRequests(); renderRequests(); if (!silent) { setStatus(status, 'Список pairing-заявок обновлён.', 'info'); } } catch (error) { const message = toUserMessage(error, 'Не удалось загрузить pairing-заявки.'); setAuthError(message); setStatus(status, message, 'error'); } }; const setButtonsBusy = (flag) => { enableBtn.disabled = flag; disableBtn.disabled = flag; refreshBtn.disabled = flag; usePasswordInput.disabled = flag; }; const approveRequest = async (request, mode) => { const withExtras = mode === 'with-extras'; const keys = buildTransferKeys(savedKeys, { withExtras }); const payload = buildSecretsPayload({ login: state.session.login, keys, mode: withExtras ? 'with-extras' : 'device-only', }); const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload); await authService.approveEspPairing(request?.pairingId, encryptedPayload); showToast(withExtras ? 'Ключи переданы на новое устройство' : 'Новое устройство подключено'); setAuthInfo(withExtras ? 'Заявка подтверждена, ключи переданы.' : 'Заявка подтверждена без передачи доп. ключей.'); await refreshSessions().catch(() => {}); await reloadRequests({ silent: true }); }; usePasswordInput.addEventListener('change', syncPasswordUi); settingsCard.addEventListener('click', async (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; 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); } 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); } } }); refreshBtn.addEventListener('click', () => { void reloadRequests(); }); codeFilterInput.addEventListener('input', () => { codeFilterInput.value = normalizeCode(codeFilterInput.value); renderRequests(); }); requestsListEl.addEventListener('click', async (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; const action = String(target.dataset.action || ''); if (!action) return; const card = target.closest('[data-pairing-id]'); if (!(card instanceof HTMLElement)) return; const pairingId = String(card.dataset.pairingId || ''); const request = requests.find((item) => String(item?.pairingId || '') === pairingId); if (!request) return; const buttons = [...card.querySelectorAll('button')]; buttons.forEach((btn) => { btn.disabled = true; }); try { if (action === 'approve-device') { await approveRequest(request, 'device-only'); } else if (action === 'approve-full') { await approveRequest(request, 'with-extras'); } else if (action === 'reject') { await authService.rejectEspPairing(pairingId, 'rejected_by_user'); showToast('Заявка отклонена', { kind: 'error' }); await reloadRequests({ silent: true }); } } catch (error) { const message = toUserMessage(error, 'Не удалось обработать pairing-заявку.'); setAuthError(message); setStatus(status, message, 'error'); buttons.forEach((btn) => { btn.disabled = false; }); } }); void (async () => { try { syncPasswordUi(); await loadSavedKeys(); await reloadRequests({ silent: true }); cleanupEvent = authService.onEvent('IncomingEspPairingRequest', () => { if (disposed) return; showToast('Пришла новая заявка на подключение устройства'); void reloadRequests({ silent: true }); }); } catch (error) { const message = toUserMessage(error, 'Не удалось подготовить экран pairing.'); setAuthError(message); setStatus(status, message, 'error'); } })(); screen.cleanup = () => { disposed = true; cleanupEvent(); }; screen.append(settingsCard, keySummaryCard, requestsCard, status); return screen; }