From 241f0f3a2a945bfd0f01072f959f1f480977db51 Mon Sep 17 00:00:00 2001 From: timmy Date: Mon, 17 Aug 2026 11:57:27 +0000 Subject: [PATCH] feat: make Security Center Back-safe on mobile (Closes #1020) --- frontend/dashboard.css | 6 + frontend/index.html | 39 ++-- frontend/security-center.js | 62 +++++- .../e2e/test_mobile_home_bootstrap_release.py | 16 ++ tests/test_dashboard_session_frontend.py | 12 ++ tests/test_security_center.py | 177 +++++++++++++++++- 6 files changed, 294 insertions(+), 18 deletions(-) diff --git a/frontend/dashboard.css b/frontend/dashboard.css index cd84e98..fcc993d 100644 --- a/frontend/dashboard.css +++ b/frontend/dashboard.css @@ -46,6 +46,11 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid # .active-devices-header { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; } .active-devices-header h2, .active-devices-header p { margin-top:0; } .active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; } +.security-section-nav { position:sticky; top:-18px; z-index:2; display:grid; grid-template-columns:repeat(3,minmax(0,1fr)); gap:8px; margin:0 -2px; padding:10px 2px; background:#0b1526; } +.security-section-nav button { min-height:44px; min-width:0; } +.security-section-nav button[aria-current="page"] { border-color:#55d6be; color:#55d6be; } +.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:64px; } +.security-devices { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; } .active-devices-list { display:grid; gap:10px; margin-top:16px; } .device-setup-sheet { position:fixed; inset:0; z-index:95; display:flex; align-items:flex-end; justify-content:center; background:rgba(5,12,21,.82); backdrop-filter:blur(4px); } .device-setup-sheet[hidden] { display:none; } @@ -81,6 +86,7 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid # .security-activity-header h3, .security-activity-header p { margin:0 0 6px; } .security-activity-list { display:grid; gap:8px; margin:12px 0; } .security-event { padding:12px; border:1px solid #243d5d; border-radius:12px; background:#0d1c30; } +.security-event button { min-height:44px; margin-top:10px; } .authentication-alert { border-color:#d69e2e; background:#241b09; } .authentication-alert strong { color:#f6c453; } .security-event strong, .security-event span { display:block; overflow-wrap:anywhere; } diff --git a/frontend/index.html b/frontend/index.html index 9206ad9..6d2c4f3 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -48,22 +48,15 @@ diff --git a/frontend/security-center.js b/frontend/security-center.js index 805d191..4d4436f 100644 --- a/frontend/security-center.js +++ b/frontend/security-center.js @@ -12,8 +12,49 @@ const activityList = root.document.getElementById('security-activity-list'); const activityStatus = root.document.getElementById('security-activity-status'); const loadMoreActivity = root.document.getElementById('load-more-security-activity'); + const sectionButtons = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [ + section, root.document.getElementById(`security-section-${section}`), + ])); + const sections = Object.fromEntries(['activity', 'devices', 'passkeys'].map(section => [ + section, root.document.getElementById(`security-${section}-section`), + ])); let activityCursor = null; + const navigate = (section, { history = true } = {}) => { + if (!sections[section]) section = 'activity'; + Object.entries(sectionButtons).forEach(([name, button]) => { + if (!button) return; + if (name === section) button.setAttribute('aria-current', 'page'); + else button.removeAttribute('aria-current'); + }); + sections[section]?.scrollIntoView?.({ block: 'start' }); + if (history && root.history?.pushState) { + root.history.pushState( + { ...(root.history.state || {}), stackchainSecuritySection: section }, + '', + `${root.location.pathname}${root.location.search || ''}#security/${section}`, + ); + } + }; + + Object.entries(sectionButtons).forEach(([section, button]) => { + button?.addEventListener('click', () => navigate(section)); + }); + root.document.getElementById('close-active-devices')?.addEventListener('click', () => { + if (root.history?.state?.stackchainSecuritySection) root.history.back(); + }); + root.addEventListener?.('popstate', event => { + const section = event.state?.stackchainSecuritySection; + if (section && !devicesSheet.hidden) { + navigate(section, { history: false }); + return; + } + if (!devicesSheet.hidden) { + devicesSheet.hidden = true; + devicesButton?.focus(); + } + }); + const renderDevices = async () => { devicesStatus.textContent = 'Loading active devices…'; devicesList.replaceChildren(); @@ -40,7 +81,9 @@ revoke.textContent = device.current ? 'Sign out' : 'Revoke'; revoke.addEventListener('click', async () => { if (device.current) await boundary.signOut(); - else if (await boundary.revokeActiveDevice(device)) await renderDevices(); + else if (await boundary.revokeActiveDevice(device)) { + await Promise.all([renderDevices(), renderSecurityActivity()]); + } }); row.append(details, revoke); devicesList.append(row); @@ -82,6 +125,7 @@ : 'Passkey removed.'; await renderPasskeys(); if (outcome.session_revoked) await renderDevices(); + await renderSecurityActivity(); } else { remove.disabled = false; passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`; @@ -122,7 +166,11 @@ const details = root.document.createElement('span'); details.className = 'small muted'; details.textContent = formatted.detail; - row.append(title, details); + const action = root.document.createElement('button'); + action.type = 'button'; + action.textContent = 'Review devices'; + action.addEventListener('click', () => navigate('devices')); + row.append(title, details, action); activityList.append(row); }); } @@ -150,6 +198,13 @@ .filter(value => typeof value === 'string' && value).join(' · '); details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`; row.append(title, details); + if (event.kind === 'passkey_counter_anomaly') { + const action = root.document.createElement('button'); + action.type = 'button'; + action.textContent = 'Review passkeys'; + action.addEventListener('click', () => navigate('passkeys')); + row.append(action); + } activityList.append(row); }); activityCursor = page.next_cursor; @@ -169,6 +224,7 @@ if (!devicesSheet) return; devicesSheet.hidden = false; root.document.getElementById('close-active-devices')?.focus(); + navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' }); return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]); }; devicesButton?.addEventListener('click', open); @@ -186,5 +242,5 @@ enrollPasskey.disabled = false; } }); - return { open, renderDevices, renderPasskeys, renderSecurityActivity }; + return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity }; }); diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index b7ee753..872f3fe 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -131,6 +131,22 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( page.locator("#close-device-setup").click() expect(page.locator("#device-setup-sheet")).to_be_hidden() + page.locator("#active-devices").click() + expect(page.locator("#active-devices-sheet")).to_be_visible() + expect(page.locator("#security-activity-section")).to_be_visible() + security_nav = page.locator(".security-section-nav") + expect(security_nav).to_be_visible() + for control in security_nav.locator("button").all(): + bounds = control.bounding_box() + assert bounds and bounds["height"] >= 44 + page.locator("#security-section-devices").click() + expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page") + page.go_back() + expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page") + page.go_back() + expect(page.locator("#active-devices-sheet")).to_be_hidden() + expect(page.locator("#active-devices")).to_be_focused() + assert len(workspace_requests) == 1, workspace_requests assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") assert browser_errors == [] diff --git a/tests/test_dashboard_session_frontend.py b/tests/test_dashboard_session_frontend.py index dd01cf6..6273ee3 100644 --- a/tests/test_dashboard_session_frontend.py +++ b/tests/test_dashboard_session_frontend.py @@ -850,10 +850,22 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out(): assert 'aria-labelledby="enrolled-passkeys-title"' in html assert 'id="enrolled-passkeys-list"' in html assert 'A recovery token will be required after sign-out if you remove this device’s passkey.' in html + assert 'class="security-section-nav" aria-label="Security Center sections"' in html + assert 'id="security-section-activity"' in html + assert 'id="security-section-devices"' in html + assert 'id="security-section-passkeys"' in html + assert 'id="security-activity-section"' in html + assert 'id="security-devices-section"' in html + assert 'id="security-passkeys-section"' in html + assert html.index('id="security-activity-section"') < html.index('id="security-devices-section"') assert 'aria-labelledby="security-activity-title"' in html assert 'id="security-activity-list"' in html assert '>Load older activity' in html assert '#sign-out-all { min-height:44px; }' in html + assert '.security-section-nav { position:sticky;' in html + assert '.security-section-nav button { min-height:44px;' in html + assert '.security-event button { min-height:44px;' in html + assert '.security-activity, .security-devices, .enrolled-passkeys { scroll-margin-top:' in html def test_security_activity_names_confirmed_gitea_time_logging(): diff --git a/tests/test_security_center.py b/tests/test_security_center.py index 22bbe03..b2efe42 100644 --- a/tests/test_security_center.py +++ b/tests/test_security_center.py @@ -83,6 +83,181 @@ const boundary={{ ["node", "-e", harness], check=True, capture_output=True, text=True ) - title, detail = json.loads(completed.stdout) + title, detail, action = json.loads(completed.stdout) assert title == "Passkey counter anomaly" assert "Remove and re-enroll this passkey" in detail + assert action == "Review passkeys" + + +def test_activity_alert_hands_off_to_addressable_devices_section(): + harness = f""" +const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); +const listeners={{}}; +const element=(id='')=>({{ + id, hidden:true, disabled:false, textContent:'', children:[], className:'', dataset:{{}}, attributes:{{}}, + addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, + click(){{this.listeners?.click?.({{preventDefault(){{}}}});}}, + focus(){{}}, replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}}, + setAttribute(name,value){{this.attributes[name]=String(value);}}, removeAttribute(name){{delete this.attributes[name];}}, + scrollIntoView(){{this.scrolled=true;}}, +}}); +const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status', + 'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list', + 'security-activity-status','load-more-security-activity','close-active-devices', + 'security-section-activity','security-section-devices','security-section-passkeys', + 'security-activity-section','security-devices-section','security-passkeys-section']; +const ids=Object.fromEntries(names.map(id=>[id,element(id)])); +const pushed=[]; +const root={{ + document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}, + history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push({{state,url}});}}}}, + location:{{pathname:'/dashboard/',search:'',hash:''}}, + addEventListener:(type,listener)=>listeners[type]=listener, +}}; +const boundary={{ + listActiveDevices:async()=>[], listPasskeys:async()=>[], + listSecurityEvents:async()=>({{events:[],authentication_alerts:[{{}}],next_cursor:null}}), + formatAuthenticationAlert:()=>({{title:'Failed sign-ins',detail:'Three attempts'}}), +}}; +(async()=>{{ + const controller=attachSecurityCenter({{root,boundary}}); + await controller.open(); + const alert=ids['security-activity-list'].children[0]; + const action=alert.children[2]; + action.click(); + console.log(JSON.stringify({{ + initial:pushed[0], latest:pushed.at(-1), action:action.textContent, + current:ids['security-section-devices'].attributes['aria-current'], + scrolled:ids['security-devices-section'].scrolled||false, + }})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], check=True, capture_output=True, text=True + ) + + output = json.loads(completed.stdout) + assert output["initial"]["state"]["stackchainSecuritySection"] == "activity" + assert output["initial"]["url"] == "/dashboard/#security/activity" + assert output["latest"]["state"]["stackchainSecuritySection"] == "devices" + assert output["action"] == "Review devices" + assert output["current"] == "page" + assert output["scrolled"] is True + + +def test_device_revocation_refreshes_inventory_and_activity_for_verification(): + harness = f""" +const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); +const element=()=>({{ + hidden:true, disabled:false, textContent:'', children:[], className:'', attributes:{{}}, + addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, + click(){{return this.listeners?.click?.();}}, focus(){{}}, replaceChildren(){{this.children=[];}}, + append(...values){{this.children.push(...values);}}, setAttribute(name,value){{this.attributes[name]=value;}}, + removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}}, +}}); +const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status', + 'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list', + 'security-activity-status','load-more-security-activity','close-active-devices']; +const ids=Object.fromEntries(names.map(id=>[id,element()])); +const calls={{devices:0,activity:0,revoke:0}}; +const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}}; +const boundary={{ + listActiveDevices:async()=>{{calls.devices++;return [{{device_label:'Lost phone',created_at:1,expires_at:2,current:false}}];}}, + listPasskeys:async()=>[], + listSecurityEvents:async()=>{{calls.activity++;return {{events:[],authentication_alerts:[],next_cursor:null}};}}, + revokeActiveDevice:async()=>{{calls.revoke++;return true;}}, +}}; +(async()=>{{ + await attachSecurityCenter({{root,boundary}}).open(); + await ids['active-devices-list'].children[0].children[1].click(); + console.log(JSON.stringify({{calls,status:ids['security-activity-status'].textContent}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], check=True, capture_output=True, text=True + ) + + output = json.loads(completed.stdout) + assert output["calls"] == {"devices": 2, "activity": 2, "revoke": 1} + assert output["status"] == "No security activity yet." + + +def test_back_unwinds_security_sections_then_closes_and_restores_focus(): + harness = f""" +const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); +const listeners={{}}; +const element=()=>({{ + hidden:true, textContent:'', children:[], attributes:{{}}, + addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, focus(){{this.focused=true;}}, + replaceChildren(){{this.children=[];}}, append(...values){{this.children.push(...values);}}, + setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}}, scrollIntoView(){{}}, +}}); +const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status', + 'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list', + 'security-activity-status','load-more-security-activity','close-active-devices', + 'security-section-activity','security-section-devices','security-section-passkeys', + 'security-activity-section','security-devices-section','security-passkeys-section']; +const ids=Object.fromEntries(names.map(id=>[id,element()])); +const pushed=[]; +const root={{ + document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}, + history:{{state:null,pushState(state,_title,url){{this.state=state;pushed.push(url);}}}}, + location:{{pathname:'/dashboard/',search:''}}, + addEventListener:(type,listener)=>listeners[type]=listener, +}}; +const boundary={{ + listActiveDevices:async()=>[],listPasskeys:async()=>[], + listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}}), +}}; +(async()=>{{ + const controller=attachSecurityCenter({{root,boundary}}); + await controller.open(); + controller.navigate('devices'); + listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}}); + const afterFirst={{hidden:ids['active-devices-sheet'].hidden,current:ids['security-section-activity'].attributes['aria-current']}}; + listeners.popstate({{state:null}}); + console.log(JSON.stringify({{afterFirst,hidden:ids['active-devices-sheet'].hidden,focused:ids['active-devices'].focused||false,pushed}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], check=True, capture_output=True, text=True + ) + + output = json.loads(completed.stdout) + assert output["afterFirst"] == {"hidden": False, "current": "page"} + assert output["hidden"] is True + assert output["focused"] is True + assert output["pushed"] == ["/dashboard/#security/activity", "/dashboard/#security/devices"] + + +def test_close_uses_history_back_to_remove_security_center_route(): + harness = f""" +const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); +const element=()=>({{ + hidden:true,textContent:'',children:[],attributes:{{}}, + addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, + click(){{return this.listeners?.click?.({{preventDefault(){{}}}});}},focus(){{}},replaceChildren(){{this.children=[];}}, + append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}}, +}}); +const names=['active-devices','active-devices-sheet','active-devices-list','active-devices-status', + 'enrolled-passkeys-list','enrolled-passkeys-status','enroll-passkey','security-activity-list', + 'security-activity-status','load-more-security-activity','close-active-devices']; +const ids=Object.fromEntries(names.map(id=>[id,element()])); +let backs=0; +const root={{ + document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}, + history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}}, + location:{{pathname:'/dashboard/',search:''}},addEventListener(){{}}, +}}; +const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}}; +(async()=>{{ + await attachSecurityCenter({{root,boundary}}).open(); + ids['close-active-devices'].click(); + console.log(JSON.stringify({{backs}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], check=True, capture_output=True, text=True + ) + + assert json.loads(completed.stdout) == {"backs": 1}