import { createTopBar } from '../components/topbar.js'; import { authService, authorizeSession, consumeAuthReturnPage, isAddingProfileLogin, 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 formatRemaining(ms) { const safe = Math.max(0, Math.ceil(Number(ms || 0) / 1000)); return `${Math.floor(safe / 60)}:${String(safe % 60).padStart(2, '0')}`; } export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack pairing-login'; let pollTimer = 0; let countdownTimer = 0; let activePairingId = ''; let requesterMaterial = null; let activeExpiresAtMs = 0; let isDisposed = false; clearAuthMessages(); const who = document.createElement('p'); who.className = 'pairing-who'; // Код и то, что с ним делать. const codeBlock = document.createElement('div'); codeBlock.className = 'pairing-code-block'; codeBlock.innerHTML = `

Код для входа

  1. Откройте Сияние, где вы уже вошли
  2. Настройки → Устройства
  3. «Подключить по коду» – введите код

Ждём подтверждения…

`; // Сообщение о ходе дела или об ошибке — вместо кода. const notice = document.createElement('div'); notice.className = 'pairing-notice'; notice.setAttribute('role', 'status'); const noticeSpinner = document.createElement('span'); noticeSpinner.className = 'pairing-spinner'; noticeSpinner.setAttribute('aria-hidden', 'true'); const noticeText = document.createElement('p'); notice.append(noticeSpinner, noticeText); const passwordWrap = document.createElement('label'); passwordWrap.className = 'stack pairing-password'; passwordWrap.innerHTML = ` Пароль подключения `; const startBtn = document.createElement('button'); startBtn.className = 'primary-btn'; startBtn.type = 'button'; startBtn.textContent = 'Получить код'; const loginInput = { value: String(state.loginDraft.login || '').trim() }; const usePasswordInput = { checked: false }; const passwordInput = passwordWrap.querySelector('input'); who.textContent = loginInput.value ? `Вход для @${loginInput.value}` : ''; const shortCodeEl = codeBlock.querySelector('#pairing-short-code'); const timerFillEl = codeBlock.querySelector('#pairing-timer-fill'); const timerTextEl = codeBlock.querySelector('#pairing-timer-text'); const onlineEl = codeBlock.querySelector('#pairing-online'); let activeStartedAtMs = 0; // Этапы: creating → code → approving; отдельно password (нужен пароль) и retry (истекло, отклонено, ошибка). const showStage = (stage, message = '', { error = false, action = '' } = {}) => { codeBlock.hidden = stage !== 'code'; passwordWrap.hidden = stage !== 'password'; notice.hidden = stage === 'code' || (stage === 'password' && !message); noticeSpinner.hidden = !(stage === 'creating' || stage === 'approving'); noticeText.textContent = message; notice.classList.toggle('is-error', error); startBtn.hidden = !(stage === 'password' || stage === 'retry'); if (action) startBtn.textContent = action; }; 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; showStage('retry', 'Время ожидания истекло', { error: true, action: 'Получить новый код' }); return; } const totalMs = Math.max(1, activeExpiresAtMs - activeStartedAtMs); timerFillEl.style.width = `${Math.min(100, (leftMs / totalMs) * 100).toFixed(1)}%`; timerTextEl.textContent = formatRemaining(leftMs); }; const startCountdown = (expiresAtMs) => { activeExpiresAtMs = Number(expiresAtMs || 0); activeStartedAtMs = Date.now(); stopCountdown(); updateCountdown(); countdownTimer = window.setInterval(updateCountdown, 1000); }; const clearActivePairing = () => { stopPolling(); stopCountdown(); activePairingId = ''; activeExpiresAtMs = 0; }; const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); const addingProfile = isAddingProfileLogin(); if (!addingProfile) { await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); } await clearStoredMessages(session.login).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(consumeAuthReturnPage('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, }; const addingProfile = isAddingProfileLogin(); if (!addingProfile) { await terminateCurrentSession({ closeServerSession: true, notifySessionReset: false }); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); } await clearStoredMessages(login).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(consumeAuthReturnPage('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(); showStage('approving', 'Вход подтверждён. Подключаем устройство…'); 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; showStage('retry', 'Вход отклонён на другом устройстве', { error: true, action: 'Получить новый код' }); return; } if (stateValue === 'canceled') { clearActivePairing(); startBtn.disabled = false; showStage('retry', 'Заявка отменена', { error: true, action: 'Получить новый код' }); return; } if (stateValue === 'expired') { clearActivePairing(); startBtn.disabled = false; showStage('retry', 'Время ожидания истекло', { error: true, action: 'Получить новый код' }); return; } schedulePoll(); } catch (error) { clearActivePairing(); startBtn.disabled = false; const message = toUserMessage(error, 'Не удалось проверить, подтверждён ли вход.'); setAuthError(message); showStage('retry', message, { error: true, action: 'Получить новый код' }); } }, 2200); }; startBtn.addEventListener('click', async () => { const login = String(loginInput.value || '').trim(); const usePassword = !!usePasswordInput.checked; const password = String(passwordInput.value || ''); if (!login) { navigate('login-view'); return; } if (usePassword && !password) { showStage('password', 'Введите пароль подключения', { error: true, action: 'Получить код' }); passwordInput.focus(); return; } startBtn.disabled = true; setAuthBusy(true); setAuthError(''); setAuthInfo(''); clearActivePairing(); showStage('creating', 'Создаём код для входа…'); try { await authService.reconnect(state.entrySettings.shineServer); const resolved = await authService.resolveLoginForAuth(login); if (String(resolved?.resolution || '').toUpperCase() !== 'LOCAL') { state.loginDraft.login = String(resolved?.login || login).trim(); navigate('login-view'); return; } 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 || ''); shortCodeEl.setAttribute('aria-label', `Код для входа: ${String(payload?.shortCode || '').split('').join(' ')}`); const online = !!payload?.trustedSessionOnline; onlineEl.textContent = online ? 'Устройство в сети' : 'Устройство не в сети – заявка подождёт'; onlineEl.classList.toggle('is-online', online); showStage('code'); startCountdown(payload?.expiresAtMs); state.loginDraft.login = login; schedulePoll(); } catch (error) { startBtn.disabled = false; if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) { usePasswordInput.checked = true; showStage('password', 'Для входа по коду на другом устройстве задан пароль подключения.', { action: 'Получить код' }); window.setTimeout(() => passwordInput.focus(), 0); } else if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID') { showStage('password', 'Неверный пароль подключения', { error: true, action: 'Получить код' }); window.setTimeout(() => passwordInput.focus(), 0); } else { const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.'); setAuthError(message); showStage('retry', message, { error: true, action: 'Повторить' }); } } finally { setAuthBusy(false); } }); const cancelActivePairingAndBack = async () => { const pairingId = activePairingId; const requesterSessionKey = requesterMaterial?.sessionKey; isDisposed = true; stopPolling(); stopCountdown(); if (pairingId && requesterSessionKey) { try { await authService.cancelTrustedDeviceLogin(pairingId, requesterSessionKey); } catch { // Навигацию назад не блокируем из-за ошибки отмены уже созданной заявки. } } navigate('login-view'); }; screen.cleanup = () => { isDisposed = true; stopPolling(); stopCountdown(); if (activePairingId && requesterMaterial?.sessionKey) { void authService.cancelTrustedDeviceLogin(activePairingId, requesterMaterial.sessionKey).catch(() => {}); } }; passwordInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') startBtn.click(); }); screen.append( createTopBar({ title: 'Вход с другого устройства', back: { label: '←', onClick: () => { void cancelActivePairingAndBack(); } }, }), who, codeBlock, notice, passwordWrap, startBtn, ); if (!String(loginInput.value || '').trim()) { window.setTimeout(() => navigate('login-view'), 0); } else { // После проверки логина на предыдущем экране код создаётся сразу. window.setTimeout(() => startBtn.click(), 0); } return screen; }