Добавить UI pairing по коду и обновить документацию агента

This commit is contained in:
AidarKC
2026-06-14 20:39:05 +04:00
parent b166013707
commit c681b4d684
13 changed files with 846 additions and 5 deletions
+6 -1
View File
@@ -28,6 +28,7 @@ export function render({ navigate }) {
</div>
<div class="stack">
<button class="primary-btn" type="button" id="open-qr">Показать QR-код для подключения</button>
<button class="ghost-btn" type="button" id="open-pairing">Подключить по коду</button>
<button class="text-btn" type="button" id="open-camera">Подключить через камеру</button>
</div>
`;
@@ -37,6 +38,7 @@ export function render({ navigate }) {
const deviceToggle = card.querySelector('#connect-device');
const statusEl = card.querySelector('#connect-keys-status');
const openQrBtn = card.querySelector('#open-qr');
const openPairBtn = card.querySelector('#open-pairing');
deviceToggle.checked = true;
rootToggle.addEventListener('change', () => {
@@ -70,7 +72,7 @@ export function render({ navigate }) {
<p class="meta-muted">подключение происходит напрямую через QR</p>
<p class="meta-muted">сервер не используется</p>
<p class="meta-muted">текущая логика: устройство 1 показывает QR, устройство 2 сканирует</p>
<p class="meta-muted">обратный сценарий пока не реализован</p>
<p class="meta-muted">для сценария через сервер используйте кнопку «Подключить по коду»</p>
</div>
<button class="primary-btn" type="button" data-close="true">OK</button>
</div>
@@ -87,6 +89,7 @@ export function render({ navigate }) {
card.querySelector('#tech-help').addEventListener('click', openHelp);
card.querySelector('#open-qr').addEventListener('click', () => navigate('device-qr-view'));
card.querySelector('#open-pairing').addEventListener('click', () => navigate('device-pairing-view'));
card.querySelector('#open-camera').addEventListener('click', () => navigate('device-camera-view'));
(async () => {
@@ -109,6 +112,7 @@ export function render({ navigate }) {
blockchainToggle.checked = state.deviceConnect.blockchain;
deviceToggle.checked = hasDevice;
openQrBtn.disabled = !hasDevice;
openPairBtn.disabled = !hasDevice;
const available = [
hasDevice ? 'device' : '',
@@ -126,6 +130,7 @@ export function render({ navigate }) {
state.deviceConnect.blockchain = false;
state.deviceConnect.device = false;
openQrBtn.disabled = true;
openPairBtn.disabled = true;
statusEl.textContent = 'Не удалось прочитать сохранённые ключи на этом устройстве.';
}
})();
+321
View File
@@ -0,0 +1,321 @@
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;
}
@@ -0,0 +1,240 @@
import { renderHeader } from '../components/header.js';
import {
authService,
authorizeSession,
clearAuthMessages,
clearBrowserClientData,
refreshSessions,
setAuthBusy,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
import { showToast } from '../services/channels-ux.js';
import { decryptPairingPayloadFromEnvelope, deriveEspPairingPasswordHash, createRequesterPairingMaterial } from '../services/device-pairing-service.js';
import { clearClientAuthData, saveEncryptedUserSecrets } from '../services/key-vault.js';
import { clearStoredMessages } from '../services/message-store.js';
import { toUserMessage } from '../services/ui-error-texts.js';
export const pageMeta = { id: 'login-other-device-view', title: 'Войти через другое устройство', showAppChrome: false };
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 codeCardHtml() {
return `
<div class="card stack">
<p class="field-label">Код подключения</p>
<div id="pairing-short-code" style="font-size:34px; font-weight:700; letter-spacing:0.18em;">0000000</div>
<p class="meta-muted" id="pairing-status-hint">Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».</p>
<p class="meta-muted" id="pairing-online-hint"></p>
<p class="meta-muted" id="pairing-expire-hint"></p>
</div>
`;
}
function formatExpiresAt(ms) {
const ts = Number(ms || 0);
if (!Number.isFinite(ts) || ts <= 0) return '';
return new Date(ts).toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
let pollTimer = 0;
let activePairingId = '';
let requesterMaterial = null;
let isDisposed = false;
clearAuthMessages();
screen.append(
renderHeader({
title: 'Войти через другое устройство',
leftAction: { label: '←', onClick: () => navigate('login-view') },
}),
);
const formCard = document.createElement('div');
formCard.className = 'card stack';
formCard.innerHTML = `
<label class="stack">
<span class="field-label">Логин</span>
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="@login" value="${String(state.loginDraft.login || '')}" />
</label>
<label class="stack">
<span class="field-label">Пароль подключения</span>
<input class="input" id="pair-password" type="password" autocomplete="current-password" placeholder="Пароль, заданный на другом устройстве" />
</label>
<button class="primary-btn" type="button" id="pair-start-btn">Получить код</button>
<p class="meta-muted">Сначала вводится ваш логин и pairing-пароль. После этого появится 7-значный код для подтверждения на уже подключённом устройстве.</p>
`;
const status = document.createElement('p');
status.className = 'status-line is-unavailable';
status.style.display = 'none';
const resultWrap = document.createElement('div');
resultWrap.className = 'stack';
resultWrap.style.display = 'none';
resultWrap.innerHTML = codeCardHtml();
const loginInput = formCard.querySelector('#pair-login');
const passwordInput = formCard.querySelector('#pair-password');
const startBtn = formCard.querySelector('#pair-start-btn');
const shortCodeEl = resultWrap.querySelector('#pairing-short-code');
const statusHintEl = resultWrap.querySelector('#pairing-status-hint');
const onlineHintEl = resultWrap.querySelector('#pairing-online-hint');
const expireHintEl = resultWrap.querySelector('#pairing-expire-hint');
const stopPolling = () => {
if (pollTimer) {
window.clearTimeout(pollTimer);
pollTimer = 0;
}
};
const finalizeAuthorizedLogin = async (keys, login) => {
const session = await authService.createSessionFromImportedSecrets(login, keys);
await clearStoredMessages().catch(() => {});
clearBrowserClientData();
await clearClientAuthData().catch(() => {});
await terminateCurrentSession();
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
const resumed = await authService.resumeSession(session.login, session.sessionId);
authorizeSession({
login: resumed.login || session.login,
sessionId: resumed.sessionId || session.sessionId,
storagePwd: resumed.storagePwd || session.storagePwd,
});
state.loginDraft.login = resumed.login || session.login;
state.loginDraft.password = '';
await refreshSessions();
setAuthInfo(`Вход через другое устройство выполнен для @${resumed.login || session.login}.`);
showToast(`Устройство подключено для @${resumed.login || session.login}`);
navigate('profile-view');
};
const schedulePoll = () => {
stopPolling();
if (!activePairingId || isDisposed) return;
pollTimer = window.setTimeout(async () => {
try {
const payload = await authService.getEspPairingStatus(activePairingId);
const stateValue = String(payload?.state || '');
if (stateValue === 'created') {
schedulePoll();
return;
}
if (stateValue === 'approved') {
setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info');
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial);
if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') {
throw new Error('Получен неподдерживаемый pairing payload');
}
await finalizeAuthorizedLogin(decoded.keys, decoded.login || loginInput.value);
return;
}
if (stateValue === 'rejected') {
stopPolling();
startBtn.disabled = false;
setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error');
statusHintEl.textContent = 'Заявка отклонена. Можно попробовать снова.';
return;
}
if (stateValue === 'expired') {
stopPolling();
startBtn.disabled = false;
setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error');
statusHintEl.textContent = 'Заявка истекла. Создайте новую заявку.';
return;
}
schedulePoll();
} catch (error) {
stopPolling();
startBtn.disabled = false;
const message = toUserMessage(error, 'Не удалось проверить статус pairing.');
setAuthError(message);
setStatus(status, message, 'error');
}
}, 2200);
};
startBtn.addEventListener('click', async () => {
const login = String(loginInput.value || '').trim();
const password = String(passwordInput.value || '');
if (!login) {
setStatus(status, 'Введите логин.', 'error');
return;
}
if (!password) {
setStatus(status, 'Введите пароль подключения.', 'error');
return;
}
startBtn.disabled = true;
setAuthBusy(true);
setAuthError('');
setAuthInfo('');
setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info');
resultWrap.style.display = 'none';
stopPolling();
try {
await authService.reconnect(state.entrySettings.shineServer);
const user = await authService.getUser(login);
if (!user?.exists) {
throw new Error('Пользователь не найден.');
}
requesterMaterial = await createRequesterPairingMaterial();
const passwordHash = await deriveEspPairingPasswordHash(login, password);
const payload = await authService.startEspPairing({
login,
passwordHash,
requesterSessionKey: requesterMaterial.sessionKey,
payloadType: 3,
});
activePairingId = String(payload?.pairingId || '');
if (!activePairingId) {
throw new Error('Сервер не вернул pairingId.');
}
shortCodeEl.textContent = String(payload?.shortCode || '0000000');
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.';
onlineHintEl.textContent = payload?.trustedSessionOnline
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
expireHintEl.textContent = payload?.expiresAtMs
? `Код действует до ${formatExpiresAt(payload.expiresAtMs)}.`
: '';
resultWrap.style.display = '';
state.loginDraft.login = login;
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
schedulePoll();
} catch (error) {
startBtn.disabled = false;
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
setAuthError(message);
setStatus(status, message, 'error');
} finally {
setAuthBusy(false);
}
});
screen.cleanup = () => {
isDisposed = true;
stopPolling();
};
screen.append(formCard, status, resultWrap);
return screen;
}
+7 -1
View File
@@ -48,9 +48,15 @@ export function render({ navigate }) {
loginButton.textContent = 'Войти по логину';
loginButton.addEventListener('click', () => navigate('login-password-view'));
const otherDeviceButton = document.createElement('button');
otherDeviceButton.className = 'text-btn';
otherDeviceButton.type = 'button';
otherDeviceButton.textContent = 'Войти через другое устройство';
otherDeviceButton.addEventListener('click', () => navigate('login-other-device-view'));
const actions = document.createElement('div');
actions.className = 'auth-actions login-actions-wide';
actions.append(cameraButton, loginButton);
actions.append(cameraButton, loginButton, otherDeviceButton);
const backButton = document.createElement('button');
backButton.className = 'ghost-btn';