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, formatPairingShortCode, } 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 `

Код подключения

00 00 00 00 00

Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».

`; } function formatRemaining(ms) { const safe = Math.max(0, Math.floor(Number(ms || 0) / 1000)); const minutes = Math.floor(safe / 60); const seconds = safe % 60; return `${minutes} мин ${seconds} сек`; } function resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl) { resultWrap.style.display = 'none'; shortCodeEl.textContent = formatPairingShortCode(''); statusHintEl.textContent = 'Покажите этот код на уже подключённом устройстве в разделе «Подключить по коду».'; onlineHintEl.textContent = ''; expireHintEl.textContent = ''; } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; let pollTimer = 0; let countdownTimer = 0; let activePairingId = ''; let requesterMaterial = null; let activeExpiresAtMs = 0; 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 = `

Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.

`; 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 usePasswordInput = formCard.querySelector('#pair-use-password'); const passwordInput = formCard.querySelector('#pair-password'); const startBtn = formCard.querySelector('#pair-start-btn'); const modeHintEl = formCard.querySelector('#pair-mode-hint'); 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 cancelBtn = document.createElement('button'); cancelBtn.className = 'ghost-btn'; cancelBtn.type = 'button'; cancelBtn.textContent = 'Отмена'; cancelBtn.style.display = 'none'; const syncPasswordUi = () => { const usePassword = !!usePasswordInput.checked; passwordInput.parentElement.style.display = usePassword ? '' : 'none'; modeHintEl.textContent = usePassword ? 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети. Если на доверённом устройстве включён доп. пароль, введите его.' : 'Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.'; if (!usePassword) { passwordInput.value = ''; } }; const stopPolling = () => { if (pollTimer) { window.clearTimeout(pollTimer); pollTimer = 0; } }; const stopCountdown = () => { if (countdownTimer) { window.clearInterval(countdownTimer); countdownTimer = 0; } }; const updateCountdown = () => { const leftMs = activeExpiresAtMs - Date.now(); if (leftMs <= 0) { stopPolling(); stopCountdown(); activePairingId = ''; activeExpiresAtMs = 0; startBtn.disabled = false; cancelBtn.style.display = 'none'; resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); return; } expireHintEl.textContent = `Код действителен ещё ${formatRemaining(leftMs)}.`; }; const startCountdown = (expiresAtMs) => { activeExpiresAtMs = Number(expiresAtMs || 0); stopCountdown(); updateCountdown(); countdownTimer = window.setInterval(updateCountdown, 1000); }; const clearActivePairing = () => { stopPolling(); stopCountdown(); activePairingId = ''; activeExpiresAtMs = 0; cancelBtn.style.display = 'none'; resetCodeCard(resultWrap, shortCodeEl, statusHintEl, onlineHintEl, expireHintEl); }; const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); await terminateCurrentSession({ closeServerSession: true }); await clearStoredMessages().catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); 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 finalizeAuthorizedSessionAttach = async (payloadSession, login, requesterKeys) => { const sessionId = String(payloadSession?.sessionId || '').trim(); const storagePwd = String(payloadSession?.storagePwd || '').trim(); if (!sessionId || !storagePwd) { throw new Error('В session-only payload нет sessionId или storagePwd'); } const sessionMaterial = { sessionId, sessionKey: requesterKeys.sessionKey, sessionPrivPkcs8: requesterKeys.sessionPrivPkcs8, sessionType: Number(payloadSession?.sessionType || 50) || 50, }; await terminateCurrentSession({ closeServerSession: true }); await clearStoredMessages().catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); await authService.persistSessionMaterial(login, sessionMaterial); const resumed = await authService.resumeSession(login, sessionId); authorizeSession({ login: resumed.login || login, sessionId: resumed.sessionId || sessionId, storagePwd: resumed.storagePwd || storagePwd, }); state.loginDraft.login = resumed.login || login; state.loginDraft.password = ''; await refreshSessions(); setAuthInfo(`Session-only вход выполнен для @${resumed.login || login}.`); showToast(`Wallet-session подключена для @${resumed.login || login}`); navigate('profile-view'); }; const schedulePoll = () => { stopPolling(); if (!activePairingId || isDisposed) return; pollTimer = window.setTimeout(async () => { try { const payload = await authService.getTrustedDeviceLoginStatus(activePairingId); const stateValue = String(payload?.state || ''); if (stateValue === 'created') { schedulePoll(); return; } if (stateValue === 'approved') { stopCountdown(); setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info'); const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial); const decodedType = String(decoded?.type || ''); if (decodedType === 'shine-esp-session-attach') { await finalizeAuthorizedSessionAttach(decoded.session, decoded.login || loginInput.value, requesterMaterial); return; } if (decodedType !== 'shine-esp-pairing-transfer') { throw new Error('Получен неподдерживаемый pairing payload'); } await finalizeAuthorizedLogin(decoded.keys, decoded.login || loginInput.value); return; } if (stateValue === 'rejected') { clearActivePairing(); startBtn.disabled = false; setStatus(status, 'Подключение отклонено на доверенном устройстве.', 'error'); return; } if (stateValue === 'canceled') { clearActivePairing(); startBtn.disabled = false; setStatus(status, 'Ожидание подключения отменено.', 'error'); return; } if (stateValue === 'expired') { clearActivePairing(); startBtn.disabled = false; setStatus(status, 'Время ожидания истекло. Получите новый код.', 'error'); return; } schedulePoll(); } catch (error) { stopPolling(); startBtn.disabled = false; const message = toUserMessage(error, 'Не удалось проверить статус pairing.'); setAuthError(message); setStatus(status, message, 'error'); } }, 2200); }; usePasswordInput.addEventListener('change', syncPasswordUi); syncPasswordUi(); startBtn.addEventListener('click', async () => { const login = String(loginInput.value || '').trim(); const usePassword = !!usePasswordInput.checked; const password = String(passwordInput.value || ''); if (!login) { setStatus(status, 'Введите логин.', 'error'); return; } if (usePassword && !password) { setStatus(status, 'Введите пароль подключения.', 'error'); return; } startBtn.disabled = true; setAuthBusy(true); setAuthError(''); setAuthInfo(''); setStatus(status, 'Проверяем пользователя и создаём pairing-заявку...', 'info'); clearActivePairing(); try { await authService.reconnect(state.entrySettings.shineServer); const user = await authService.getUser(login); if (!user?.exists) { throw new Error('Пользователь не найден.'); } requesterMaterial = await createRequesterPairingMaterial(); const passwordHash = usePassword ? await deriveEspPairingPasswordHash(login, password) : ''; const payload = await authService.startTrustedDeviceLogin({ login, passwordHash, requesterSessionKey: requesterMaterial.sessionKey, payloadType: 3, }); activePairingId = String(payload?.pairingId || ''); if (!activePairingId) { throw new Error('Сервер не вернул pairingId.'); } shortCodeEl.textContent = formatPairingShortCode(payload?.shortCode || ''); statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить устройство -> Подключить по коду.'; onlineHintEl.textContent = payload?.trustedSessionOnline ? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.' : 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.'; resultWrap.style.display = ''; cancelBtn.style.display = ''; startCountdown(payload?.expiresAtMs); 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); } }); cancelBtn.addEventListener('click', async () => { if (!activePairingId || !requesterMaterial?.sessionKey) { clearActivePairing(); startBtn.disabled = false; cancelBtn.style.display = 'none'; return; } cancelBtn.disabled = true; try { await authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey); clearActivePairing(); startBtn.disabled = false; setStatus(status, 'Ожидание подключения отменено.', 'info'); } catch (error) { const message = toUserMessage(error, 'Не удалось отменить ожидание подключения.'); setAuthError(message); setStatus(status, message, 'error'); } finally { cancelBtn.disabled = false; cancelBtn.style.display = activePairingId ? '' : 'none'; } }); screen.cleanup = () => { isDisposed = true; stopPolling(); stopCountdown(); }; const resultActions = document.createElement('div'); resultActions.className = 'row'; resultActions.append(cancelBtn); resultWrap.append(resultActions); screen.append(formCard, status, resultWrap); return screen; }