diff --git a/Dev_Docs/Pending_Features/2026-06-16_1032_закрытие_сессий_и_сортировка_устройств.md b/Dev_Docs/Pending_Features/2026-06-16_1032_закрытие_сессий_и_сортировка_устройств.md new file mode 100644 index 0000000..3224b7d --- /dev/null +++ b/Dev_Docs/Pending_Features/2026-06-16_1032_закрытие_сессий_и_сортировка_устройств.md @@ -0,0 +1,22 @@ +# Закрытие сессий и сортировка устройств + +- краткое описание фичи: + - добровольный выход и переключение устройства/аккаунта теперь сначала пытаются закрыть текущую серверную сессию, а затем очищают локальные данные; + - на экране `Устройства` сессии сортируются так, чтобы онлайн-сессии шли раньше оффлайн; + - статус онлайн-сессии выделяется зелёным. + +- что именно проверять: + - в `Настройки` нажать выход из текущей сессии и убедиться, что запись исчезает из списка сессий после повторного входа; + - в `Устройства` нажать `Завершить текущую сессию` и убедиться, что локальные данные очищены, а серверная сессия удалена; + - выполнить вход/переключение через `Подключить устройство` или QR и убедиться, что старая сессия не остаётся висеть на сервере; + - открыть `Устройства` при наличии нескольких сессий и убедиться, что сначала показаны `Online now`, затем `Offline`; + - проверить, что строки со статусом `Online now` визуально выделены зелёным. + +- ожидаемый результат: + - при добровольном завершении сессии серверная запись удаляется; + - при локальном переключении на другой аккаунт старая текущая сессия не остаётся в `active_sessions`; + - порядок сессий в UI соответствует онлайн-статусу сервера; + - зелёный статус виден и не ломает верстку на экране `Устройства`. + +- статус: + - pending diff --git a/VERSION.properties b/VERSION.properties index 3450509..0d75cbd 100644 --- a/VERSION.properties +++ b/VERSION.properties @@ -1,2 +1,2 @@ -client.version=1.2.203 -server.version=1.2.192 +client.version=1.2.204 +server.version=1.2.193 diff --git a/shine-UI/js/pages/device-session-view.js b/shine-UI/js/pages/device-session-view.js index b7787d6..0cc5bf6 100644 --- a/shine-UI/js/pages/device-session-view.js +++ b/shine-UI/js/pages/device-session-view.js @@ -55,11 +55,12 @@ export function render({ navigate, route }) { const details = document.createElement('div'); details.className = 'card stack'; + const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status'; details.innerHTML = `

sessionId

${session.sessionId}

sessionType

${formatSessionType(session.sessionType)}

clientPlatform

${session.clientPlatform || '-'}

-

onlineOnThisServer

${formatOnlineStatus(!!session.onlineOnThisServer)}

+

onlineOnThisServer

${formatOnlineStatus(!!session.onlineOnThisServer)}

clientInfoFromClient

${session.clientInfoFromClient || '-'}

clientInfoFromRequest

${session.clientInfoFromRequest || '-'}

geo

${session.geo || 'unknown'}

@@ -79,6 +80,14 @@ export function render({ navigate, route }) { if (!confirmed) return; try { + if (isCurrentSession) { + await terminateCurrentSession({ + infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.', + closeServerSession: true, + }); + return; + } + await authService.closeSession(session.sessionId); } catch (error) { if (!isSessionInvalidError(error)) { @@ -86,13 +95,12 @@ export function render({ navigate, route }) { window.alert(error.message); return; } - } - - if (isCurrentSession) { - await terminateCurrentSession({ - infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.', - }); - return; + if (isCurrentSession) { + await terminateCurrentSession({ + infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.', + }); + return; + } } try { diff --git a/shine-UI/js/pages/device-view.js b/shine-UI/js/pages/device-view.js index 381caaf..b6ddbff 100644 --- a/shine-UI/js/pages/device-view.js +++ b/shine-UI/js/pages/device-view.js @@ -1,6 +1,5 @@ import { renderHeader } from '../components/header.js'; import { - authService, isSessionInvalidError, refreshSessions, setAuthError, @@ -32,6 +31,15 @@ function formatOnlineStatus(onlineOnThisServer) { return onlineOnThisServer ? 'Online now' : 'Offline'; } +function sortSessionsByOnline(sessions = []) { + return [...sessions].sort((a, b) => { + const aOnline = a?.onlineOnThisServer ? 1 : 0; + const bOnline = b?.onlineOnThisServer ? 1 : 0; + if (aOnline !== bOnline) return bOnline - aOnline; + return Number(b?.lastAuthenticatedAtMs || 0) - Number(a?.lastAuthenticatedAtMs || 0); + }); +} + export function render({ navigate }) { const screen = document.createElement('section'); screen.className = 'stack'; @@ -59,9 +67,9 @@ export function render({ navigate }) { const buildList = () => { sessionsBlock.innerHTML = ''; - const sessions = state.sessions || []; + const sessions = sortSessionsByOnline(state.sessions || []); const current = sessions.find((s) => s.sessionId === state.session.sessionId) || sessions[0]; - const others = sessions.filter((s) => s.sessionId !== current?.sessionId); + const others = sortSessionsByOnline(sessions.filter((s) => s.sessionId !== current?.sessionId)); const createSessionItem = (session, isCurrent) => { const item = document.createElement('button'); @@ -70,12 +78,13 @@ export function render({ navigate }) { const sessionTypeText = formatSessionType(session.sessionType); const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : ''; const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer); + const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status'; item.innerHTML = `
${session.clientInfoFromClient || 'unknown client'} Type: ${sessionTypeText}${sessionPlatformText} - Status: ${onlineStatusText} + Status: ${onlineStatusText} ${session.geo || 'unknown'}
${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())} @@ -108,29 +117,31 @@ export function render({ navigate }) { if (!confirmed) return; try { - await authService.closeSession(state.session.sessionId); + await terminateCurrentSession({ + infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.', + closeServerSession: true, + }); } catch (error) { if (!isSessionInvalidError(error)) { setAuthError(error.message); window.alert(error.message); return; } + await terminateCurrentSession({ + infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.', + }); } - - await terminateCurrentSession({ - infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.', - }); }); currentMenu.append(endCurrentSessionBtn); const othersMenu = document.createElement('div'); othersMenu.className = 'stack'; - othersMenu.innerHTML = '

Остальные активные сеансы

'; + othersMenu.innerHTML = '

Остальные сеансы

'; if (others.length === 0) { const empty = document.createElement('p'); empty.className = 'meta-muted'; - empty.textContent = 'Других активных сеансов нет.'; + empty.textContent = 'Других сеансов нет.'; othersMenu.append(empty); } else { others.forEach((session) => { diff --git a/shine-UI/js/pages/login-camera-view.js b/shine-UI/js/pages/login-camera-view.js index d48694d..99f40df 100644 --- a/shine-UI/js/pages/login-camera-view.js +++ b/shine-UI/js/pages/login-camera-view.js @@ -205,10 +205,10 @@ export function render({ navigate }) { try { await authService.reconnect(state.entrySettings.shineServer); const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys); + await terminateCurrentSession({ closeServerSession: true }); await clearStoredMessages().catch(() => {}); clearBrowserClientData(); await clearClientAuthData().catch(() => {}); - await terminateCurrentSession(); await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys); await authService.persistSessionMaterial(session.login, session.sessionMaterial); const resumed = await authService.resumeSession(session.login, session.sessionId); diff --git a/shine-UI/js/pages/login-other-device-view.js b/shine-UI/js/pages/login-other-device-view.js index 32c2073..40e94e2 100644 --- a/shine-UI/js/pages/login-other-device-view.js +++ b/shine-UI/js/pages/login-other-device-view.js @@ -174,10 +174,10 @@ export function render({ navigate }) { const finalizeAuthorizedLogin = async (keys, login) => { const session = await authService.createSessionFromImportedSecrets(login, keys); + await terminateCurrentSession({ closeServerSession: true }); 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); @@ -194,6 +194,38 @@ export function render({ navigate }) { 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; @@ -209,7 +241,12 @@ export function render({ navigate }) { stopCountdown(); setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info'); const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial); - if (String(decoded?.type || '') !== 'shine-esp-pairing-transfer') { + 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); diff --git a/shine-UI/js/state.js b/shine-UI/js/state.js index fc2614e..0e41177 100644 --- a/shine-UI/js/state.js +++ b/shine-UI/js/state.js @@ -749,7 +749,27 @@ function resetStateForSignedOut() { state.messageReactions = next.messageReactions; } -export async function terminateCurrentSession({ infoMessage = '' } = {}) { +async function tryCloseCurrentSessionOnServer() { + const currentSessionId = String(state.session.sessionId || '').trim(); + if (!state.session.isAuthorized || !currentSessionId) return; + + try { + await authService.closeSession(currentSessionId); + } catch (error) { + addAppLogEntry({ + level: 'warn', + source: 'session', + message: 'Не удалось завершить текущую сессию на сервере', + details: { sessionId: currentSessionId, error: error?.message || 'unknown' }, + }); + } +} + +export async function terminateCurrentSession({ infoMessage = '', closeServerSession = false } = {}) { + if (closeServerSession) { + await tryCloseCurrentSessionOnServer(); + } + clearStoredSession(); resetStateForSignedOut(); await clearStoredMessages().catch(() => {}); @@ -768,21 +788,7 @@ export async function terminateCurrentSession({ infoMessage = '' } = {}) { } export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) { - const currentSessionId = String(state.session.sessionId || '').trim(); - try { - if (state.session.isAuthorized && currentSessionId) { - await authService.closeSession(currentSessionId); - } - } catch (error) { - addAppLogEntry({ - level: 'warn', - source: 'session', - message: 'Не удалось завершить текущую сессию на сервере', - details: { sessionId: currentSessionId, error: error?.message || 'unknown' }, - }); - } - - await terminateCurrentSession({ infoMessage }); + await terminateCurrentSession({ infoMessage, closeServerSession: true }); } export function refreshRegistrationBalance() { diff --git a/shine-UI/styles/components.css b/shine-UI/styles/components.css index 17519f8..3f01ae8 100644 --- a/shine-UI/styles/components.css +++ b/shine-UI/styles/components.css @@ -689,6 +689,15 @@ font-size: 13px; } +.session-status { + color: var(--text-muted); + font-size: 13px; +} + +.session-status--online { + color: #3dbb72; +} + .unread { min-width: 20px; height: 20px;