SHiNE-server/shine-UI/js/pages/device-pairing-view.js

348 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 `
<div class="card stack" data-pairing-id="${String(request?.pairingId || '')}">
<div class="row" style="align-items:flex-start;">
<div class="stack" style="gap:4px;">
<div style="font-size:28px; font-weight:700; letter-spacing:0.14em;">${shortCode}</div>
<span class="meta-muted">Платформа: ${client}</span>
<span class="meta-muted">Тип payload: ${Number(request?.payloadType || 0)}</span>
<span class="meta-muted">Истекает: ${expiresText}</span>
</div>
</div>
<div class="row" style="flex-wrap:wrap;">
<button class="ghost-btn" type="button" data-action="approve-device">Подключить без доп. ключей</button>
<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
</div>
</div>
`;
}
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 = `
<p class="field-label">Пароль подключения</p>
<label class="checkbox-row">
<input type="checkbox" id="pairing-use-password" />
использовать доп. пароль
</label>
<label class="stack">
<span class="meta-muted" id="pairing-password-help">Если включено, новое устройство должно будет ввести этот пароль перед получением кода.</span>
<input class="input" id="pairing-password" type="password" autocomplete="new-password" placeholder="Новый pairing-пароль" />
</label>
<div class="row">
<button class="primary-btn" type="button" id="enable-pairing-btn">Включить / обновить</button>
<button class="ghost-btn" type="button" id="disable-pairing-btn">Выключить</button>
</div>
<p class="meta-muted">Пароль хранится на сервере только в виде hash. После включения можно переходить к заявкам ниже.</p>
`;
const keySummaryCard = document.createElement('div');
keySummaryCard.className = 'card stack';
keySummaryCard.innerHTML = `
<p class="field-label">Что передаётся при расширенном подключении</p>
<p class="meta-muted" id="pairing-key-summary">Проверяем локальные ключи...</p>
`;
const requestsCard = document.createElement('div');
requestsCard.className = 'card stack';
requestsCard.innerHTML = `
<div class="row" style="align-items:flex-end; gap:10px; flex-wrap:wrap;">
<label class="stack" style="flex:1 1 180px;">
<span class="field-label">Код нового устройства</span>
<input class="input" id="pairing-code-filter" inputmode="numeric" maxlength="7" placeholder="7 цифр" />
</label>
<button class="ghost-btn" type="button" id="refresh-pairing-requests">Обновить заявки</button>
</div>
<p class="meta-muted">Если код не введён, показываются все активные заявки. Для подключения без доп. ключей передаётся только device key.</p>
<div class="stack" id="pairing-requests-list"></div>
`;
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;
}