feat: add durable security activity center (Closes #493)
CI / lint (pull_request) Successful in 1m5s
CI / build-release (pull_request) Successful in 5s
CI / release-candidate (pull_request) Has been skipped

This commit is contained in:
timmy committed 2026-08-10 15:37:37 +00:00
1 parent 4749d15d85
commit 6565165295
10 files changed
+678 -3

No files matched your search

+6
View File
@@ -21,6 +21,12 @@ button { background: linear-gradient(180deg,#1f3a5f,#15324d); border:1px solid #
.active-device { display:grid; grid-template-columns:minmax(0,1fr) auto; gap:8px; align-items:center; padding:12px; border:1px solid #2a496e; border-radius:12px; background:#0f2237; }
.active-device strong, .active-device span { display:block; overflow-wrap:anywhere; }
.active-device-current { color:#55d6be; font-weight:700; }
.security-activity { margin-top:24px; padding-top:18px; border-top:1px solid #2a496e; }
.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 strong, .security-event span { display:block; overflow-wrap:anywhere; }
#load-more-security-activity { width:100%; min-height:44px; }
button:hover { filter: brightness(1.15); }
.panel { border: 1px solid #1b2d45; border-radius: 14px; padding: 12px; background: rgba(11,21,38,.92); }
.panel > summary { cursor: pointer; list-style-position: inside; }
+9
View File
@@ -38,6 +38,15 @@
<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 class="security-activity" aria-labelledby="security-activity-title">
<div class="security-activity-header">
<h3 id="security-activity-title">Security activity</h3>
<p class="small muted">Recent sign-ins, device changes, and protected actions.</p>
</div>
<div id="security-activity-status" class="small" role="status" aria-live="polite"></div>
<div id="security-activity-list" class="security-activity-list"></div>
<button id="load-more-security-activity" type="button" hidden>Load older activity</button>
</section>
</section>
</div>
+62
View File
@@ -40,6 +40,10 @@
const devicesStatus = root.document.getElementById('active-devices-status');
const closeDevices = root.document.getElementById('close-active-devices');
const enrollPasskey = root.document.getElementById('enroll-passkey');
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');
let activityCursor = null;
const renderDevices = async () => {
devicesStatus.textContent = 'Loading active devices…';
devicesList.replaceChildren();
@@ -76,10 +80,55 @@
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
}
};
const renderSecurityActivity = async (append = false) => {
if (!activityList || !activityStatus || !loadMoreActivity) return;
activityStatus.textContent = append ? 'Loading older activity…' : 'Loading security activity…';
loadMoreActivity.hidden = true;
if (!append) {
activityCursor = null;
activityList.replaceChildren();
}
try {
const page = await boundary.listSecurityEvents(append ? activityCursor : null);
const labels = {
sign_in: 'Signed in',
sign_out: 'Signed out',
device_revoked: 'Device access revoked',
all_sessions_revoked: 'All device access revoked',
issue_closed: 'Issue closed',
pull_merged: 'Pull request merged',
};
page.events.forEach(event => {
const row = root.document.createElement('article');
row.className = 'security-event';
const title = root.document.createElement('strong');
title.textContent = labels[event.kind] || 'Security event';
const details = root.document.createElement('span');
details.className = 'small muted';
const context = [event.device_label, event.method, event.target]
.filter(value => typeof value === 'string' && value).join(' · ');
details.textContent = `${new Date(event.created_at * 1000).toLocaleString()}${context ? ' · ' + context : ''}`;
row.append(title, details);
activityList.append(row);
});
activityCursor = page.next_cursor;
activityStatus.textContent = activityList.children.length
? `${activityList.children.length} recent security event${activityList.children.length === 1 ? '' : 's'}`
: 'No security activity yet.';
loadMoreActivity.textContent = 'Load older activity';
loadMoreActivity.hidden = !activityCursor;
} catch (_error) {
activityStatus.textContent = 'Security activity could not be loaded.';
loadMoreActivity.textContent = 'Retry activity';
loadMoreActivity.hidden = false;
}
};
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
if (devicesButton && devicesSheet) devicesButton.addEventListener('click', () => {
devicesSheet.hidden = false;
closeDevices?.focus();
renderDevices();
renderSecurityActivity();
});
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
devicesSheet.hidden = true;
@@ -494,6 +543,18 @@
return Array.isArray(payload.devices) ? payload.devices : [];
}
async function listSecurityEvents(cursor = null) {
const query = new URLSearchParams({ limit: '25' });
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
const response = await sessionFetch(base + 'api/v1/security-events?' + query);
if (!response.ok) throw new Error('Could not load security activity');
const payload = await response.json();
return {
events: Array.isArray(payload.events) ? payload.events : [],
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
};
}
async function revokeActiveDevice(device) {
if (!device?.management_id || device.current) return false;
const confirmed = confirmAction?.(`Sign out ${device.device_label}?`);
@@ -537,6 +598,7 @@
signOut,
signOutAllDevices,
listActiveDevices,
listSecurityEvents,
enrollPasskey,
revokeActiveDevice,
clearPrivateDeviceData,