From d58ea792e494abc625d6eae4b1542c9b49cdbb91 Mon Sep 17 00:00:00 2001 From: jelveh Date: Thu, 16 Jul 2026 20:19:47 -0700 Subject: [PATCH] redesign: compact, scannable Dashboard Session Manager MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- src/gui/src/UI/UIWindowManageSessions.js | 244 ++++++++----- src/gui/src/css/style.css | 416 +++++++++++++++++++---- src/gui/src/i18n/translations/en.js | 2 + 3 files changed, 513 insertions(+), 149 deletions(-) diff --git a/src/gui/src/UI/UIWindowManageSessions.js b/src/gui/src/UI/UIWindowManageSessions.js index 91008fa5c..bc283bfd0 100644 --- a/src/gui/src/UI/UIWindowManageSessions.js +++ b/src/gui/src/UI/UIWindowManageSessions.js @@ -51,6 +51,42 @@ 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: '', +}; + +// 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 ?? {}; @@ -132,16 +168,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 +205,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 +215,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 +255,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', @@ -267,7 +331,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 +344,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,8 +394,10 @@ 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'); @@ -420,7 +455,7 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { } }); 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 +512,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 +528,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 () => { @@ -511,10 +556,20 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { 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,12 +580,14 @@ 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 { @@ -584,6 +641,11 @@ const UIWindowManageSessions = async function UIWindowManageSessions (options) { 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); diff --git a/src/gui/src/css/style.css b/src/gui/src/css/style.css index 426a980b6..37aae8f15 100644 --- a/src/gui/src/css/style.css +++ b/src/gui/src/css/style.css @@ -5057,112 +5057,412 @@ html.dark-mode .usage-table-show-less:hover { margin-bottom: 20px; } +/* ========================================================================== + Session manager + ========================================================================== */ + +.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; + gap: 0; + padding: 16px; height: 100% !important; + 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; + } } /* 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',