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 = `
${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;