351 lines
14 KiB
JavaScript
351 lines
14 KiB
JavaScript
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 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 = '0000000';
|
||
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 = `
|
||
<label class="stack">
|
||
<span class="field-label">Введите логин</span>
|
||
<input class="input" id="pair-login" type="text" autocomplete="username" placeholder="" value="" />
|
||
</label>
|
||
<label class="checkbox-row">
|
||
<input type="checkbox" id="pair-use-password" />
|
||
использовать доп. пароль
|
||
</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" id="pair-mode-hint">Получить код для входа можно только если сейчас есть хотя бы одно другое активное устройство этого пользователя в сети.</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 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 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') {
|
||
stopCountdown();
|
||
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') {
|
||
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.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
|
||
? 'Сейчас есть хотя бы одна онлайн доверенная сессия, которая может принять заявку.'
|
||
: 'Сейчас нет онлайн доверенной сессии. Заявка будет ждать, пока пользователь откроет уже подключённое устройство.';
|
||
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.cancelEspPairing(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;
|
||
}
|