Make Security Center a Back-safe mobile investigation flow #1021
|
|
@ -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 { 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 h2, .active-devices-header p { margin-top:0; }
|
||||||
.active-devices-header button, .active-device button, .enrolled-passkey button { min-height:44px; }
|
.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; }
|
.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 { 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; }
|
.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-header h3, .security-activity-header p { margin:0 0 6px; }
|
||||||
.security-activity-list { display:grid; gap:8px; margin:12px 0; }
|
.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 { 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 { border-color:#d69e2e; background:#241b09; }
|
||||||
.authentication-alert strong { color:#f6c453; }
|
.authentication-alert strong { color:#f6c453; }
|
||||||
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
.security-event strong, .security-event span { display:block; overflow-wrap:anywhere; }
|
||||||
|
|
|
||||||
|
|
@ -48,22 +48,15 @@
|
||||||
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
<div id="active-devices-sheet" class="active-devices-sheet" hidden>
|
||||||
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
<section class="active-devices-panel" role="dialog" aria-modal="true" aria-label="Active devices and passkeys">
|
||||||
<div class="active-devices-header">
|
<div class="active-devices-header">
|
||||||
<div><h2>Active devices</h2><p class="small muted">Review signed-in devices and remove access you no longer trust.</p></div>
|
<div><h2>Security Center</h2><p class="small muted">Investigate activity, remove untrusted access, and verify the result.</p></div>
|
||||||
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
<button id="close-active-devices" type="button" aria-label="Close active devices">Close</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
<nav class="security-section-nav" aria-label="Security Center sections">
|
||||||
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
<button id="security-section-activity" type="button">Activity</button>
|
||||||
<div id="active-devices-list" class="active-devices-list"></div>
|
<button id="security-section-devices" type="button">Devices</button>
|
||||||
<section class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
<button id="security-section-passkeys" type="button">Passkeys</button>
|
||||||
<div class="enrolled-passkeys-header">
|
</nav>
|
||||||
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
<section id="security-activity-section" class="security-activity" aria-labelledby="security-activity-title">
|
||||||
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
|
||||||
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
|
||||||
</div>
|
|
||||||
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
|
||||||
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
|
||||||
</section>
|
|
||||||
<section class="security-activity" aria-labelledby="security-activity-title">
|
|
||||||
<div class="security-activity-header">
|
<div class="security-activity-header">
|
||||||
<h3 id="security-activity-title">Security activity</h3>
|
<h3 id="security-activity-title">Security activity</h3>
|
||||||
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
|
||||||
|
|
@ -72,6 +65,24 @@
|
||||||
<div id="security-activity-list" class="security-activity-list"></div>
|
<div id="security-activity-list" class="security-activity-list"></div>
|
||||||
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
|
||||||
</section>
|
</section>
|
||||||
|
<section id="security-devices-section" class="security-devices" aria-labelledby="security-devices-title">
|
||||||
|
<div class="security-activity-header">
|
||||||
|
<h3 id="security-devices-title">Active devices</h3>
|
||||||
|
<p class="small muted">Review signed-in devices and remove access you no longer trust.</p>
|
||||||
|
</div>
|
||||||
|
<div id="active-devices-status" class="small" role="status" aria-live="polite"></div>
|
||||||
|
<button id="enroll-passkey" type="button">Add a passkey for this device</button>
|
||||||
|
<div id="active-devices-list" class="active-devices-list"></div>
|
||||||
|
</section>
|
||||||
|
<section id="security-passkeys-section" class="enrolled-passkeys" aria-labelledby="enrolled-passkeys-title">
|
||||||
|
<div class="enrolled-passkeys-header">
|
||||||
|
<h3 id="enrolled-passkeys-title">Enrolled passkeys</h3>
|
||||||
|
<p class="small muted">Passkeys can remain enrolled after a device signs out. Remove credentials you no longer trust.</p>
|
||||||
|
<p class="small muted">A recovery token will be required after sign-out if you remove this device’s passkey.</p>
|
||||||
|
</div>
|
||||||
|
<div id="enrolled-passkeys-status" class="small" role="status" aria-live="polite"></div>
|
||||||
|
<div id="enrolled-passkeys-list" class="enrolled-passkeys-list"></div>
|
||||||
|
</section>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,49 @@
|
||||||
const activityList = root.document.getElementById('security-activity-list');
|
const activityList = root.document.getElementById('security-activity-list');
|
||||||
const activityStatus = root.document.getElementById('security-activity-status');
|
const activityStatus = root.document.getElementById('security-activity-status');
|
||||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
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;
|
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 () => {
|
const renderDevices = async () => {
|
||||||
devicesStatus.textContent = 'Loading active devices…';
|
devicesStatus.textContent = 'Loading active devices…';
|
||||||
devicesList.replaceChildren();
|
devicesList.replaceChildren();
|
||||||
|
|
@ -40,7 +81,9 @@
|
||||||
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
revoke.textContent = device.current ? 'Sign out' : 'Revoke';
|
||||||
revoke.addEventListener('click', async () => {
|
revoke.addEventListener('click', async () => {
|
||||||
if (device.current) await boundary.signOut();
|
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);
|
row.append(details, revoke);
|
||||||
devicesList.append(row);
|
devicesList.append(row);
|
||||||
|
|
@ -82,6 +125,7 @@
|
||||||
: 'Passkey removed.';
|
: 'Passkey removed.';
|
||||||
await renderPasskeys();
|
await renderPasskeys();
|
||||||
if (outcome.session_revoked) await renderDevices();
|
if (outcome.session_revoked) await renderDevices();
|
||||||
|
await renderSecurityActivity();
|
||||||
} else {
|
} else {
|
||||||
remove.disabled = false;
|
remove.disabled = false;
|
||||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||||
|
|
@ -122,7 +166,11 @@
|
||||||
const details = root.document.createElement('span');
|
const details = root.document.createElement('span');
|
||||||
details.className = 'small muted';
|
details.className = 'small muted';
|
||||||
details.textContent = formatted.detail;
|
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);
|
activityList.append(row);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -150,6 +198,13 @@
|
||||||
.filter(value => typeof value === 'string' && value).join(' · ');
|
.filter(value => typeof value === 'string' && value).join(' · ');
|
||||||
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
|
||||||
row.append(title, details);
|
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);
|
activityList.append(row);
|
||||||
});
|
});
|
||||||
activityCursor = page.next_cursor;
|
activityCursor = page.next_cursor;
|
||||||
|
|
@ -169,6 +224,7 @@
|
||||||
if (!devicesSheet) return;
|
if (!devicesSheet) return;
|
||||||
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' });
|
||||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||||
};
|
};
|
||||||
devicesButton?.addEventListener('click', open);
|
devicesButton?.addEventListener('click', open);
|
||||||
|
|
@ -186,5 +242,5 @@
|
||||||
enrollPasskey.disabled = false;
|
enrollPasskey.disabled = false;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
return { open, navigate, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,22 @@ def test_release_artifact_bootstraps_mobile_home_and_returns_from_insights(
|
||||||
page.locator("#close-device-setup").click()
|
page.locator("#close-device-setup").click()
|
||||||
expect(page.locator("#device-setup-sheet")).to_be_hidden()
|
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 len(workspace_requests) == 1, workspace_requests
|
||||||
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
assert page.evaluate("document.documentElement.scrollWidth <= window.innerWidth")
|
||||||
assert browser_errors == []
|
assert browser_errors == []
|
||||||
|
|
|
||||||
|
|
@ -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 'aria-labelledby="enrolled-passkeys-title"' in html
|
||||||
assert 'id="enrolled-passkeys-list"' 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 '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 'aria-labelledby="security-activity-title"' in html
|
||||||
assert 'id="security-activity-list"' in html
|
assert 'id="security-activity-list"' in html
|
||||||
assert '>Load older activity</button>' in html
|
assert '>Load older activity</button>' in html
|
||||||
assert '#sign-out-all { min-height:44px; }' 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():
|
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,181 @@ const boundary={{
|
||||||
["node", "-e", harness], check=True, capture_output=True, text=True
|
["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 title == "Passkey counter anomaly"
|
||||||
assert "Remove and re-enroll this passkey" in detail
|
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}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user