// ==UserScript== // @name ether.fi 设备会话管理工具 // @namespace https://www.ether.fi/ // @version 1.0.3 // @description 列出 ether.fi Cash 的所有登录会话,支持选中/全选并注销(登出其它设备)。使用站点自身的内部接口 GET /v2/sessions 与 DELETE /v2/sessions/{id}。 // @author DotRacel // @icon https://www.ether.fi/images/favicon/android-chrome-192x192.png // @icon64 https://www.ether.fi/images/favicon/android-chrome-512x512.png // @homepageURL https://github.com/DotRacel/etherfi-session-manager // @supportURL https://github.com/DotRacel/etherfi-session-manager/issues // @updateURL https://raw.githubusercontent.com/DotRacel/etherfi-session-manager/main/etherfi-session-manager.user.js // @downloadURL https://raw.githubusercontent.com/DotRacel/etherfi-session-manager/main/etherfi-session-manager.user.js // @match https://www.ether.fi/* // @run-at document-idle // @grant none // ==/UserScript== (function () { 'use strict'; // ---- API 常量(与前端一致:baseURL=/app/cash/api,cookie 会话,X-Active-User 头)---- const API_BASE = '/app/cash/api'; const LIST_PATH = '/v2/sessions'; const DEL_PATH = (id) => `/v2/sessions/${encodeURIComponent(id)}`; function activeUser() { try { return localStorage.getItem('active_user') || ''; } catch { return ''; } } async function apiFetch(path, opts = {}) { const headers = Object.assign( { 'Content-Type': 'application/json', 'X-Active-User': activeUser() }, opts.headers || {} ); return fetch(API_BASE + path, { credentials: 'include', ...opts, headers, }); } async function listSessions() { const r = await apiFetch(LIST_PATH, { method: 'GET' }); let body = null; try { body = await r.json(); } catch { /* non-JSON */ } if (!r.ok) { const msg = r.status === 401 ? '未登录 / 会话已失效(401)。请先在本浏览器登录 ether.fi Cash 再试。' : `列出会话失败:HTTP ${r.status}`; throw new Error(msg + (body ? ' — ' + JSON.stringify(body) : '')); } return body; } async function revokeSession(id) { const r = await apiFetch(DEL_PATH(id), { method: 'DELETE' }); let body = null; try { body = await r.clone().json(); } catch { /* 可能 204 无 body */ } return { ok: r.ok, status: r.status, body }; } // ---- 数据解析(返回结构未知,尽量兜底)---- function extractList(data) { if (Array.isArray(data)) return data; if (data && typeof data === 'object') { for (const k of ['sessions', 'data', 'items', 'results', 'list', 'rows']) { if (Array.isArray(data[k])) return data[k]; } if (data.data && Array.isArray(data.data.sessions)) return data.data.sessions; if (data.data && Array.isArray(data.data.items)) return data.data.items; } return []; } function getId(s) { for (const k of ['id', 'sessionId', 'sessionKey', 'sessionKeyId', 'session_id', 'uuid', 'key', '_id']) { if (s && s[k] != null && typeof s[k] !== 'object') return String(s[k]); } return null; } function isCurrent(s) { for (const k of ['isCurrent', 'current', 'isCurrentSession', 'isThisDevice', 'thisDevice', 'self', 'isMe']) { if (s && s[k] === true) return true; } return false; } function pick(s, keys) { for (const k of keys) if (s && s[k] != null && s[k] !== '') return { key: k, val: s[k] }; return null; } function fmtVal(v) { if (v == null) return ''; if (typeof v === 'object') return JSON.stringify(v); const str = String(v); // 看起来像时间戳/日期就格式化 if (/^\d{10}$/.test(str)) return new Date(Number(str) * 1000).toLocaleString(); if (/^\d{13}$/.test(str)) return new Date(Number(str)).toLocaleString(); if (/^\d{4}-\d{2}-\d{2}T/.test(str)) { const d = new Date(str); if (!isNaN(d)) return d.toLocaleString(); } return str; } function summarize(s) { const rows = []; const add = (label, hit) => { if (hit) rows.push([label, fmtVal(hit.val)]); }; add('设备', pick(s, ['deviceName', 'device', 'deviceType', 'deviceModel'])); add('系统', pick(s, ['os', 'operatingSystem', 'platform'])); add('浏览器', pick(s, ['browser', 'client', 'userAgent', 'ua'])); add('IP', pick(s, ['ipAddress', 'ip', 'ipAddr', 'remoteIp'])); add('位置', pick(s, ['location', 'city', 'region', 'country', 'geo'])); add('创建于', pick(s, ['createdAt', 'created', 'creationTime', 'loginAt', 'createdTime', 'issuedAt'])); add('最近活动', pick(s, ['lastActiveAt', 'lastActive', 'lastUsedAt', 'lastSeenAt', 'updatedAt', 'lastUsed'])); add('过期于', pick(s, ['expiresAt', 'expiry', 'expireAt', 'expiresAtDate', 'exp'])); return rows; } // ---- UI(Shadow DOM 隔离样式)---- let host, root, panel, listEl, statusEl, countEl, selectAllEl; let sessions = []; const css = ` :host { all: initial; } * { box-sizing: border-box; font-family: -apple-system, Segoe UI, Roboto, Helvetica, Arial, sans-serif; } .launcher { position: fixed; right: 18px; bottom: 18px; z-index: 2147483647; background: #111827; color: #fff; border: 1px solid #374151; border-radius: 999px; padding: 10px 16px; font-size: 13px; font-weight: 600; cursor: pointer; box-shadow: 0 6px 20px rgba(0,0,0,.35); } .launcher:hover { background: #1f2937; } .overlay { position: fixed; inset: 0; z-index: 2147483647; background: rgba(0,0,0,.5); display: flex; align-items: center; justify-content: center; } .panel { width: min(720px, 94vw); max-height: 86vh; display: flex; flex-direction: column; background: #0b0f17; color: #e5e7eb; border: 1px solid #1f2937; border-radius: 14px; box-shadow: 0 20px 60px rgba(0,0,0,.6); overflow: hidden; } .head { display: flex; align-items: center; gap: 10px; padding: 14px 16px; border-bottom: 1px solid #1f2937; cursor: move; user-select: none; } .head h1 { font-size: 15px; margin: 0; flex: 1; font-weight: 700; } .head button { background: #1f2937; color: #e5e7eb; border: 1px solid #374151; border-radius: 8px; padding: 6px 10px; font-size: 12px; cursor: pointer; } .head button:hover { background: #374151; } .toolbar { display: flex; align-items: center; gap: 12px; padding: 10px 16px; border-bottom: 1px solid #1f2937; font-size: 13px; } .toolbar label { display: flex; align-items: center; gap: 6px; cursor: pointer; } .count { color: #9ca3af; } .body { overflow: auto; padding: 8px 12px; } .card { border: 1px solid #1f2937; border-radius: 10px; padding: 10px 12px; margin: 8px 0; display: flex; gap: 12px; align-items: flex-start; background: #0f1420; } .card.cur { border-color: #b45309; background: #1a1305; } .card input[type=checkbox] { width: 18px; height: 18px; margin-top: 2px; cursor: pointer; } .info { flex: 1; min-width: 0; } .idline { font-family: ui-monospace, Menlo, monospace; font-size: 12px; color: #93c5fd; word-break: break-all; } .badge { display: inline-block; font-size: 10px; font-weight: 700; padding: 1px 7px; border-radius: 999px; background: #b45309; color: #fff; margin-left: 8px; vertical-align: middle; } .kv { display: grid; grid-template-columns: 72px 1fr; gap: 2px 10px; margin-top: 6px; font-size: 12px; color: #cbd5e1; } .kv .k { color: #6b7280; } .rowbtns { display: flex; flex-direction: column; gap: 6px; } .rowbtns button { background: #7f1d1d; color: #fecaca; border: 1px solid #991b1b; border-radius: 8px; padding: 5px 10px; font-size: 12px; cursor: pointer; white-space: nowrap; } .rowbtns button:hover { background: #991b1b; color: #fff; } .rowstatus { font-size: 11px; margin-top: 4px; } .ok { color: #34d399; } .err { color: #f87171; } .muted { color: #9ca3af; } .raw { margin-top: 6px; } .raw summary { cursor: pointer; color: #6b7280; font-size: 11px; } .raw pre { margin: 6px 0 0; padding: 8px; background: #060911; border: 1px solid #1f2937; border-radius: 8px; font-size: 11px; max-height: 180px; overflow: auto; color: #94a3b8; } .foot { display: flex; align-items: center; gap: 12px; padding: 12px 16px; border-top: 1px solid #1f2937; } .status { flex: 1; font-size: 12px; color: #9ca3af; min-height: 16px; } .danger { background: #dc2626; color: #fff; border: none; border-radius: 10px; padding: 9px 16px; font-size: 13px; font-weight: 700; cursor: pointer; } .danger:disabled { opacity: .5; cursor: not-allowed; } .danger:hover:not(:disabled) { background: #ef4444; } .empty { padding: 30px; text-align: center; color: #9ca3af; font-size: 13px; } a.reload { color: #93c5fd; cursor: pointer; text-decoration: underline; } `; function buildUI() { host = document.createElement('div'); root = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); style.textContent = css; root.appendChild(style); const launcher = document.createElement('button'); launcher.className = 'launcher'; launcher.textContent = '🔒 会话管理'; launcher.onclick = openPanel; root.appendChild(launcher); document.documentElement.appendChild(host); } function openPanel() { const overlay = document.createElement('div'); overlay.className = 'overlay'; overlay.onclick = (e) => { if (e.target === overlay) closePanel(); }; panel = document.createElement('div'); panel.className = 'panel'; panel.innerHTML = `
${escapeHtml(JSON.stringify(raw, null, 2))}${escapeHtml(JSON.stringify(s, null, 2))}