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}