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;
|
||
}
|