From c188257f8528956e9dbcdc86919cc69b71831bab Mon Sep 17 00:00:00 2001 From: Nariman Jelveh Date: Thu, 16 Jul 2026 20:48:54 -0700 Subject: [PATCH] Session Manager: compact redesign + responsive modal (no UIWindow) (#3400) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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). --- src/gui/src/UI/UIWindowManageSessions.js | 509 ++++++++++++------- src/gui/src/css/style.css | 606 ++++++++++++++++++++--- src/gui/src/i18n/translations/en.js | 2 + 3 files changed, 891 insertions(+), 226 deletions(-) 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',