UI: нормальное закрытие сессий и сортировка устройств
This commit is contained in:
parent
41d199e24a
commit
5c155ef503
@ -0,0 +1,22 @@
|
|||||||
|
# Закрытие сессий и сортировка устройств
|
||||||
|
|
||||||
|
- краткое описание фичи:
|
||||||
|
- добровольный выход и переключение устройства/аккаунта теперь сначала пытаются закрыть текущую серверную сессию, а затем очищают локальные данные;
|
||||||
|
- на экране `Устройства` сессии сортируются так, чтобы онлайн-сессии шли раньше оффлайн;
|
||||||
|
- статус онлайн-сессии выделяется зелёным.
|
||||||
|
|
||||||
|
- что именно проверять:
|
||||||
|
- в `Настройки` нажать выход из текущей сессии и убедиться, что запись исчезает из списка сессий после повторного входа;
|
||||||
|
- в `Устройства` нажать `Завершить текущую сессию` и убедиться, что локальные данные очищены, а серверная сессия удалена;
|
||||||
|
- выполнить вход/переключение через `Подключить устройство` или QR и убедиться, что старая сессия не остаётся висеть на сервере;
|
||||||
|
- открыть `Устройства` при наличии нескольких сессий и убедиться, что сначала показаны `Online now`, затем `Offline`;
|
||||||
|
- проверить, что строки со статусом `Online now` визуально выделены зелёным.
|
||||||
|
|
||||||
|
- ожидаемый результат:
|
||||||
|
- при добровольном завершении сессии серверная запись удаляется;
|
||||||
|
- при локальном переключении на другой аккаунт старая текущая сессия не остаётся в `active_sessions`;
|
||||||
|
- порядок сессий в UI соответствует онлайн-статусу сервера;
|
||||||
|
- зелёный статус виден и не ломает верстку на экране `Устройства`.
|
||||||
|
|
||||||
|
- статус:
|
||||||
|
- pending
|
||||||
@ -1,2 +1,2 @@
|
|||||||
client.version=1.2.203
|
client.version=1.2.204
|
||||||
server.version=1.2.192
|
server.version=1.2.193
|
||||||
|
|||||||
@ -55,11 +55,12 @@ export function render({ navigate, route }) {
|
|||||||
|
|
||||||
const details = document.createElement('div');
|
const details = document.createElement('div');
|
||||||
details.className = 'card stack';
|
details.className = 'card stack';
|
||||||
|
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
|
||||||
details.innerHTML = `
|
details.innerHTML = `
|
||||||
<div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div>
|
<div><p class="meta-muted">sessionId</p><p>${session.sessionId}</p></div>
|
||||||
<div><p class="meta-muted">sessionType</p><p>${formatSessionType(session.sessionType)}</p></div>
|
<div><p class="meta-muted">sessionType</p><p>${formatSessionType(session.sessionType)}</p></div>
|
||||||
<div><p class="meta-muted">clientPlatform</p><p>${session.clientPlatform || '-'}</p></div>
|
<div><p class="meta-muted">clientPlatform</p><p>${session.clientPlatform || '-'}</p></div>
|
||||||
<div><p class="meta-muted">onlineOnThisServer</p><p>${formatOnlineStatus(!!session.onlineOnThisServer)}</p></div>
|
<div><p class="meta-muted">onlineOnThisServer</p><p class="${onlineStatusClass}">${formatOnlineStatus(!!session.onlineOnThisServer)}</p></div>
|
||||||
<div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient || '-'}</p></div>
|
<div><p class="meta-muted">clientInfoFromClient</p><p>${session.clientInfoFromClient || '-'}</p></div>
|
||||||
<div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div>
|
<div><p class="meta-muted">clientInfoFromRequest</p><p>${session.clientInfoFromRequest || '-'}</p></div>
|
||||||
<div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div>
|
<div><p class="meta-muted">geo</p><p>${session.geo || 'unknown'}</p></div>
|
||||||
@ -79,6 +80,14 @@ export function render({ navigate, route }) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
if (isCurrentSession) {
|
||||||
|
await terminateCurrentSession({
|
||||||
|
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||||
|
closeServerSession: true,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
await authService.closeSession(session.sessionId);
|
await authService.closeSession(session.sessionId);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isSessionInvalidError(error)) {
|
if (!isSessionInvalidError(error)) {
|
||||||
@ -86,13 +95,12 @@ export function render({ navigate, route }) {
|
|||||||
window.alert(error.message);
|
window.alert(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}
|
if (isCurrentSession) {
|
||||||
|
await terminateCurrentSession({
|
||||||
if (isCurrentSession) {
|
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||||
await terminateCurrentSession({
|
});
|
||||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
return;
|
||||||
});
|
}
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { renderHeader } from '../components/header.js';
|
import { renderHeader } from '../components/header.js';
|
||||||
import {
|
import {
|
||||||
authService,
|
|
||||||
isSessionInvalidError,
|
isSessionInvalidError,
|
||||||
refreshSessions,
|
refreshSessions,
|
||||||
setAuthError,
|
setAuthError,
|
||||||
@ -32,6 +31,15 @@ function formatOnlineStatus(onlineOnThisServer) {
|
|||||||
return onlineOnThisServer ? 'Online now' : 'Offline';
|
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 }) {
|
export function render({ navigate }) {
|
||||||
const screen = document.createElement('section');
|
const screen = document.createElement('section');
|
||||||
screen.className = 'stack';
|
screen.className = 'stack';
|
||||||
@ -59,9 +67,9 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const buildList = () => {
|
const buildList = () => {
|
||||||
sessionsBlock.innerHTML = '';
|
sessionsBlock.innerHTML = '';
|
||||||
const sessions = state.sessions || [];
|
const sessions = sortSessionsByOnline(state.sessions || []);
|
||||||
const current = sessions.find((s) => s.sessionId === state.session.sessionId) || sessions[0];
|
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 createSessionItem = (session, isCurrent) => {
|
||||||
const item = document.createElement('button');
|
const item = document.createElement('button');
|
||||||
@ -70,12 +78,13 @@ export function render({ navigate }) {
|
|||||||
const sessionTypeText = formatSessionType(session.sessionType);
|
const sessionTypeText = formatSessionType(session.sessionType);
|
||||||
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
const sessionPlatformText = session.clientPlatform ? ` · ${session.clientPlatform}` : '';
|
||||||
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
|
const onlineStatusText = formatOnlineStatus(!!session.onlineOnThisServer);
|
||||||
|
const onlineStatusClass = session.onlineOnThisServer ? 'session-status session-status--online' : 'session-status';
|
||||||
item.innerHTML = `
|
item.innerHTML = `
|
||||||
<div class="row" style="align-items:flex-start;">
|
<div class="row" style="align-items:flex-start;">
|
||||||
<div class="stack" style="gap:4px; text-align:left;">
|
<div class="stack" style="gap:4px; text-align:left;">
|
||||||
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
<strong>${session.clientInfoFromClient || 'unknown client'}</strong>
|
||||||
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
|
<span class="meta-muted"><strong>Type:</strong> ${sessionTypeText}${sessionPlatformText}</span>
|
||||||
<span class="meta-muted"><strong>Status:</strong> ${onlineStatusText}</span>
|
<span class="${onlineStatusClass}"><strong>Status:</strong> ${onlineStatusText}</span>
|
||||||
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
<span class="meta-muted">${session.geo || 'unknown'}</span>
|
||||||
</div>
|
</div>
|
||||||
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
<span class="meta-muted">${formatSessionTime(session.lastAuthenticatedAtMs || Date.now())}</span>
|
||||||
@ -108,29 +117,31 @@ export function render({ navigate }) {
|
|||||||
if (!confirmed) return;
|
if (!confirmed) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await authService.closeSession(state.session.sessionId);
|
await terminateCurrentSession({
|
||||||
|
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||||
|
closeServerSession: true,
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (!isSessionInvalidError(error)) {
|
if (!isSessionInvalidError(error)) {
|
||||||
setAuthError(error.message);
|
setAuthError(error.message);
|
||||||
window.alert(error.message);
|
window.alert(error.message);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await terminateCurrentSession({
|
||||||
|
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
await terminateCurrentSession({
|
|
||||||
infoMessage: 'Текущая сессия завершена, данные на устройстве очищены.',
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
currentMenu.append(endCurrentSessionBtn);
|
currentMenu.append(endCurrentSessionBtn);
|
||||||
|
|
||||||
const othersMenu = document.createElement('div');
|
const othersMenu = document.createElement('div');
|
||||||
othersMenu.className = 'stack';
|
othersMenu.className = 'stack';
|
||||||
othersMenu.innerHTML = '<p class="meta-muted">Остальные активные сеансы</p>';
|
othersMenu.innerHTML = '<p class="meta-muted">Остальные сеансы</p>';
|
||||||
|
|
||||||
if (others.length === 0) {
|
if (others.length === 0) {
|
||||||
const empty = document.createElement('p');
|
const empty = document.createElement('p');
|
||||||
empty.className = 'meta-muted';
|
empty.className = 'meta-muted';
|
||||||
empty.textContent = 'Других активных сеансов нет.';
|
empty.textContent = 'Других сеансов нет.';
|
||||||
othersMenu.append(empty);
|
othersMenu.append(empty);
|
||||||
} else {
|
} else {
|
||||||
others.forEach((session) => {
|
others.forEach((session) => {
|
||||||
|
|||||||
@ -205,10 +205,10 @@ export function render({ navigate }) {
|
|||||||
try {
|
try {
|
||||||
await authService.reconnect(state.entrySettings.shineServer);
|
await authService.reconnect(state.entrySettings.shineServer);
|
||||||
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
const session = await authService.createSessionFromImportedSecrets(scannedTransfer.login, scannedTransfer.keys);
|
||||||
|
await terminateCurrentSession({ closeServerSession: true });
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
clearBrowserClientData();
|
clearBrowserClientData();
|
||||||
await clearClientAuthData().catch(() => {});
|
await clearClientAuthData().catch(() => {});
|
||||||
await terminateCurrentSession();
|
|
||||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
await saveEncryptedUserSecrets(session.login, session.storagePwd, scannedTransfer.keys);
|
||||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||||
|
|||||||
@ -174,10 +174,10 @@ export function render({ navigate }) {
|
|||||||
|
|
||||||
const finalizeAuthorizedLogin = async (keys, login) => {
|
const finalizeAuthorizedLogin = async (keys, login) => {
|
||||||
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
const session = await authService.createSessionFromImportedSecrets(login, keys);
|
||||||
|
await terminateCurrentSession({ closeServerSession: true });
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
clearBrowserClientData();
|
clearBrowserClientData();
|
||||||
await clearClientAuthData().catch(() => {});
|
await clearClientAuthData().catch(() => {});
|
||||||
await terminateCurrentSession();
|
|
||||||
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
await saveEncryptedUserSecrets(session.login, session.storagePwd, keys);
|
||||||
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
await authService.persistSessionMaterial(session.login, session.sessionMaterial);
|
||||||
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
const resumed = await authService.resumeSession(session.login, session.sessionId);
|
||||||
@ -194,6 +194,38 @@ export function render({ navigate }) {
|
|||||||
navigate('profile-view');
|
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 = () => {
|
const schedulePoll = () => {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
if (!activePairingId || isDisposed) return;
|
if (!activePairingId || isDisposed) return;
|
||||||
@ -209,7 +241,12 @@ export function render({ navigate }) {
|
|||||||
stopCountdown();
|
stopCountdown();
|
||||||
setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info');
|
setStatus(status, 'Заявка подтверждена. Подключаем устройство...', 'info');
|
||||||
const decoded = await decryptPairingPayloadFromEnvelope(payload?.encryptedPayload, requesterMaterial);
|
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');
|
throw new Error('Получен неподдерживаемый pairing payload');
|
||||||
}
|
}
|
||||||
await finalizeAuthorizedLogin(decoded.keys, decoded.login || loginInput.value);
|
await finalizeAuthorizedLogin(decoded.keys, decoded.login || loginInput.value);
|
||||||
|
|||||||
@ -749,7 +749,27 @@ function resetStateForSignedOut() {
|
|||||||
state.messageReactions = next.messageReactions;
|
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();
|
clearStoredSession();
|
||||||
resetStateForSignedOut();
|
resetStateForSignedOut();
|
||||||
await clearStoredMessages().catch(() => {});
|
await clearStoredMessages().catch(() => {});
|
||||||
@ -768,21 +788,7 @@ export async function terminateCurrentSession({ infoMessage = '' } = {}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
|
export async function closeCurrentSessionAndSignOut({ infoMessage = '' } = {}) {
|
||||||
const currentSessionId = String(state.session.sessionId || '').trim();
|
await terminateCurrentSession({ infoMessage, closeServerSession: true });
|
||||||
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 });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function refreshRegistrationBalance() {
|
export function refreshRegistrationBalance() {
|
||||||
|
|||||||
@ -689,6 +689,15 @@
|
|||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.session-status {
|
||||||
|
color: var(--text-muted);
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.session-status--online {
|
||||||
|
color: #3dbb72;
|
||||||
|
}
|
||||||
|
|
||||||
.unread {
|
.unread {
|
||||||
min-width: 20px;
|
min-width: 20px;
|
||||||
height: 20px;
|
height: 20px;
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user