SHA256
Палитра «Сияние» по умолчанию, новые роли цветов. Лента канала: плоские записи, ряд действий с равным шагом иконок, дата у записи. Обсуждение со строкой ввода, личный чат, шторки. Вход и регистрация: стартовый экран (надпись «СИЯНИЕ», сияние мандалы), настройки входа со статусами серверов, статус логина в одну строку, вход с другого устройства (код, таймер, шаги), сохранение ключей с переключателями. Корневые разделы – заголовок по центру; фильтр чатов – переключатель; уведомления – список как в чатах; фото – просмотр на весь экран. Общее: один зазор под шапкой (--page-top-gap), переключатели высотой с поле ввода, второстепенные кнопки по одной роли (в том числе на экранах устройств и серверов), иконки только из ui-icon.js, полоса прокрутки скрыта, среднее тире в текстах интерфейса. Что нужно от сервера и разработчика – docs/UI-Design/UI-v4-for-dev.md. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
397 lines
16 KiB
JavaScript
397 lines
16 KiB
JavaScript
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 = `
|
||
<div class="pairing-code-card">
|
||
<p class="pairing-code-card__label">Код для входа</p>
|
||
<div class="pairing-code" id="pairing-short-code"></div>
|
||
<div class="pairing-timer" aria-live="off">
|
||
<div class="pairing-timer__bar"><span id="pairing-timer-fill"></span></div>
|
||
<span class="pairing-timer__text" id="pairing-timer-text"></span>
|
||
</div>
|
||
<p class="pairing-online" id="pairing-online"></p>
|
||
</div>
|
||
<ol class="pairing-steps">
|
||
<li>Откройте Сияние, где вы уже вошли</li>
|
||
<li>Настройки → Устройства</li>
|
||
<li>«Подключить по коду» – введите код</li>
|
||
</ol>
|
||
<p class="pairing-waiting"><span class="pairing-spinner" aria-hidden="true"></span>Ждём подтверждения…</p>
|
||
`;
|
||
|
||
// Сообщение о ходе дела или об ошибке — вместо кода.
|
||
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 = `
|
||
<span class="field-label">Пароль подключения</span>
|
||
<input class="input" type="password" autocomplete="current-password" placeholder="Задан на другом устройстве" />
|
||
`;
|
||
|
||
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;
|
||
}
|