mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-24 06:58:21 +00:00
Session Manager: compact redesign + responsive modal (no UIWindow) (#3400)
* redesign: compact, scannable Dashboard Session Manager The session list rendered each entry as a bulky card with a 5-row Created/Last active/Expires/IP/Client key-value table and a large filled-red Revoke button, so only a couple of sessions fit on screen and destructive actions dominated the layout. Reworked it into an icon-led, compact list: - One row per session: a device/kind icon tile (laptop / phone / globe by OS, bolt for workers, key for API tokens, the real app icon for app sessions), the title + badges, and two tight meta lines (client · IP, then a fainter last-active · created · expires with absolute-time tooltips). - Destructive actions are calm: per-row Revoke and the rename pencil are quiet and reveal on hover (always shown on touch); "Revoke all other sessions" is a subtle ghost button instead of a filled block competing with search. - Current session gets a green accent, tint, and pill, and has no revoke button. - Toolbar search gains an inline icon + focus ring; added a session count line; restyled kind badges; nested child sessions get a proper tree line. Styling uses the existing dashboard design tokens. All behavior is preserved: search filtering, parent/child tree with expand/collapse, inline rename (optimistic + rollback), per-session revoke (access-token vs session routing), revoke-all, current-session guard, and focus/interval refresh. Adds ui_session_count_one / ui_session_count_other to en.js (other locales fall back to en). * refactor: render Session Manager as a responsive modal, not a UIWindow Replaces the draggable UIWindow shell with a self-contained DOM modal: - Backdrop + centered card on desktop (max 680px / 90vh); full-screen sheet on phones (<=640px). Fade/scale in, respects prefers-reduced-motion. - Adds a header bar (title + close), backdrop-click and Escape to close, and a scroll-contained body. - Confirmations no longer use UIAlert (itself a UIWindow). Revoke, revoke-all, and the rename-error path now use in-modal confirm/alert sheets, so there is no cross-window z-index juggling — important because this can be opened from a stay_on_top window (UIWindowCopyToken). Drops the UIWindow and UIAlert imports entirely. All behavior is preserved: search, parent/child tree + expand/collapse, inline rename (optimistic + rollback), per-session revoke (access-token vs session routing), revoke-all, current-session guard, and the focus/60s-interval refresh (cleaned up on close).
This commit is contained in:
@@ -16,8 +16,11 @@
|
||||
* You should have received a copy of the GNU Affero General Public License
|
||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
*/
|
||||
import UIAlert from './UIAlert.js';
|
||||
import UIWindow from './UIWindow.js';
|
||||
|
||||
// Renders the Session Manager as a self-contained, responsive modal (a plain
|
||||
// DOM overlay) rather than a draggable UIWindow. Confirmations are shown as
|
||||
// in-modal sheets instead of UIAlert windows, so nothing here depends on the
|
||||
// window system and there is no cross-window z-index juggling.
|
||||
|
||||
// Hand-rolled UA → {browser, os} extractor. Covers Chrome/Edge/Firefox/
|
||||
// Safari/Opera + Windows/macOS/iOS/Android/Linux. The backend already
|
||||
@@ -51,28 +54,216 @@ const formatBrowserOs = ({ browser, os }) => {
|
||||
return browser || os || null;
|
||||
};
|
||||
|
||||
// Inline line-icons (stroke, currentColor) so the list is scannable at a
|
||||
// glance without pulling an icon font into the bundle.
|
||||
const ICONS = {
|
||||
laptop: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="12" rx="2"/><line x1="2" y1="20" x2="22" y2="20"/></svg>',
|
||||
phone: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="6" y="2" width="12" height="20" rx="2.5"/><line x1="10" y1="18.5" x2="14" y2="18.5"/></svg>',
|
||||
globe: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><circle cx="12" cy="12" r="9"/><line x1="3" y1="12" x2="21" y2="12"/><path d="M12 3a15 15 0 0 1 0 18 15 15 0 0 1 0-18z"/></svg>',
|
||||
worker: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"><path d="M13 2 4 14h7l-1 8 9-12h-7l1-8z"/></svg>',
|
||||
app: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"><rect x="3" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="3" width="7" height="7" rx="1.5"/><rect x="14" y="14" width="7" height="7" rx="1.5"/><rect x="3" y="14" width="7" height="7" rx="1.5"/></svg>',
|
||||
key: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><line x1="10.8" y1="12.2" x2="21" y2="2"/><line x1="16" y1="6" x2="19" y2="9"/><line x1="14" y1="8" x2="17" y2="11"/></svg>',
|
||||
pencil: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4 12.5-12.5z"/></svg>',
|
||||
trash: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>',
|
||||
chevron: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="6 9 12 15 18 9"/></svg>',
|
||||
search: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="11" cy="11" r="8"/><line x1="21" y1="21" x2="16.65" y2="16.65"/></svg>',
|
||||
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>',
|
||||
};
|
||||
|
||||
// Pick a device/kind glyph for a session's icon tile.
|
||||
const deviceIconSvg = (session) => {
|
||||
if ( session.kind === 'worker' ) return ICONS.worker;
|
||||
if ( session.kind === 'access_token' ) return ICONS.key;
|
||||
if ( session.kind === 'app' ) return ICONS.app;
|
||||
const { os } = parseUserAgent(session.last_user_agent);
|
||||
if ( os === 'iOS' || os === 'Android' ) return ICONS.phone;
|
||||
if ( os ) return ICONS.laptop;
|
||||
return ICONS.globe;
|
||||
};
|
||||
|
||||
// Short, human label for the kind pill (falls back to the raw kind).
|
||||
const kindBadgeLabel = (kind) => {
|
||||
switch ( kind ) {
|
||||
case 'worker': return i18n('ui_session_kind_worker') || 'Worker';
|
||||
case 'app': return i18n('ui_session_kind_app') || 'App';
|
||||
case 'access_token': return i18n('ui_session_kind_access_token') || 'API token';
|
||||
default: return kind;
|
||||
}
|
||||
};
|
||||
|
||||
const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
options = options ?? {};
|
||||
|
||||
const services = globalThis.services;
|
||||
|
||||
const w = await UIWindow({
|
||||
title: i18n('ui_manage_sessions'),
|
||||
icon: null,
|
||||
uid: null,
|
||||
is_dir: false,
|
||||
message: 'message',
|
||||
is_droppable: false,
|
||||
has_head: true,
|
||||
selectable_body: false,
|
||||
draggable_body: true,
|
||||
allow_context_menu: false,
|
||||
window_class: 'window-session-manager',
|
||||
dominant: true,
|
||||
body_content: '',
|
||||
...options.window_options,
|
||||
// =====================================================================
|
||||
// Responsive modal shell
|
||||
// =====================================================================
|
||||
const backdrop = document.createElement('div');
|
||||
backdrop.className = 'sessions-modal-backdrop';
|
||||
|
||||
const modal = document.createElement('div');
|
||||
modal.className = 'sessions-modal';
|
||||
modal.setAttribute('role', 'dialog');
|
||||
modal.setAttribute('aria-modal', 'true');
|
||||
modal.setAttribute('aria-label', i18n('ui_manage_sessions'));
|
||||
backdrop.appendChild(modal);
|
||||
|
||||
const el_head = document.createElement('div');
|
||||
el_head.className = 'sessions-modal-head';
|
||||
const el_title_head = document.createElement('h2');
|
||||
el_title_head.className = 'sessions-modal-title';
|
||||
el_title_head.textContent = i18n('ui_manage_sessions');
|
||||
el_head.appendChild(el_title_head);
|
||||
const el_close = document.createElement('button');
|
||||
el_close.type = 'button';
|
||||
el_close.className = 'sessions-modal-close';
|
||||
el_close.setAttribute('aria-label', i18n('close'));
|
||||
el_close.innerHTML = ICONS.close;
|
||||
el_head.appendChild(el_close);
|
||||
modal.appendChild(el_head);
|
||||
|
||||
// Content container (plays the role the window-body used to).
|
||||
const w_body = document.createElement('div');
|
||||
w_body.className = 'session-manager-list';
|
||||
modal.appendChild(w_body);
|
||||
|
||||
document.body.appendChild(backdrop);
|
||||
// Next frame so the open transition runs.
|
||||
requestAnimationFrame(() => backdrop.classList.add('open'));
|
||||
|
||||
// Refresh handles — assigned once the list wiring below is in place,
|
||||
// referenced by close(). Declared here so close() can see them.
|
||||
let interval = null;
|
||||
let onFocus = null;
|
||||
let closed = false;
|
||||
|
||||
const close = () => {
|
||||
if ( closed ) return;
|
||||
closed = true;
|
||||
if ( interval ) clearInterval(interval);
|
||||
if ( onFocus ) window.removeEventListener('focus', onFocus);
|
||||
document.removeEventListener('keydown', onKeydown);
|
||||
backdrop.classList.remove('open');
|
||||
// Remove after the fade-out; guard against a missing transitionend.
|
||||
setTimeout(() => backdrop.remove(), 200);
|
||||
};
|
||||
|
||||
// Escape: cancel an open confirm sheet if there is one, otherwise close
|
||||
// the whole modal.
|
||||
const onKeydown = (e) => {
|
||||
if ( e.key !== 'Escape' ) return;
|
||||
const sheet = modal.querySelector('.sessions-modal-sheet');
|
||||
if ( sheet && typeof sheet._cancel === 'function' ) {
|
||||
sheet._cancel();
|
||||
return;
|
||||
}
|
||||
close();
|
||||
};
|
||||
document.addEventListener('keydown', onKeydown);
|
||||
|
||||
el_close.addEventListener('click', close);
|
||||
backdrop.addEventListener('mousedown', (e) => {
|
||||
if ( e.target !== backdrop ) return;
|
||||
// Don't close underneath an open confirm sheet.
|
||||
if ( modal.querySelector('.sessions-modal-sheet') ) return;
|
||||
close();
|
||||
});
|
||||
|
||||
// =====================================================================
|
||||
// In-modal confirm / alert sheets (replace UIAlert)
|
||||
// =====================================================================
|
||||
const confirmDialog = ({ message, confirmLabel, danger = false }) => {
|
||||
return new Promise((resolve) => {
|
||||
const sheet = document.createElement('div');
|
||||
sheet.className = 'sessions-modal-sheet';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'sessions-modal-sheet-card';
|
||||
|
||||
const msg = document.createElement('p');
|
||||
msg.className = 'sessions-modal-sheet-msg';
|
||||
msg.textContent = message;
|
||||
card.appendChild(msg);
|
||||
|
||||
const btns = document.createElement('div');
|
||||
btns.className = 'sessions-modal-sheet-btns';
|
||||
|
||||
const cancelBtn = document.createElement('button');
|
||||
cancelBtn.type = 'button';
|
||||
cancelBtn.className = 'sessions-modal-sheet-btn';
|
||||
cancelBtn.textContent = i18n('cancel');
|
||||
|
||||
const confirmBtn = document.createElement('button');
|
||||
confirmBtn.type = 'button';
|
||||
confirmBtn.className =
|
||||
`sessions-modal-sheet-btn ${danger ? 'sessions-modal-sheet-btn-danger' : 'sessions-modal-sheet-btn-primary'}`;
|
||||
confirmBtn.textContent = confirmLabel;
|
||||
|
||||
btns.appendChild(cancelBtn);
|
||||
btns.appendChild(confirmBtn);
|
||||
card.appendChild(btns);
|
||||
sheet.appendChild(card);
|
||||
|
||||
const done = (val) => {
|
||||
sheet.remove();
|
||||
resolve(val);
|
||||
};
|
||||
// onKeydown (Escape) reaches for this to cancel the sheet.
|
||||
sheet._cancel = () => done(false);
|
||||
|
||||
cancelBtn.addEventListener('click', () => done(false));
|
||||
confirmBtn.addEventListener('click', () => done(true));
|
||||
sheet.addEventListener('mousedown', (e) => {
|
||||
if ( e.target === sheet ) done(false);
|
||||
});
|
||||
|
||||
modal.appendChild(sheet);
|
||||
requestAnimationFrame(() => sheet.classList.add('open'));
|
||||
confirmBtn.focus();
|
||||
});
|
||||
};
|
||||
|
||||
const alertDialog = ({ message }) => {
|
||||
return new Promise((resolve) => {
|
||||
const sheet = document.createElement('div');
|
||||
sheet.className = 'sessions-modal-sheet';
|
||||
|
||||
const card = document.createElement('div');
|
||||
card.className = 'sessions-modal-sheet-card';
|
||||
|
||||
const msg = document.createElement('p');
|
||||
msg.className = 'sessions-modal-sheet-msg';
|
||||
msg.textContent = message;
|
||||
card.appendChild(msg);
|
||||
|
||||
const btns = document.createElement('div');
|
||||
btns.className = 'sessions-modal-sheet-btns';
|
||||
|
||||
const okBtn = document.createElement('button');
|
||||
okBtn.type = 'button';
|
||||
okBtn.className = 'sessions-modal-sheet-btn sessions-modal-sheet-btn-primary';
|
||||
okBtn.textContent = i18n('ok');
|
||||
btns.appendChild(okBtn);
|
||||
card.appendChild(btns);
|
||||
sheet.appendChild(card);
|
||||
|
||||
const done = () => {
|
||||
sheet.remove();
|
||||
resolve();
|
||||
};
|
||||
sheet._cancel = done;
|
||||
okBtn.addEventListener('click', done);
|
||||
sheet.addEventListener('mousedown', (e) => {
|
||||
if ( e.target === sheet ) done();
|
||||
});
|
||||
|
||||
modal.appendChild(sheet);
|
||||
requestAnimationFrame(() => sheet.classList.add('open'));
|
||||
okBtn.focus();
|
||||
});
|
||||
};
|
||||
|
||||
// Backend BIGINT columns store epoch seconds (SessionStore writes
|
||||
// `nowSeconds()`). timeago / Date both take ms — multiply on read.
|
||||
const fmtRelative = (secs) => {
|
||||
@@ -132,16 +323,35 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(q));
|
||||
};
|
||||
|
||||
// Build a compact meta line (client · ip, or active · created · expires)
|
||||
// from a list of { text, title } parts. Returns null when empty so the
|
||||
// caller can skip appending an empty row.
|
||||
const buildMetaLine = (parts, extraClass) => {
|
||||
const items = parts.filter((p) => p && p.text);
|
||||
if ( items.length === 0 ) return null;
|
||||
const line = document.createElement('div');
|
||||
line.classList.add('session-widget-meta');
|
||||
if ( extraClass ) line.classList.add(extraClass);
|
||||
for ( const it of items ) {
|
||||
const span = document.createElement('span');
|
||||
span.classList.add('session-widget-meta-item');
|
||||
span.textContent = it.text;
|
||||
if ( it.title ) span.title = it.title;
|
||||
line.appendChild(span);
|
||||
}
|
||||
return line;
|
||||
};
|
||||
|
||||
const SessionWidget = ({ session, children = [], depth = 0 }) => {
|
||||
const el = document.createElement('div');
|
||||
el.classList.add('session-widget');
|
||||
if ( session.current ) el.classList.add('current-session');
|
||||
if ( depth > 0 ) el.classList.add('session-widget-child');
|
||||
el.dataset.uuid = session.uuid;
|
||||
if ( depth > 0 ) el.style.marginLeft = `${depth * 24}px`;
|
||||
|
||||
const el_header = document.createElement('div');
|
||||
el_header.classList.add('session-widget-header');
|
||||
const el_row = document.createElement('div');
|
||||
el_row.classList.add('session-widget-row');
|
||||
el.appendChild(el_row);
|
||||
|
||||
// Expand/collapse caret for rows with children.
|
||||
let el_children_container = null;
|
||||
@@ -150,8 +360,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
el_caret = document.createElement('button');
|
||||
el_caret.type = 'button';
|
||||
el_caret.classList.add('session-widget-caret');
|
||||
el_caret.textContent = '▾';
|
||||
el_caret.style.marginRight = '4px';
|
||||
el_caret.innerHTML = ICONS.chevron;
|
||||
el_caret.setAttribute(
|
||||
'aria-label',
|
||||
i18n('ui_toggle_session_children') || 'Toggle child sessions',
|
||||
@@ -161,27 +370,37 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
if ( !el_children_container ) return;
|
||||
const collapsed = el_children_container.style.display === 'none';
|
||||
el_children_container.style.display = collapsed ? '' : 'none';
|
||||
el_caret.textContent = collapsed ? '▾' : '▸';
|
||||
el_caret.classList.toggle('collapsed', !collapsed);
|
||||
el_caret.setAttribute('aria-expanded', collapsed ? 'true' : 'false');
|
||||
});
|
||||
el_header.appendChild(el_caret);
|
||||
el_row.appendChild(el_caret);
|
||||
}
|
||||
|
||||
// Icon tile — app icon when available, otherwise a device/kind glyph.
|
||||
const el_icon = document.createElement('div');
|
||||
el_icon.classList.add('session-widget-icon');
|
||||
if ( session.kind === 'app' && session.app?.icon ) {
|
||||
const el_icon = document.createElement('img');
|
||||
el_icon.classList.add('session-widget-app-icon');
|
||||
el_icon.src = session.app.icon;
|
||||
el_icon.alt = '';
|
||||
el_header.appendChild(el_icon);
|
||||
el_icon.classList.add('session-widget-icon-img');
|
||||
const img = document.createElement('img');
|
||||
img.src = session.app.icon;
|
||||
img.alt = '';
|
||||
el_icon.appendChild(img);
|
||||
} else {
|
||||
el_icon.innerHTML = deviceIconSvg(session);
|
||||
}
|
||||
el_row.appendChild(el_icon);
|
||||
|
||||
// Main column: title line + meta lines.
|
||||
const el_main = document.createElement('div');
|
||||
el_main.classList.add('session-widget-main');
|
||||
|
||||
const el_titleline = document.createElement('div');
|
||||
el_titleline.classList.add('session-widget-titleline');
|
||||
|
||||
// Title + inline rename. Pencil opens an <input>; Enter saves,
|
||||
// Escape cancels. Optimistic update; revert on non-2xx.
|
||||
const el_title_wrap = document.createElement('div');
|
||||
el_title_wrap.classList.add('session-widget-title-wrap');
|
||||
el_title_wrap.style.display = 'inline-flex';
|
||||
el_title_wrap.style.alignItems = 'center';
|
||||
el_title_wrap.style.gap = '4px';
|
||||
|
||||
const el_title = document.createElement('div');
|
||||
el_title.classList.add('session-widget-title');
|
||||
@@ -191,7 +410,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
const el_rename_btn = document.createElement('button');
|
||||
el_rename_btn.type = 'button';
|
||||
el_rename_btn.classList.add('session-widget-rename');
|
||||
el_rename_btn.textContent = '✎';
|
||||
el_rename_btn.innerHTML = ICONS.pencil;
|
||||
el_rename_btn.setAttribute(
|
||||
'aria-label',
|
||||
i18n('ui_rename') || 'Rename session',
|
||||
@@ -251,11 +470,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
// Roll back optimistic update
|
||||
session.label = original || null;
|
||||
el_title.textContent = sessionTitle(session);
|
||||
UIAlert({
|
||||
parent_uuid: $(w).attr('data-element_uuid'),
|
||||
stay_on_top: true,
|
||||
message: e?.toString?.() ?? String(e),
|
||||
});
|
||||
alertDialog({ message: e?.toString?.() ?? String(e) });
|
||||
}
|
||||
};
|
||||
|
||||
@@ -267,7 +482,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
el_input.addEventListener('blur', onBlur);
|
||||
};
|
||||
|
||||
el_header.appendChild(el_title_wrap);
|
||||
el_titleline.appendChild(el_title_wrap);
|
||||
|
||||
const el_badges = document.createElement('div');
|
||||
el_badges.classList.add('session-widget-badges');
|
||||
@@ -280,79 +495,48 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
if ( session.kind && session.kind !== 'web' ) {
|
||||
const b = document.createElement('span');
|
||||
b.classList.add('session-widget-badge', `session-widget-badge-${session.kind}`);
|
||||
b.textContent = session.kind;
|
||||
b.textContent = kindBadgeLabel(session.kind);
|
||||
el_badges.appendChild(b);
|
||||
}
|
||||
el_header.appendChild(el_badges);
|
||||
el.appendChild(el_header);
|
||||
el_titleline.appendChild(el_badges);
|
||||
el_main.appendChild(el_titleline);
|
||||
|
||||
// Metadata rows
|
||||
const el_meta = document.createElement('div');
|
||||
el_meta.classList.add('session-widget-meta');
|
||||
|
||||
const addRow = (key, value, absolute) => {
|
||||
if ( !value ) return;
|
||||
const el_entry = document.createElement('div');
|
||||
el_entry.classList.add('session-widget-meta-entry');
|
||||
|
||||
const el_key = document.createElement('div');
|
||||
el_key.textContent = key;
|
||||
el_key.classList.add('session-widget-meta-key');
|
||||
el_entry.appendChild(el_key);
|
||||
|
||||
const el_value = document.createElement('div');
|
||||
el_value.textContent = value;
|
||||
el_value.classList.add('session-widget-meta-value');
|
||||
if ( absolute ) el_value.title = absolute;
|
||||
el_entry.appendChild(el_value);
|
||||
|
||||
el_meta.appendChild(el_entry);
|
||||
};
|
||||
|
||||
if ( session.kind === 'app' && session.app ) {
|
||||
addRow(i18n('ui_session_app') || 'App', session.app.title || session.app.name || session.app_uid);
|
||||
}
|
||||
addRow(
|
||||
i18n('ui_session_created') || 'Created',
|
||||
fmtRelative(session.created_at),
|
||||
fmtAbsolute(session.created_at),
|
||||
);
|
||||
addRow(
|
||||
i18n('ui_session_last_active') || 'Last active',
|
||||
fmtRelative(session.last_activity),
|
||||
fmtAbsolute(session.last_activity),
|
||||
);
|
||||
if ( session.expires_at ) {
|
||||
addRow(
|
||||
i18n('ui_session_expires') || 'Expires',
|
||||
fmtRelative(session.expires_at),
|
||||
fmtAbsolute(session.expires_at),
|
||||
);
|
||||
}
|
||||
if ( session.last_ip ) {
|
||||
addRow(i18n('ui_session_ip') || 'IP', session.last_ip);
|
||||
}
|
||||
// Primary meta: client / app · IP.
|
||||
const ua = parseUserAgent(session.last_user_agent);
|
||||
const uaLabel = formatBrowserOs(ua);
|
||||
if ( uaLabel ) {
|
||||
const el_entry = document.createElement('div');
|
||||
el_entry.classList.add('session-widget-meta-entry');
|
||||
const el_key = document.createElement('div');
|
||||
el_key.textContent = i18n('ui_session_client') || 'Client';
|
||||
el_key.classList.add('session-widget-meta-key');
|
||||
el_entry.appendChild(el_key);
|
||||
const el_value = document.createElement('div');
|
||||
el_value.textContent = uaLabel;
|
||||
el_value.classList.add('session-widget-meta-value');
|
||||
// Raw UA string surfaced on hover for the rare case where
|
||||
// the heuristic mis-classifies and the user wants to know
|
||||
// what's actually there.
|
||||
el_value.title = session.last_user_agent;
|
||||
el_entry.appendChild(el_value);
|
||||
el_meta.appendChild(el_entry);
|
||||
const primaryParts = [];
|
||||
if ( session.kind === 'app' && session.app ) {
|
||||
primaryParts.push({ text: session.app.title || session.app.name || session.app_uid });
|
||||
} else if ( uaLabel ) {
|
||||
primaryParts.push({ text: uaLabel, title: session.last_user_agent });
|
||||
}
|
||||
if ( session.last_ip ) primaryParts.push({ text: session.last_ip });
|
||||
const el_meta_primary = buildMetaLine(primaryParts);
|
||||
if ( el_meta_primary ) el_main.appendChild(el_meta_primary);
|
||||
|
||||
el.appendChild(el_meta);
|
||||
// Secondary meta: last active · created · expires (with absolute-time
|
||||
// tooltips on hover).
|
||||
const lastActive = fmtRelative(session.last_activity);
|
||||
const created = fmtRelative(session.created_at);
|
||||
const expires = session.expires_at ? fmtRelative(session.expires_at) : null;
|
||||
const secondaryParts = [
|
||||
lastActive && {
|
||||
text: `${i18n('ui_session_last_active') || 'Last active'} ${lastActive}`,
|
||||
title: fmtAbsolute(session.last_activity),
|
||||
},
|
||||
created && {
|
||||
text: `${i18n('ui_session_created') || 'Created'} ${created}`,
|
||||
title: fmtAbsolute(session.created_at),
|
||||
},
|
||||
expires && {
|
||||
text: `${i18n('ui_session_expires') || 'Expires'} ${expires}`,
|
||||
title: fmtAbsolute(session.expires_at),
|
||||
},
|
||||
];
|
||||
const el_meta_secondary = buildMetaLine(secondaryParts, 'session-widget-meta-secondary');
|
||||
if ( el_meta_secondary ) el_main.appendChild(el_meta_secondary);
|
||||
|
||||
el_row.appendChild(el_main);
|
||||
|
||||
// Actions: omit revoke entirely for the current session so the
|
||||
// caller can't self-revoke (backend also rejects this).
|
||||
@@ -361,25 +545,18 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
el_actions.classList.add('session-widget-actions');
|
||||
|
||||
const el_btn_revoke = document.createElement('button');
|
||||
el_btn_revoke.textContent = i18n('ui_revoke');
|
||||
el_btn_revoke.classList.add('button', 'button-danger');
|
||||
el_btn_revoke.type = 'button';
|
||||
el_btn_revoke.classList.add('session-widget-revoke');
|
||||
el_btn_revoke.innerHTML = `${ICONS.trash}<span>${i18n('ui_revoke')}</span>`;
|
||||
el_btn_revoke.title = i18n('ui_revoke');
|
||||
el_btn_revoke.addEventListener('click', async () => {
|
||||
try {
|
||||
const parent_uuid = $(w).attr('data-element_uuid');
|
||||
const alert_resp = await UIAlert({
|
||||
parent_uuid,
|
||||
stay_on_top: true,
|
||||
const ok = await confirmDialog({
|
||||
message: i18n('confirm_session_revoke'),
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('yes'),
|
||||
value: 'yes',
|
||||
type: 'primary',
|
||||
},
|
||||
{ label: i18n('cancel') },
|
||||
],
|
||||
confirmLabel: i18n('ui_revoke'),
|
||||
danger: true,
|
||||
});
|
||||
if ( alert_resp !== 'yes' ) return;
|
||||
if ( ! ok ) return;
|
||||
|
||||
const anti_csrf = await services.get('anti-csrf').token();
|
||||
|
||||
@@ -410,17 +587,13 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
reload_sessions();
|
||||
return;
|
||||
}
|
||||
UIAlert({ parent_uuid, stay_on_top: true, message: await resp.text() });
|
||||
alertDialog({ message: await resp.text() });
|
||||
} catch ( e ) {
|
||||
UIAlert({
|
||||
parent_uuid: $(w).attr('data-element_uuid'),
|
||||
stay_on_top: true,
|
||||
message: e.toString(),
|
||||
});
|
||||
alertDialog({ message: e.toString() });
|
||||
}
|
||||
});
|
||||
el_actions.appendChild(el_btn_revoke);
|
||||
el.appendChild(el_actions);
|
||||
el_row.appendChild(el_actions);
|
||||
}
|
||||
|
||||
// Children container — only rendered when this row has any.
|
||||
@@ -477,6 +650,10 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
// Refreshed by reload_sessions (focus / interval / post-revoke / etc.).
|
||||
let cachedSessions = [];
|
||||
|
||||
// Set by the toolbar below; render() keeps its text in sync. Declared
|
||||
// here so the render closure can see it (assigned before render runs).
|
||||
let el_count = null;
|
||||
|
||||
// Re-render the visible tree from the in-memory cache. Cheap; safe to
|
||||
// call on every search keystroke.
|
||||
const render = () => {
|
||||
@@ -489,6 +666,12 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
depth: 0,
|
||||
}).appendTo(w_body_list);
|
||||
}
|
||||
if ( el_count ) {
|
||||
const n = cachedSessions.length;
|
||||
el_count.textContent = n === 1
|
||||
? i18n('ui_session_count_one', [], false)
|
||||
: i18n('ui_session_count_other', [String(n)], false);
|
||||
}
|
||||
};
|
||||
|
||||
const reload_sessions = async () => {
|
||||
@@ -508,13 +691,20 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
render();
|
||||
};
|
||||
|
||||
const w_body = w.querySelector('.window-body');
|
||||
w_body.classList.add('session-manager-list');
|
||||
|
||||
// Toolbar: search input + "Revoke all other sessions" button.
|
||||
// Toolbar: search input + a de-emphasised "Revoke all other sessions"
|
||||
// ghost button (destructive, so it stays red, but no longer a giant
|
||||
// filled block competing with the search field).
|
||||
const el_toolbar = document.createElement('div');
|
||||
el_toolbar.classList.add('session-manager-toolbar');
|
||||
|
||||
const el_search_wrap = document.createElement('div');
|
||||
el_search_wrap.classList.add('session-manager-search-wrap');
|
||||
|
||||
const el_search_icon = document.createElement('span');
|
||||
el_search_icon.classList.add('session-manager-search-icon');
|
||||
el_search_icon.innerHTML = ICONS.search;
|
||||
el_search_wrap.appendChild(el_search_icon);
|
||||
|
||||
const el_search = document.createElement('input');
|
||||
el_search.type = 'search';
|
||||
el_search.placeholder = i18n('ui_search') || 'Search sessions…';
|
||||
@@ -525,31 +715,22 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
// rather than re-fetching /auth/list-sessions per keystroke.
|
||||
render();
|
||||
});
|
||||
el_toolbar.appendChild(el_search);
|
||||
el_search_wrap.appendChild(el_search);
|
||||
el_toolbar.appendChild(el_search_wrap);
|
||||
|
||||
const el_btn_revoke_all = document.createElement('button');
|
||||
el_btn_revoke_all.textContent =
|
||||
i18n('ui_revoke_all_other_sessions') || 'Revoke all other sessions';
|
||||
el_btn_revoke_all.classList.add('button', 'button-danger');
|
||||
el_btn_revoke_all.type = 'button';
|
||||
el_btn_revoke_all.classList.add('session-manager-revoke-all');
|
||||
el_btn_revoke_all.innerHTML =
|
||||
`${ICONS.trash}<span>${i18n('ui_revoke_all_other_sessions') || 'Revoke all others'}</span>`;
|
||||
el_btn_revoke_all.addEventListener('click', async () => {
|
||||
const parent_uuid = $(w).attr('data-element_uuid');
|
||||
try {
|
||||
const alert_resp = await UIAlert({
|
||||
parent_uuid,
|
||||
stay_on_top: true,
|
||||
message:
|
||||
i18n('confirm_revoke_all_other_sessions') ||
|
||||
'Revoke all other sessions? You will stay signed in here.',
|
||||
buttons: [
|
||||
{
|
||||
label: i18n('yes'),
|
||||
value: 'yes',
|
||||
type: 'primary',
|
||||
},
|
||||
{ label: i18n('cancel') },
|
||||
],
|
||||
const ok = await confirmDialog({
|
||||
message: i18n('confirm_revoke_all_other_sessions'),
|
||||
confirmLabel: i18n('ui_revoke'),
|
||||
danger: true,
|
||||
});
|
||||
if ( alert_resp !== 'yes' ) return;
|
||||
if ( ! ok ) return;
|
||||
|
||||
const anti_csrf = await services.get('anti-csrf').token();
|
||||
const resp = await fetch(
|
||||
@@ -571,19 +752,20 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
reload_sessions();
|
||||
return;
|
||||
}
|
||||
UIAlert({ parent_uuid, stay_on_top: true, message: await resp.text() });
|
||||
alertDialog({ message: await resp.text() });
|
||||
} catch ( e ) {
|
||||
UIAlert({
|
||||
parent_uuid,
|
||||
stay_on_top: true,
|
||||
message: e.toString(),
|
||||
});
|
||||
alertDialog({ message: e.toString() });
|
||||
}
|
||||
});
|
||||
el_toolbar.appendChild(el_btn_revoke_all);
|
||||
|
||||
w_body.appendChild(el_toolbar);
|
||||
|
||||
// Session count line (kept in sync by render()).
|
||||
el_count = document.createElement('div');
|
||||
el_count.classList.add('session-manager-count');
|
||||
w_body.appendChild(el_count);
|
||||
|
||||
const w_body_list = document.createElement('div');
|
||||
w_body_list.classList.add('session-manager-list-body');
|
||||
w_body.appendChild(w_body_list);
|
||||
@@ -593,18 +775,11 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) {
|
||||
// Two-tier refresh:
|
||||
// - focus → re-fetch immediately (cheapest signal that something
|
||||
// in the user's other tabs might have changed sessions).
|
||||
// - 60s fallback interval so a long-lived but unfocused window
|
||||
// - 60s fallback interval so a long-lived but unfocused modal
|
||||
// still eventually sees revocations propagate.
|
||||
// Older code polled every 8s flat — that burned CPU + network
|
||||
// continuously even when the window wasn't visible.
|
||||
const onFocus = () => reload_sessions();
|
||||
onFocus = () => reload_sessions();
|
||||
window.addEventListener('focus', onFocus);
|
||||
const interval = setInterval(reload_sessions, 60_000);
|
||||
|
||||
w.on_close = () => {
|
||||
clearInterval(interval);
|
||||
window.removeEventListener('focus', onFocus);
|
||||
};
|
||||
interval = setInterval(reload_sessions, 60_000);
|
||||
};
|
||||
|
||||
export default UIWindowManageSessions;
|
||||
|
||||
+547
-59
@@ -5057,112 +5057,600 @@ html.dark-mode .usage-table-show-less:hover {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
/* ==========================================================================
|
||||
Session manager — responsive modal (not a UIWindow)
|
||||
========================================================================== */
|
||||
|
||||
.sessions-modal-backdrop,
|
||||
.sessions-modal-backdrop * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.sessions-modal-backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 2000000000;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
background: rgba(15, 23, 42, 0.55);
|
||||
opacity: 0;
|
||||
transition: opacity 0.18s ease;
|
||||
}
|
||||
|
||||
.sessions-modal-backdrop.open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sessions-modal {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(680px, 100%);
|
||||
max-height: min(720px, 90vh);
|
||||
background: var(--dashboard-background, #fff);
|
||||
border: 1px solid var(--dashboard-border, #e0e0e0);
|
||||
border-radius: 14px;
|
||||
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35), 0 2px 8px rgba(0, 0, 0, 0.18);
|
||||
overflow: hidden;
|
||||
opacity: 0;
|
||||
transform: translateY(8px) scale(0.985);
|
||||
transition: opacity 0.18s ease, transform 0.18s ease;
|
||||
}
|
||||
|
||||
.sessions-modal-backdrop.open .sessions-modal {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.sessions-modal-head {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border-bottom: 1px solid var(--dashboard-border, #e0e0e0);
|
||||
}
|
||||
|
||||
.sessions-modal-title {
|
||||
margin: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 650;
|
||||
color: var(--dashboard-text-heading, #333);
|
||||
}
|
||||
|
||||
.sessions-modal-close {
|
||||
flex: 0 0 auto;
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-secondary, #64748b);
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.sessions-modal-close svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.sessions-modal-close:hover {
|
||||
background: var(--dashboard-hover, #e8e8e8);
|
||||
color: var(--dashboard-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
/* In-modal confirm / alert sheet (replaces UIAlert) */
|
||||
.sessions-modal-sheet {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 5;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
background: rgba(15, 23, 42, 0.28);
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s ease;
|
||||
}
|
||||
|
||||
.sessions-modal-sheet.open {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-card {
|
||||
width: min(380px, 100%);
|
||||
background: var(--dashboard-card-background, #fff);
|
||||
border: 1px solid var(--dashboard-border, #e0e0e0);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.22);
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-msg {
|
||||
margin: 0 0 16px;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--dashboard-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-btns {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-btn {
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dashboard-border, #e0e0e0);
|
||||
background: var(--dashboard-card-background, #fff);
|
||||
color: var(--dashboard-text-primary, #1e293b);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.12s, border-color 0.12s, opacity 0.12s;
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-btn:hover {
|
||||
background: var(--dashboard-hover, #e8e8e8);
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-btn-primary {
|
||||
background: var(--dashboard-text-primary, #1e293b);
|
||||
border-color: var(--dashboard-text-primary, #1e293b);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
background: var(--dashboard-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-btn-danger {
|
||||
background: var(--dashboard-danger-text, #dc2626);
|
||||
border-color: var(--dashboard-danger-text, #dc2626);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.sessions-modal-sheet-btn-danger:hover {
|
||||
opacity: 0.9;
|
||||
background: var(--dashboard-danger-text, #dc2626);
|
||||
}
|
||||
|
||||
.session-manager-list,
|
||||
.session-manager-list * {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.session-manager-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
box-sizing: border-box;
|
||||
height: 100% !important;
|
||||
gap: 0;
|
||||
min-height: 0;
|
||||
flex: 1 1 auto;
|
||||
padding: 16px;
|
||||
background: var(--dashboard-background, #fff);
|
||||
}
|
||||
|
||||
.session-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--dashboard-border);
|
||||
border-radius: 4px;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.current-session.session-widget {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.session-widget-uuid {
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
color: #9c185b;
|
||||
}
|
||||
|
||||
.session-widget-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.session-widget-header {
|
||||
/* -- Toolbar: search + de-emphasised revoke-all -- */
|
||||
.session-manager-toolbar {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin-bottom: 4px;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.session-widget-app-icon {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
flex-shrink: 0;
|
||||
.session-manager-search-wrap {
|
||||
position: relative;
|
||||
flex: 1 1 220px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.session-manager-search-icon {
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
display: flex;
|
||||
color: var(--dashboard-text-muted, #94a3b8);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.session-manager-search-icon svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.session-manager-search {
|
||||
width: 100%;
|
||||
height: 38px;
|
||||
padding: 0 12px 0 36px;
|
||||
border: 1px solid var(--dashboard-border, #e0e0e0);
|
||||
border-radius: 9px;
|
||||
background: var(--dashboard-input-background, #f5f7f9);
|
||||
font-size: 14px;
|
||||
color: var(--dashboard-text-primary, #1e293b);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.session-manager-search::placeholder {
|
||||
color: var(--dashboard-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.session-manager-search:focus {
|
||||
border-color: var(--select-color, #4a90d9);
|
||||
background: var(--dashboard-background, #fff);
|
||||
box-shadow: 0 0 0 3px hsla(var(--select-hue), var(--select-saturation), var(--select-lightness), 0.16);
|
||||
}
|
||||
|
||||
.session-manager-revoke-all {
|
||||
flex: 0 0 auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
height: 38px;
|
||||
padding: 0 14px;
|
||||
border: 1px solid var(--dashboard-danger-border, #fecaca);
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-danger-text, #dc2626);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: background 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.session-manager-revoke-all svg {
|
||||
width: 15px;
|
||||
height: 15px;
|
||||
}
|
||||
|
||||
.session-manager-revoke-all:hover {
|
||||
background: var(--dashboard-danger-background, #fef2f2);
|
||||
border-color: var(--dashboard-danger-text, #dc2626);
|
||||
}
|
||||
|
||||
.session-manager-count {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: var(--dashboard-text-muted, #94a3b8);
|
||||
padding: 12px 2px 10px;
|
||||
}
|
||||
|
||||
/* -- Scrollable list body -- */
|
||||
.session-manager-list-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
flex: 1 1 auto;
|
||||
overflow-y: auto;
|
||||
padding-bottom: 8px;
|
||||
}
|
||||
|
||||
/* -- Session row (card) -- */
|
||||
.session-widget {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
border: 1px solid var(--dashboard-border, #e0e0e0);
|
||||
border-radius: 12px;
|
||||
background: var(--dashboard-card-background, #fff);
|
||||
transition: border-color 0.12s, box-shadow 0.12s;
|
||||
}
|
||||
|
||||
.session-widget:hover {
|
||||
border-color: #cfd6dd;
|
||||
box-shadow: 0 1px 5px rgba(15, 23, 42, 0.07);
|
||||
}
|
||||
|
||||
.current-session.session-widget {
|
||||
border-color: var(--dashboard-success-border, #08bf4e);
|
||||
background: var(--dashboard-success-background, #e6ffed);
|
||||
}
|
||||
|
||||
.session-widget-row {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
padding: 12px 14px;
|
||||
}
|
||||
|
||||
.session-widget-caret {
|
||||
flex: 0 0 auto;
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
margin-top: 8px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-muted, #94a3b8);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.session-widget-caret svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
transition: transform 0.15s;
|
||||
}
|
||||
|
||||
.session-widget-caret.collapsed svg {
|
||||
transform: rotate(-90deg);
|
||||
}
|
||||
|
||||
/* Icon tile */
|
||||
.session-widget-icon {
|
||||
flex: 0 0 auto;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 10px;
|
||||
background: var(--dashboard-sidebar-background, #f1f5f9);
|
||||
color: var(--dashboard-text-secondary, #64748b);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.session-widget-icon svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.session-widget-icon-img {
|
||||
background: #fff;
|
||||
border: 1px solid var(--dashboard-border, #e0e0e0);
|
||||
}
|
||||
|
||||
.session-widget-icon-img img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.current-session .session-widget-icon {
|
||||
background: #d3f5df;
|
||||
color: var(--dashboard-success-text, #03933a);
|
||||
}
|
||||
|
||||
/* Main column */
|
||||
.session-widget-main {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.session-widget-titleline {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 8px;
|
||||
}
|
||||
|
||||
.session-widget-title-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.session-widget-title {
|
||||
font-size: 13px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #222;
|
||||
flex-grow: 1;
|
||||
color: var(--dashboard-text-card-title, #1a1a1a);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-widget-rename {
|
||||
flex: 0 0 auto;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
padding: 0;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-text-muted, #94a3b8);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s, color 0.12s;
|
||||
}
|
||||
|
||||
.session-widget-rename svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.session-widget:hover .session-widget-rename {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-widget-rename:hover {
|
||||
background: var(--dashboard-hover, #e8e8e8);
|
||||
color: var(--dashboard-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
.session-widget-rename-input {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
padding: 2px 6px;
|
||||
border: 1px solid var(--select-color, #4a90d9);
|
||||
border-radius: 6px;
|
||||
outline: none;
|
||||
min-width: 140px;
|
||||
max-width: 100%;
|
||||
color: var(--dashboard-text-primary, #1e293b);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.session-widget-badges {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 4px;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.session-widget-badge {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 6px;
|
||||
border-radius: 10px;
|
||||
background-color: #e0e0e0;
|
||||
color: #555;
|
||||
padding: 2px 8px;
|
||||
border-radius: 20px;
|
||||
background: var(--dashboard-hover, #eef1f4);
|
||||
color: var(--dashboard-text-secondary, #64748b);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-widget-badge-current {
|
||||
background-color: #1f7a3a;
|
||||
background: var(--dashboard-success-border, #08bf4e);
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.session-widget-meta-entry {
|
||||
.session-widget-badge-worker {
|
||||
background: #fff3d6;
|
||||
color: #a16207;
|
||||
}
|
||||
|
||||
.session-widget-badge-access_token {
|
||||
background: #e0e7ff;
|
||||
color: #4338ca;
|
||||
}
|
||||
|
||||
.session-widget-badge-app {
|
||||
background: #dbeafe;
|
||||
color: #1d4ed8;
|
||||
}
|
||||
|
||||
/* Meta lines */
|
||||
.session-widget-meta {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
font-size: 12.5px;
|
||||
color: var(--dashboard-text-secondary, #64748b);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.session-widget-meta-key {
|
||||
.session-widget-meta-secondary {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
flex-basis: 40%;
|
||||
flex-shrink: 0;
|
||||
color: var(--dashboard-text-muted, #94a3b8);
|
||||
}
|
||||
|
||||
.session-widget-meta-value {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
flex-grow: 1;
|
||||
.session-widget-meta-item {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.session-widget-meta-item:not(:first-child)::before {
|
||||
content: "·";
|
||||
margin: 0 7px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* Revoke: subtle, hover-revealed danger action */
|
||||
.session-widget-actions {
|
||||
flex: 0 0 auto;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
.session-widget-revoke {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
height: 30px;
|
||||
padding: 0 11px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
color: var(--dashboard-danger-text, #dc2626);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
opacity: 0;
|
||||
transition: opacity 0.12s, background 0.12s, border-color 0.12s;
|
||||
}
|
||||
|
||||
.session-widget-revoke svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.session-widget:hover .session-widget-revoke {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.session-widget-revoke:hover {
|
||||
background: var(--dashboard-danger-background, #fef2f2);
|
||||
border-color: var(--dashboard-danger-border, #fecaca);
|
||||
}
|
||||
|
||||
/* Nested (child) sessions — tree line + lighter surface */
|
||||
.session-widget-children {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
margin: 0 14px 12px 30px;
|
||||
padding-left: 12px;
|
||||
border-left: 2px solid var(--dashboard-border, #e0e0e0);
|
||||
}
|
||||
|
||||
.session-widget-child {
|
||||
background: var(--dashboard-content-background, #fcfcfd);
|
||||
}
|
||||
|
||||
/* Touch devices have no hover — keep the row actions visible */
|
||||
@media (hover: none), (pointer: coarse) {
|
||||
.session-widget-rename,
|
||||
.session-widget-revoke {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* Phone: the modal becomes a full-screen sheet */
|
||||
@media (max-width: 640px) {
|
||||
.sessions-modal-backdrop {
|
||||
padding: 0;
|
||||
}
|
||||
.sessions-modal {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.sessions-modal-backdrop,
|
||||
.sessions-modal,
|
||||
.sessions-modal-sheet {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Extra small devices (phones, less than 576px) */
|
||||
|
||||
@@ -391,6 +391,8 @@ const en = {
|
||||
ui_search: 'Search sessions…',
|
||||
ui_session_app: 'App',
|
||||
ui_session_client: 'Client',
|
||||
ui_session_count_one: '1 active session',
|
||||
ui_session_count_other: '%% active sessions',
|
||||
ui_session_created: 'Created',
|
||||
ui_session_current: 'Current',
|
||||
ui_session_expires: 'Expires',
|
||||
|
||||
Reference in New Issue
Block a user