diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js
index 91008fa5c..c8b95a9cf 100644
--- a/src/gui/src/UI/UIWindowManageSessions.js
+++ b/src/gui/src/UI/UIWindowManageSessions.js
@@ -16,8 +16,11 @@
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
-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: '',
+ phone: '',
+ globe: '',
+ worker: '',
+ app: '',
+ key: '',
+ pencil: '',
+ trash: '',
+ chevron: '',
+ search: '',
+ close: '',
+};
+
+// 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 ; 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}${i18n('ui_revoke')}`;
+ 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}${i18n('ui_revoke_all_other_sessions') || 'Revoke all others'}`;
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;
diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css
index 426a980b6..d12b7355a 100644
--- a/src/gui/src/css/style.css
+++ b/src/gui/src/css/style.css
@@ -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) */
diff --git a/src/gui/src/i18n/translations/en.js b/src/gui/src/i18n/translations/en.js
index deec7716f..3cc949ac9 100644
--- a/src/gui/src/i18n/translations/en.js
+++ b/src/gui/src/i18n/translations/en.js
@@ -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',