diff --git a/frontend/security-center.js b/frontend/security-center.js index a0e2fc1..ff1ada0 100644 --- a/frontend/security-center.js +++ b/frontend/security-center.js @@ -19,6 +19,31 @@ section, root.document.getElementById(`security-${section}-section`), ])); let activityCursor = null; + let backgroundInert = null; + + const backgroundElements = ['header', 'main', '#mobile-task-dock'] + .map(selector => root.document.querySelector?.(selector)) + .filter(Boolean); + const focusableControls = () => [...(devicesSheet?.querySelectorAll?.( + 'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])' + ) || [])].filter(control => !control.hidden && !control.disabled); + const containBackground = () => { + if (!backgroundInert) { + backgroundInert = new Map(backgroundElements.map(element => [element, element.inert])); + } + backgroundInert.forEach((_wasInert, element) => { element.inert = true; }); + }; + const releaseBackground = () => { + if (!backgroundInert) return; + backgroundInert.forEach((wasInert, element) => { element.inert = wasInert; }); + backgroundInert = null; + }; + const finishClose = () => { + devicesSheet.hidden = true; + releaseBackground(); + devicesButton?.focus(); + onClose(); + }; const navigate = (section, { history = true } = {}) => { if (!sections[section]) section = 'activity'; @@ -43,16 +68,41 @@ 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; + const handleRouteChange = section => { if (section && !devicesSheet.hidden) { + containBackground(); navigate(section, { history: false }); return; } if (!devicesSheet.hidden) { - devicesSheet.hidden = true; - devicesButton?.focus(); - onClose(); + finishClose(); + } + }; + root.addEventListener?.('popstate', event => { + handleRouteChange(event.state?.stackchainSecuritySection); + }); + root.addEventListener?.('hashchange', () => { + handleRouteChange(root.history?.state?.stackchainSecuritySection); + }); + root.document.addEventListener?.('keydown', event => { + if (devicesSheet.hidden) return; + if (event.key === 'Escape') { + event.preventDefault(); + if (root.history?.state?.stackchainSecuritySection) root.history.back(); + else finishClose(); + return; + } + if (event.key !== 'Tab') return; + const controls = focusableControls(); + if (!controls.length) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && event.target === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && event.target === last) { + event.preventDefault(); + first.focus(); } }); @@ -225,6 +275,7 @@ const open = () => { if (!devicesSheet) return; onOpen(); + containBackground(); devicesSheet.hidden = false; root.document.getElementById('close-active-devices')?.focus(); navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' }); diff --git a/tests/e2e/test_mobile_home_bootstrap_release.py b/tests/e2e/test_mobile_home_bootstrap_release.py index 9c64683..4a19bd1 100644 --- a/tests/e2e/test_mobile_home_bootstrap_release.py +++ b/tests/e2e/test_mobile_home_bootstrap_release.py @@ -170,6 +170,25 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( page.locator("#active-devices").click() expect(page.locator("#active-devices-sheet")).to_be_visible() expect(page.locator("#security-activity-section")).to_be_visible() + expect(page.locator("body > header")).to_have_attribute("inert", "") + expect(page.locator("main")).to_have_attribute("inert", "") + expect(dock).to_have_attribute("inert", "") + expect(page.locator("#close-active-devices")).to_be_focused() + expect(page.locator("#active-devices-status")).to_contain_text("active device") + expect(page.locator("#enrolled-passkeys-status")).not_to_contain_text("Loading") + page.keyboard.press("Shift+Tab") + assert page.evaluate( + """ + () => { + const controls = [...document.querySelector('#active-devices-sheet').querySelectorAll( + 'button:not([disabled]), select:not([disabled]), input:not([disabled]), textarea:not([disabled]), a[href], [tabindex]:not([tabindex="-1"])' + )].filter(control => !control.hidden); + return document.activeElement === controls.at(-1); + } + """ + ) + page.keyboard.press("Tab") + expect(page.locator("#close-active-devices")).to_be_focused() security_nav = page.locator(".security-section-nav") expect(security_nav).to_be_visible() for control in security_nav.locator("button").all(): @@ -179,9 +198,15 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights( 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") + expect(page.locator("body > header")).to_have_attribute("inert", "") + expect(page.locator("main")).to_have_attribute("inert", "") + expect(dock).to_have_attribute("inert", "") page.go_back() expect(page.locator("#active-devices-sheet")).to_be_hidden() expect(page.locator("#active-devices")).to_be_focused() + expect(page.locator("body > header")).not_to_have_attribute("inert", "") + expect(page.locator("main")).not_to_have_attribute("inert", "") + expect(dock).not_to_have_attribute("inert", "") assert len(workspace_requests) == 1, workspace_requests assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth") diff --git a/tests/test_security_center.py b/tests/test_security_center.py index f78e2e6..02e225b 100644 --- a/tests/test_security_center.py +++ b/tests/test_security_center.py @@ -304,3 +304,139 @@ const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecu ) assert json.loads(completed.stdout) == {"backs": 1} + + +def test_security_center_contains_focus_and_restores_prior_background_state_on_final_back(): + harness = f""" +const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); +const listeners={{}}; +const element=(id='', inert=false)=>({{ + id, inert, hidden:false, disabled:false, textContent:'', children:[], attributes:{{}}, + addEventListener(type,listener){{(this.listeners ||= {{}})[type]=listener;}}, + focus(){{focused=this;}}, replaceChildren(){{this.children=[];}}, + append(...values){{this.children.push(...values);}}, + setAttribute(name,value){{this.attributes[name]=value;}}, removeAttribute(name){{delete this.attributes[name];}}, + scrollIntoView(){{}}, +}}); +let focused=null; +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)])); +ids['active-devices-sheet'].hidden=true; +const header=element('header'); +const main=element('main', true); +const dock=element('mobile-task-dock'); +const dynamicAction=element('revoke-lost-phone'); +const hiddenAction=element('hidden-action'); hiddenAction.hidden=true; +const disabledAction=element('disabled-action'); disabledAction.disabled=true; +ids['active-devices-sheet'].querySelectorAll=()=>[ + ids['close-active-devices'], dynamicAction, hiddenAction, disabledAction, +]; +const documentListeners={{}}; +const root={{ + document:{{ + getElementById:id=>ids[id]||null, createElement:()=>element(), + querySelector:selector=>({{'header':header,'main':main,'#mobile-task-dock':dock}})[selector]||null, + addEventListener:(type,listener)=>documentListeners[type]=listener, + }}, + history:{{state:null,pushState(state){{this.state=state;}}}}, + 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(); + const opened={{inert:[header.inert,main.inert,dock.inert],focused:focused?.id}}; + let shiftPrevented=false; + documentListeners.keydown({{key:'Tab',shiftKey:true,target:ids['close-active-devices'],preventDefault(){{shiftPrevented=true;}}}}); + const afterShift=focused?.id; + let tabPrevented=false; + documentListeners.keydown({{key:'Tab',shiftKey:false,target:dynamicAction,preventDefault(){{tabPrevented=true;}}}}); + const afterTab=focused?.id; + controller.navigate('devices'); + header.inert=false; // An earlier popstate listener released its own overlay background. + listeners.popstate({{state:{{stackchainSecuritySection:'activity'}}}}); + header.inert=false; // That listener also reacts to the resulting hashchange. + root.history.state={{stackchainSecuritySection:'activity'}}; + listeners.hashchange({{}}); + const sectionBack={{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert]}}; + root.history.state=null; + listeners.popstate({{state:null}}); + console.log(JSON.stringify({{ + opened,shiftPrevented,afterShift,tabPrevented,afterTab,sectionBack, + closed:{{hidden:ids['active-devices-sheet'].hidden,inert:[header.inert,main.inert,dock.inert],focused:focused?.id}}, + }})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], capture_output=True, text=True + ) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "opened": {"inert": [True, True, True], "focused": "close-active-devices"}, + "shiftPrevented": True, + "afterShift": "revoke-lost-phone", + "tabPrevented": True, + "afterTab": "close-active-devices", + "sectionBack": {"hidden": False, "inert": [True, True, True]}, + "closed": { + "hidden": True, + "inert": [False, True, False], + "focused": "active-devices", + }, + } + + +def test_escape_closes_security_center_through_history_and_releases_background(): + harness = f""" +const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))}); +const windowListeners={{}}; +const documentListeners={{}}; +const element=(id='')=>({{ + id,inert:false,hidden:false,disabled:false,textContent:'',children:[],attributes:{{}}, + addEventListener(){{}},focus(){{focused=this.id;}},replaceChildren(){{this.children=[];}}, + append(...values){{this.children.push(...values);}},setAttribute(){{}},removeAttribute(){{}},scrollIntoView(){{}}, +}}); +let focused=''; +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(id)])); +ids['active-devices-sheet'].hidden=true; +ids['active-devices-sheet'].querySelectorAll=()=>[ids['close-active-devices']]; +const header=element('header'); +let backs=0; +const root={{ + document:{{ + getElementById:id=>ids[id]||null,createElement:()=>element(), + querySelector:selector=>selector==='header'?header:null, + addEventListener:(type,listener)=>documentListeners[type]=listener, + }}, + history:{{state:null,pushState(state){{this.state=state;}},back(){{backs++;}}}}, + location:{{pathname:'/dashboard/',search:''}}, + addEventListener:(type,listener)=>windowListeners[type]=listener, +}}; +const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecurityEvents:async()=>({{events:[],authentication_alerts:[],next_cursor:null}})}}; +(async()=>{{ + await attachSecurityCenter({{root,boundary}}).open(); + let prevented=false; + documentListeners.keydown({{key:'Escape',preventDefault(){{prevented=true;}}}}); + const requested={{backs,prevented,hidden:ids['active-devices-sheet'].hidden,inert:header.inert}}; + windowListeners.popstate({{state:null}}); + console.log(JSON.stringify({{requested,closed:{{hidden:ids['active-devices-sheet'].hidden,inert:header.inert,focused}}}})); +}})().catch(error=>{{console.error(error);process.exit(1);}}); +""" + completed = subprocess.run( + ["node", "-e", harness], capture_output=True, text=True + ) + + assert completed.returncode == 0, completed.stderr + assert json.loads(completed.stdout) == { + "requested": {"backs": 1, "prevented": True, "hidden": False, "inert": True}, + "closed": {"hidden": True, "inert": False, "focused": "active-devices"}, + }