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 auth-screen auth-screen--other-device';
let pollTimer = 0;
let countdownTimer = 0;
let activePairingId = '';
let requesterMaterial = null;
let activeExpiresAtMs = 0;
let isDisposed = false;
clearAuthMessages();
const panel = document.createElement('section');
panel.className = 'login-panel login-panel--wide stack';
panel.innerHTML = `
`;
panel.querySelector('#login-other-device-back')?.addEventListener('click', () => { void cancelActivePairingAndBack(); });
const formCard = document.createElement('div');
formCard.className = 'card stack login-device-preparation';
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 loginLabelEl = formCard.querySelector('#pair-login-label');
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');
loginInput.value = String(state.loginDraft.login || '').trim();
loginLabelEl.textContent = loginInput.value ? `Вход для @${loginInput.value}` : '';
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';
startBtn.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;
startBtn.style.display = '';
startBtn.textContent = 'Получить новый код';
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, notifySessionReset: false });
await clearStoredMessages(session.login).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, notifySessionReset: false });
await clearStoredMessages(login).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, 'Создаём код для входа…', 'info');
clearActivePairing();
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 || '');
statusHintEl.textContent = 'Откройте на доверенном устройстве: Настройки -> Устройства -> Подключить по коду.';
onlineHintEl.textContent = payload?.trustedSessionOnline
? 'Доверенное устройство сейчас в сети и может сразу принять заявку.'
: 'Доверенное устройство сейчас не в сети. Заявка будет ждать его подключения.';
resultWrap.style.display = '';
startCountdown(payload?.expiresAtMs);
state.loginDraft.login = login;
setStatus(status, 'Код создан. Ожидаем подтверждение на другом устройстве...', 'info');
schedulePoll();
} catch (error) {
startBtn.disabled = false;
if (String(error?.code || '').toUpperCase() === 'PAIRING_PASSWORD_INVALID' && !usePassword) {
usePasswordInput.checked = true;
syncPasswordUi();
modeHintEl.textContent = 'Для этого аккаунта включён дополнительный пароль подключения. Введите его, чтобы получить код.';
setStatus(status, 'Введите дополнительный пароль подключения.', 'info');
window.setTimeout(() => passwordInput.focus(), 0);
} else {
startBtn.style.display = '';
startBtn.textContent = 'Повторить';
const message = toUserMessage(error, 'Не удалось начать вход через другое устройство.');
setAuthError(message);
setStatus(status, message, 'error');
}
} 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(() => {});
}
};
panel.append(formCard, status, resultWrap);
screen.append(panel);
if (!String(loginInput.value || '').trim()) {
window.setTimeout(() => navigate('login-view'), 0);
} else {
// После проверки логина на предыдущем экране код создаётся сразу.
window.setTimeout(() => startBtn.click(), 0);
}
return screen;
}