feat: contain Security Center focus (Closes #1136)
This commit is contained in:
parent
61b332e814
commit
931c82361b
|
|
@ -19,6 +19,31 @@
|
||||||
section, root.document.getElementById(`security-${section}-section`),
|
section, root.document.getElementById(`security-${section}-section`),
|
||||||
]));
|
]));
|
||||||
let activityCursor = null;
|
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 } = {}) => {
|
const navigate = (section, { history = true } = {}) => {
|
||||||
if (!sections[section]) section = 'activity';
|
if (!sections[section]) section = 'activity';
|
||||||
|
|
@ -43,16 +68,41 @@
|
||||||
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
root.document.getElementById('close-active-devices')?.addEventListener('click', () => {
|
||||||
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
if (root.history?.state?.stackchainSecuritySection) root.history.back();
|
||||||
});
|
});
|
||||||
root.addEventListener?.('popstate', event => {
|
const handleRouteChange = section => {
|
||||||
const section = event.state?.stackchainSecuritySection;
|
|
||||||
if (section && !devicesSheet.hidden) {
|
if (section && !devicesSheet.hidden) {
|
||||||
|
containBackground();
|
||||||
navigate(section, { history: false });
|
navigate(section, { history: false });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!devicesSheet.hidden) {
|
if (!devicesSheet.hidden) {
|
||||||
devicesSheet.hidden = true;
|
finishClose();
|
||||||
devicesButton?.focus();
|
}
|
||||||
onClose();
|
};
|
||||||
|
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 = () => {
|
const open = () => {
|
||||||
if (!devicesSheet) return;
|
if (!devicesSheet) return;
|
||||||
onOpen();
|
onOpen();
|
||||||
|
containBackground();
|
||||||
devicesSheet.hidden = false;
|
devicesSheet.hidden = false;
|
||||||
root.document.getElementById('close-active-devices')?.focus();
|
root.document.getElementById('close-active-devices')?.focus();
|
||||||
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
navigate('activity', { history: root.history?.state?.stackchainSecuritySection !== 'activity' });
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,25 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
page.locator("#active-devices").click()
|
page.locator("#active-devices").click()
|
||||||
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
expect(page.locator("#active-devices-sheet")).to_be_visible()
|
||||||
expect(page.locator("#security-activity-section")).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")
|
security_nav = page.locator(".security-section-nav")
|
||||||
expect(security_nav).to_be_visible()
|
expect(security_nav).to_be_visible()
|
||||||
for control in security_nav.locator("button").all():
|
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")
|
expect(page.locator("#security-section-devices")).to_have_attribute("aria-current", "page")
|
||||||
page.go_back()
|
page.go_back()
|
||||||
expect(page.locator("#security-section-activity")).to_have_attribute("aria-current", "page")
|
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()
|
page.go_back()
|
||||||
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
expect(page.locator("#active-devices-sheet")).to_be_hidden()
|
||||||
expect(page.locator("#active-devices")).to_be_focused()
|
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 len(workspace_requests) == 1, workspace_requests
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
|
|
|
||||||
|
|
@ -304,3 +304,139 @@ const boundary={{listActiveDevices:async()=>[],listPasskeys:async()=>[],listSecu
|
||||||
)
|
)
|
||||||
|
|
||||||
assert json.loads(completed.stdout) == {"backs": 1}
|
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"},
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user