SHiNE-server/shine-UI/js/pages/device-pairing-view.js
AidarKC 56db6d0add TrustedDeviceLogin API и настройки входа через устройство
Что сделано:\n- публичный API сценария входа через доверенное устройство переведён на TrustedDeviceLogin\n- добавлен GetTrustedDeviceLoginSettings\n- отсутствие записи настроек на сервере теперь трактуется как enabled=true и hasPassword=false\n- ttlSeconds убран из клиентского API, TTL заявки фиксирован на сервере: 300 секунд\n- в shine-UI добавлен отдельный экран настроек входа через устройство и статус на основном экране\n- browser wallet переведён на новые TrustedDeviceLogin операции\n- в wallet добавлен выбор rootKey/deviceKey для будущего запроса подписи\n- документация API обновлена\n\nЧто ещё не проверено вручную end-to-end:\n- полный сценарий UI/plugin после этого деплоя не прогонялся руками до конца\n- сам signaling подписи в wallet всё ещё не реализован
2026-06-18 14:19:31 +04:00

664 lines
29 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,
authorizeSession,
refreshSessions,
setAuthError,
setAuthInfo,
state,
terminateCurrentSession,
} from '../state.js';
import { formatRelativeTime, showToast } from '../services/channels-ux.js';
import {
buildSecretsPayload,
buildSessionAttachPayload,
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: 'Подключить по коду' };
const PAIRING_PASSWORD_STATE_PREFIX = 'shine_pairing_password_state_v1';
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 requesterSessionType = Number(request?.requesterSessionType || 0);
const expiresText = request?.expiresAtMs ? formatRelativeTime(request.expiresAtMs) : '—';
const sessionOnly = requesterSessionType === 50;
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">Тип сессии: ${requesterSessionType || '—'}</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">${sessionOnly ? 'Подключить wallet-session' : 'Подключить без доп. ключей'}</button>
${sessionOnly ? '' : '<button class="primary-btn" type="button" data-action="approve-full">Подключить и передать ключи</button>'}
<button class="text-btn" type="button" data-action="reject">Отклонить</button>
</div>
</div>
`;
}
async function restoreAuthorizedSessionForPairing() {
const login = String(state.session.login || '').trim();
const sessionId = String(state.session.sessionId || '').trim();
if (!login || !sessionId) {
throw new Error('Нет активной сохранённой сессии для восстановления pairing-доступа.');
}
const resumed = await authService.resumeSession(login, sessionId);
authorizeSession({
login: resumed.login || login,
sessionId: resumed.sessionId || sessionId,
storagePwd: resumed.storagePwd || state.session.storagePwdInMemory,
});
await refreshSessions().catch(() => {});
return resumed;
}
async function runPairingOpWithSessionRestore(runAction) {
try {
return await runAction();
} catch (error) {
const code = String(error?.code || '').trim().toUpperCase();
if (code !== 'PAIRING_REQUIRES_AUTH_SESSION' && code !== 'NOT_AUTHENTICATED') {
throw error;
}
try {
await restoreAuthorizedSessionForPairing();
} catch (restoreError) {
const restoreCode = String(restoreError?.code || '').trim().toUpperCase();
if (restoreCode === 'SESSION_NOT_FOUND'
|| restoreCode === 'SESSION_KEY_NOT_ACTUAL'
|| restoreCode === 'SESSION_OF_ANOTHER_USER') {
await terminateCurrentSession({
infoMessage: 'Сохранённая сессия устарела. Выполните вход заново.',
});
}
throw restoreError;
}
return runAction();
}
}
function makePasswordToggleIcons() {
return {
eye: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M2.4 12s3.6-6.5 9.6-6.5S21.6 12 21.6 12s-3.6 6.5-9.6 6.5S2.4 12 2.4 12Z" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<circle cx="12" cy="12" r="2.9" fill="none" stroke="currentColor" stroke-width="1.8"/>
</svg>
`,
eyeOff: `
<svg class="key-toggle-icon" viewBox="0 0 24 24" aria-hidden="true" focusable="false">
<path d="M3 4l18 16" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"/>
<path d="M2.4 12s3.6-6.5 9.6-6.5c2.4 0 4.5.8 6.1 1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
<path d="M21.6 12s-3.6 6.5-9.6 6.5c-2.4 0-4.5-.8-6.1-1.9" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linejoin="round"/>
</svg>
`,
};
}
function localPairingPasswordStateKey(login, serverUrl) {
return `${PAIRING_PASSWORD_STATE_PREFIX}:${String(serverUrl || '').trim()}:${String(login || '').trim().toLowerCase()}`;
}
function loadLocalPairingPasswordState(login, serverUrl) {
try {
const raw = localStorage.getItem(localPairingPasswordStateKey(login, serverUrl));
if (!raw) return false;
const parsed = JSON.parse(raw);
return !!parsed?.hasPassword;
} catch {
return false;
}
}
function saveLocalPairingPasswordState(login, serverUrl, hasPassword) {
try {
localStorage.setItem(localPairingPasswordStateKey(login, serverUrl), JSON.stringify({
hasPassword: !!hasPassword,
updatedAtMs: Date.now(),
}));
} catch {}
}
export function render({ navigate }) {
const screen = document.createElement('section');
screen.className = 'stack';
let savedKeys = null;
let requests = [];
let cleanupEvent = () => {};
let disposed = false;
let trustedDeviceLoginSettings = { enabled: true, hasPassword: false };
let settingsBusy = false;
let pairingPasswordConfigured = false;
let dialogMode = '';
screen.append(
renderHeader({
title: 'Подключить по коду',
leftAction: { label: '←', onClick: () => navigate('connect-device-view') },
}),
);
const settingsCard = document.createElement('div');
settingsCard.className = 'card stack';
const passwordIcons = makePasswordToggleIcons();
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">Если код не введён, показываются все активные заявки. Для wallet-заявки можно выпустить отдельную session-only сессию без передачи постоянных ключей.</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 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 passwordDialog = document.createElement('div');
passwordDialog.hidden = true;
passwordDialog.style.position = 'fixed';
passwordDialog.style.inset = '0';
passwordDialog.style.zIndex = '30';
passwordDialog.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.72); backdrop-filter:blur(4px);" data-action="close-dialog"></div>
<div class="card stack" style="position:absolute; left:50%; top:24px; width:min(calc(100vw - 32px), 360px); transform:translateX(-50%); gap:12px; box-shadow:var(--shadow);">
<div class="stack" style="gap:6px;">
<p class="field-label" id="pairing-dialog-title">Задать дополнительный пароль</p>
<p class="meta-muted" id="pairing-dialog-text">Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.</p>
</div>
<label class="stack">
<span class="field-label">Пароль</span>
<div class="inline-input-row">
<input class="input key-input" id="pairing-dialog-password" type="password" autocomplete="new-password" placeholder="Введите дополнительный пароль" />
<button class="icon-btn key-toggle-btn" type="button" id="pairing-dialog-password-toggle" aria-label="Показать пароль" title="Показать пароль">${passwordIcons.eyeOff}</button>
</div>
</label>
<label class="stack">
<span class="field-label">Подтвердите пароль</span>
<div class="inline-input-row">
<input class="input key-input" id="pairing-dialog-password-confirm" type="password" autocomplete="new-password" placeholder="Повторите пароль" />
<button class="icon-btn key-toggle-btn" type="button" id="pairing-dialog-password-confirm-toggle" aria-label="Показать пароль" title="Показать пароль">${passwordIcons.eyeOff}</button>
</div>
</label>
<div class="row" style="flex-wrap:wrap;">
<button class="primary-btn" type="button" id="pairing-dialog-save">Сохранить пароль</button>
<button class="ghost-btn" type="button" data-action="close-dialog">Отмена</button>
</div>
</div>
`;
screen.append(passwordDialog);
const dialogTitleEl = passwordDialog.querySelector('#pairing-dialog-title');
const dialogTextEl = passwordDialog.querySelector('#pairing-dialog-text');
const dialogPasswordInput = passwordDialog.querySelector('#pairing-dialog-password');
const dialogPasswordConfirmInput = passwordDialog.querySelector('#pairing-dialog-password-confirm');
const dialogSaveBtn = passwordDialog.querySelector('#pairing-dialog-save');
const dialogPasswordToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-toggle');
const dialogPasswordConfirmToggleBtn = passwordDialog.querySelector('#pairing-dialog-password-confirm-toggle');
const dialogOverlay = document.createElement('div');
dialogOverlay.hidden = true;
dialogOverlay.style.position = 'absolute';
dialogOverlay.style.inset = '0';
dialogOverlay.style.zIndex = '2';
dialogOverlay.innerHTML = `
<div style="position:absolute; inset:0; background:rgba(5,9,16,0.78);"></div>
<div class="card stack" style="position:absolute; left:50%; top:50%; width:min(calc(100% - 32px), 320px); transform:translate(-50%, -50%); gap:12px; box-shadow:var(--shadow);">
<p class="field-label" id="pairing-dialog-overlay-title">Ошибка</p>
<p class="meta-muted" id="pairing-dialog-overlay-text"></p>
<div class="row" style="justify-content:flex-end; flex-wrap:wrap;">
<button class="ghost-btn" type="button" id="pairing-dialog-overlay-cancel" hidden>Нет</button>
<button class="primary-btn" type="button" id="pairing-dialog-overlay-confirm">Ок</button>
</div>
</div>
`;
passwordDialog.append(dialogOverlay);
const dialogOverlayTitleEl = dialogOverlay.querySelector('#pairing-dialog-overlay-title');
const dialogOverlayTextEl = dialogOverlay.querySelector('#pairing-dialog-overlay-text');
const dialogOverlayCancelBtn = dialogOverlay.querySelector('#pairing-dialog-overlay-cancel');
const dialogOverlayConfirmBtn = dialogOverlay.querySelector('#pairing-dialog-overlay-confirm');
let dialogOverlayOnConfirm = null;
let dialogOverlayOnCancel = null;
const closeDialogOverlay = () => {
dialogOverlay.hidden = true;
dialogOverlayOnConfirm = null;
dialogOverlayOnCancel = null;
};
const showDialogAlert = (message) => {
dialogOverlayTitleEl.textContent = 'Ошибка';
dialogOverlayTextEl.textContent = message;
dialogOverlayCancelBtn.hidden = true;
dialogOverlayConfirmBtn.textContent = 'Ок';
dialogOverlay.hidden = false;
dialogOverlayOnConfirm = () => closeDialogOverlay();
dialogOverlayOnCancel = null;
};
const showDialogConfirm = (message, { title = 'Подтверждение', confirmLabel = 'Да', cancelLabel = 'Нет', onConfirm, onCancel } = {}) => {
dialogOverlayTitleEl.textContent = title;
dialogOverlayTextEl.textContent = message;
dialogOverlayCancelBtn.hidden = false;
dialogOverlayCancelBtn.textContent = cancelLabel;
dialogOverlayConfirmBtn.textContent = confirmLabel;
dialogOverlay.hidden = false;
dialogOverlayOnConfirm = onConfirm || (() => closeDialogOverlay());
dialogOverlayOnCancel = onCancel || (() => closeDialogOverlay());
};
const bindPasswordToggle = (input, button) => {
button.addEventListener('click', () => {
if (input.type === 'password') {
input.type = 'text';
button.innerHTML = passwordIcons.eye;
button.setAttribute('aria-label', 'Скрыть пароль');
button.title = 'Скрыть пароль';
} else {
input.type = 'password';
button.innerHTML = passwordIcons.eyeOff;
button.setAttribute('aria-label', 'Показать пароль');
button.title = 'Показать пароль';
}
});
};
bindPasswordToggle(dialogPasswordInput, dialogPasswordToggleBtn);
bindPasswordToggle(dialogPasswordConfirmInput, dialogPasswordConfirmToggleBtn);
const openPasswordDialog = (mode) => {
dialogMode = mode;
dialogTitleEl.textContent = mode === 'change'
? 'Изменить дополнительный пароль'
: 'Задать дополнительный пароль';
dialogTextEl.textContent =
'Дополнительный пароль не даёт права на подключение сам по себе. Он только отсекает лишние заявки, чтобы посторонние не могли засыпать ваш аккаунт запросами. Обычно он не нужен, поэтому при желании можно задать и что-то простое, что легко запомнить.';
dialogPasswordInput.value = '';
dialogPasswordConfirmInput.value = '';
dialogPasswordInput.type = 'password';
dialogPasswordConfirmInput.type = 'password';
dialogPasswordToggleBtn.innerHTML = passwordIcons.eyeOff;
dialogPasswordConfirmToggleBtn.innerHTML = passwordIcons.eyeOff;
passwordDialog.hidden = false;
};
const closePasswordDialog = () => {
dialogMode = '';
closeDialogOverlay();
passwordDialog.hidden = true;
};
const enablePairingWithoutPassword = async () => {
const payload = await runPairingOpWithSessionRestore(() => authService.upsertTrustedDeviceLoginSettings({
enabled: true,
passwordHash: '',
}));
pairingPasswordConfigured = false;
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServerLogin || state.entrySettings.shineServerHttp, false);
return payload;
};
const removeAdditionalPassword = async () => {
await enablePairingWithoutPassword();
setAuthInfo('Подключение по коду без дополнительного пароля включено.');
setStatus(status, 'Дополнительный пароль убран. Подключение по коду теперь работает без него.', 'info');
};
const setSettingsBusy = (flag) => {
settingsBusy = flag;
renderSettingsCard();
};
const formatTrustedDeviceLoginState = () => {
if (!trustedDeviceLoginSettings.enabled) return 'Вход через другое устройство запрещён.';
if (trustedDeviceLoginSettings.hasPassword) return 'Вход через другое устройство разрешён только с дополнительным паролем.';
return 'Вход через другое устройство разрешён без дополнительного пароля.';
};
const reloadTrustedDeviceLoginSettings = async () => {
trustedDeviceLoginSettings = await runPairingOpWithSessionRestore(() => authService.getTrustedDeviceLoginSettings());
pairingPasswordConfigured = !!trustedDeviceLoginSettings.hasPassword;
};
const renderSettingsCard = () => {
settingsCard.innerHTML = '';
const title = document.createElement('p');
title.className = 'field-label';
title.textContent = 'Статус входа через другое устройство';
const stateText = document.createElement('p');
stateText.className = 'meta-muted';
stateText.textContent = formatTrustedDeviceLoginState();
const note = document.createElement('p');
note.className = 'meta-muted';
note.textContent = 'Открыть подробные настройки можно на отдельном экране.';
const actions = document.createElement('div');
actions.className = 'row';
actions.style.flexWrap = 'wrap';
const openSettingsBtn = document.createElement('button');
openSettingsBtn.className = 'primary-btn';
openSettingsBtn.type = 'button';
openSettingsBtn.textContent = 'Изменить настройки входа';
openSettingsBtn.disabled = settingsBusy;
openSettingsBtn.addEventListener('click', () => navigate('trusted-device-login-settings-view'));
actions.append(openSettingsBtn);
settingsCard.append(title, stateText, note, actions);
};
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 {
await reloadTrustedDeviceLoginSettings();
renderSettingsCard();
requests = await runPairingOpWithSessionRestore(() => authService.listTrustedDeviceLoginRequests());
renderRequests();
if (!silent) {
setStatus(status, 'Список заявок на вход обновлён.', 'info');
}
} catch (error) {
const message = toUserMessage(error, 'Не удалось загрузить заявки на вход.');
setAuthError(message);
setStatus(status, message, 'error');
}
};
const setButtonsBusy = (flag) => {
refreshBtn.disabled = flag;
};
const approveRequest = async (request, mode) => {
const withExtras = mode === 'with-extras';
let payload;
if (!withExtras && Number(request?.requesterSessionType || 0) === 50) {
const delegatedSession = await authService.createDelegatedSessionWithDeviceKey({
login: state.session.login,
devicePrivPkcs8: String(savedKeys?.deviceKey || '').trim(),
sessionKey: String(request?.requesterSessionKey || '').trim(),
sessionType: Number(request?.requesterSessionType || 50) || 50,
clientPlatform: String(request?.requesterClientPlatform || '').trim() || 'Wallet plugin',
clientInfo: 'Wallet session approved via device pairing',
});
payload = buildSessionAttachPayload({
login: state.session.login,
session: delegatedSession,
});
} else {
const keys = buildTransferKeys(savedKeys, { withExtras });
payload = buildSecretsPayload({
login: state.session.login,
keys,
mode: withExtras ? 'with-extras' : 'device-only',
});
}
const encryptedPayload = await encryptPairingPayloadForRequester(request?.requesterSessionKey, payload);
await runPairingOpWithSessionRestore(() => authService.approveTrustedDeviceLogin(request?.pairingId, encryptedPayload));
const sessionOnly = !withExtras && Number(request?.requesterSessionType || 0) === 50;
showToast(
withExtras
? 'Ключи переданы на новое устройство'
: sessionOnly
? 'Wallet-session выпущена для нового устройства'
: 'Новое устройство подключено',
);
setAuthInfo(
withExtras
? 'Заявка подтверждена, ключи переданы.'
: sessionOnly
? 'Заявка подтверждена, для нового устройства создана отдельная wallet-session без передачи постоянных ключей.'
: 'Заявка подтверждена без передачи доп. ключей.',
);
await refreshSessions().catch(() => {});
await reloadRequests({ silent: true });
};
passwordDialog.addEventListener('click', (event) => {
const target = event.target;
if (!(target instanceof HTMLElement)) return;
if (target.dataset.action === 'close-dialog') {
closePasswordDialog();
}
});
dialogOverlayConfirmBtn.addEventListener('click', async () => {
const handler = dialogOverlayOnConfirm;
if (!handler) return;
await handler();
});
dialogOverlayCancelBtn.addEventListener('click', async () => {
const handler = dialogOverlayOnCancel;
if (!handler) {
closeDialogOverlay();
return;
}
await handler();
});
dialogSaveBtn.addEventListener('click', async () => {
const password = String(dialogPasswordInput.value || '');
const confirm = String(dialogPasswordConfirmInput.value || '');
const currentMode = dialogMode;
if (!password && !confirm) {
showDialogConfirm('Пароль не задан. Хотите убрать дополнительный пароль?', {
title: 'Пароль не задан',
confirmLabel: 'Да',
cancelLabel: 'Нет',
onConfirm: async () => {
closeDialogOverlay();
dialogSaveBtn.disabled = true;
try {
await removeAdditionalPassword();
closePasswordDialog();
renderSettingsCard();
} catch (error) {
const message = toUserMessage(error, 'Не удалось убрать дополнительный пароль.');
setAuthError(message);
showDialogAlert(message);
} finally {
dialogSaveBtn.disabled = false;
}
},
onCancel: () => {
closeDialogOverlay();
},
});
return;
}
if (!password || !confirm) {
showDialogAlert('Заполните пароль и подтверждение пароля.');
return;
}
if (password !== confirm) {
showDialogAlert('Пароли не совпадают.');
return;
}
dialogSaveBtn.disabled = true;
try {
const passwordHash = await deriveEspPairingPasswordHash(state.session.login, password);
await runPairingOpWithSessionRestore(() => authService.upsertTrustedDeviceLoginSettings({
enabled: true,
passwordHash,
}));
pairingPasswordConfigured = true;
saveLocalPairingPasswordState(state.session.login, state.entrySettings.shineServerLogin || state.entrySettings.shineServerHttp, true);
closePasswordDialog();
renderSettingsCard();
setAuthInfo('Подключение по коду включено с дополнительным паролем.');
setStatus(status, currentMode === 'change'
? 'Дополнительный пароль изменён.'
: 'Дополнительный пароль задан.', 'info');
} catch (error) {
const message = toUserMessage(error, 'Не удалось сохранить дополнительный пароль.');
setAuthError(message);
showDialogAlert(message);
} finally {
dialogSaveBtn.disabled = 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 runPairingOpWithSessionRestore(() => authService.rejectTrustedDeviceLogin(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 {
renderSettingsCard();
await loadSavedKeys();
await reloadRequests({ silent: true });
cleanupEvent = authService.onEvent('IncomingTrustedDeviceLoginRequest', () => {
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;
}