SHA256
322 lines
13 KiB
JavaScript
322 lines
13 KiB
JavaScript
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="stack">
|
|
<span class="meta-muted">Задайте пароль, который новое устройство введёт перед получением кода.</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 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 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;
|
|
};
|
|
|
|
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 });
|
|
};
|
|
|
|
settingsCard.addEventListener('click', async (event) => {
|
|
const target = event.target;
|
|
if (!(target instanceof HTMLElement)) return;
|
|
|
|
if (target.id === 'enable-pairing-btn') {
|
|
const password = String(passwordInput.value || '');
|
|
if (!password) {
|
|
setStatus(status, 'Введите pairing-пароль.', 'error');
|
|
return;
|
|
}
|
|
setButtonsBusy(true);
|
|
try {
|
|
const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password);
|
|
const payload = await authService.upsertEspPairingSettings({
|
|
enabled: true,
|
|
passwordHash,
|
|
ttlSeconds: 180,
|
|
});
|
|
setAuthInfo(`Подключение по коду включено. TTL: ${payload?.ttlSeconds || 180} сек.`);
|
|
setStatus(status, 'Подключение по коду включено или обновлено.', '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 {
|
|
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;
|
|
}
|