feat: lazy-load security center (Closes #605)
This commit is contained in:
1 parent
17823352f2
commit
fb58ba30b2
11 files changed
+308
-186
No files matched your search
@@ -47,7 +47,8 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||
} catch (_error) {
|
||||
if (status) {
|
||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
||||
name === 'pull-workflow' ? 'Pull workspace' : name.replace(/-/g, ' ');
|
||||
name === 'pull-workflow' ? 'Pull workspace' :
|
||||
name === 'security-center' ? 'Security center' : name.replace(/-/g, ' ');
|
||||
status.textContent = label + ' could not load. ' + retryLabel;
|
||||
}
|
||||
return false;
|
||||
|
||||
@@ -831,6 +831,7 @@
|
||||
|
||||
<script src="static/session.js"></script>
|
||||
<script src="static/feature-loader.js"></script>
|
||||
<script src="static/security-center.js"></script>
|
||||
<script src="static/markdown.js"></script>
|
||||
<script src="static/commands.js"></script>
|
||||
<script src="static/search-preview.js"></script>
|
||||
|
||||
@@ -0,0 +1,185 @@
|
||||
(function (root, factory) {
|
||||
if (typeof module !== 'undefined' && module.exports) module.exports = factory;
|
||||
else root.attachSecurityCenter = boundary => factory({ root, boundary });
|
||||
})(typeof window !== 'undefined' ? window : this, function attachSecurityCenter({ root, boundary }) {
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-status');
|
||||
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();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
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();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys 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);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in', sign_out: 'Signed out', passkey_enrolled: 'Passkey enrolled',
|
||||
device_revoked: 'Device access revoked', all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed', pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested', gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
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,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null]
|
||||
.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;
|
||||
}
|
||||
};
|
||||
|
||||
const open = () => {
|
||||
if (!devicesSheet) return;
|
||||
devicesSheet.hidden = false;
|
||||
root.document.getElementById('close-active-devices')?.focus();
|
||||
return Promise.all([renderDevices(), renderPasskeys(), renderSecurityActivity()]);
|
||||
};
|
||||
devicesButton?.addEventListener('click', open);
|
||||
loadMoreActivity?.addEventListener('click', () => renderSecurityActivity(Boolean(activityCursor)));
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
return { open, renderDevices, renderPasskeys, renderSecurityActivity };
|
||||
});
|
||||
@@ -14,6 +14,7 @@ const SHELL = [
|
||||
BASE + 'static/icons/stackchain-512.png',
|
||||
BASE + 'static/session.js',
|
||||
BASE + 'static/feature-loader.js',
|
||||
BASE + 'static/security-center.js',
|
||||
BASE + 'static/markdown.js',
|
||||
BASE + 'static/commands.js',
|
||||
BASE + 'static/search-preview.js',
|
||||
|
||||
+28
-178
@@ -36,192 +36,42 @@
|
||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||
const devicesButton = root.document.getElementById('active-devices');
|
||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
||||
const devicesList = root.document.getElementById('active-devices-list');
|
||||
const devicesStatus = root.document.getElementById('active-devices-status');
|
||||
const passkeysList = root.document.getElementById('enrolled-passkeys-list');
|
||||
const passkeysStatus = root.document.getElementById('enrolled-passkeys-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();
|
||||
try {
|
||||
const devices = await boundary.listActiveDevices();
|
||||
devices.forEach(device => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'active-device';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = device.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
timing.textContent = `Signed in ${new Date(device.created_at * 1000).toLocaleString()} · expires ${new Date(device.expires_at * 1000).toLocaleString()}`;
|
||||
details.append(label, timing);
|
||||
if (device.current) {
|
||||
const current = root.document.createElement('span');
|
||||
current.className = 'active-device-current';
|
||||
current.textContent = 'This device';
|
||||
details.append(current);
|
||||
}
|
||||
const revoke = root.document.createElement('button');
|
||||
revoke.type = 'button';
|
||||
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();
|
||||
});
|
||||
row.append(details, revoke);
|
||||
devicesList.append(row);
|
||||
});
|
||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Active devices could not be loaded. Try again.';
|
||||
}
|
||||
};
|
||||
const renderPasskeys = async () => {
|
||||
if (!passkeysList || !passkeysStatus) return;
|
||||
passkeysStatus.textContent = 'Loading enrolled passkeys…';
|
||||
passkeysList.replaceChildren();
|
||||
try {
|
||||
const enrolled = await boundary.listPasskeys();
|
||||
enrolled.forEach(passkey => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'enrolled-passkey';
|
||||
const details = root.document.createElement('div');
|
||||
const label = root.document.createElement('strong');
|
||||
label.textContent = passkey.device_label;
|
||||
const timing = root.document.createElement('span');
|
||||
timing.className = 'small muted';
|
||||
const state = passkey.current ? 'This active device' : (passkey.active ? 'Active device' : 'No active session');
|
||||
timing.textContent = `Enrolled ${new Date(passkey.created_at * 1000).toLocaleString()} · ${state}`;
|
||||
details.append(label, timing);
|
||||
const remove = root.document.createElement('button');
|
||||
remove.type = 'button';
|
||||
remove.textContent = 'Remove passkey';
|
||||
remove.addEventListener('click', async () => {
|
||||
remove.disabled = true;
|
||||
passkeysStatus.textContent = `Removing passkey for ${passkey.device_label}…`;
|
||||
try {
|
||||
const outcome = await boundary.revokePasskey(passkey);
|
||||
if (outcome) {
|
||||
passkeysStatus.textContent = outcome.current_session
|
||||
? 'Passkey removed. This session remains active; keep your recovery token available.'
|
||||
: 'Passkey removed.';
|
||||
await renderPasskeys();
|
||||
if (outcome.session_revoked) await renderDevices();
|
||||
} else {
|
||||
remove.disabled = false;
|
||||
passkeysStatus.textContent = `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`;
|
||||
}
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Passkey could not be removed. Refresh to verify before retrying.';
|
||||
remove.disabled = false;
|
||||
}
|
||||
});
|
||||
row.append(details, remove);
|
||||
passkeysList.append(row);
|
||||
});
|
||||
passkeysStatus.textContent = enrolled.length
|
||||
? `${enrolled.length} enrolled passkey${enrolled.length === 1 ? '' : 's'}`
|
||||
: 'No passkeys enrolled.';
|
||||
} catch (_error) {
|
||||
passkeysStatus.textContent = 'Enrolled passkeys 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);
|
||||
if (!append) {
|
||||
page.authentication_alerts.forEach(alert => {
|
||||
const row = root.document.createElement('article');
|
||||
row.className = 'security-event authentication-alert';
|
||||
const formatted = boundary.formatAuthenticationAlert(alert);
|
||||
const title = root.document.createElement('strong');
|
||||
title.textContent = formatted.title;
|
||||
const details = root.document.createElement('span');
|
||||
details.className = 'small muted';
|
||||
details.textContent = formatted.detail;
|
||||
row.append(title, details);
|
||||
activityList.append(row);
|
||||
});
|
||||
}
|
||||
const labels = {
|
||||
sign_in: 'Signed in',
|
||||
sign_out: 'Signed out',
|
||||
passkey_enrolled: 'Passkey enrolled',
|
||||
device_revoked: 'Device access revoked',
|
||||
all_sessions_revoked: 'All device access revoked',
|
||||
issue_closed: 'Issue closed',
|
||||
pull_merged: 'Pull request merged',
|
||||
pull_review_approved: 'Pull request approved',
|
||||
pull_review_changes_requested: 'Changes requested',
|
||||
gitea_time_logged: 'Gitea time logged',
|
||||
};
|
||||
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,
|
||||
event.status === 'pending' ? 'Outcome confirmation pending' : null,
|
||||
].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', () => {
|
||||
const securityFeatures = root.createFeatureLoader({
|
||||
document: root.document,
|
||||
urls: {
|
||||
'security-center': root.document.querySelector(
|
||||
'meta[name="stackchain-feature-security-center"]'
|
||||
)?.content || '',
|
||||
},
|
||||
});
|
||||
let loadingSecurityCenter = false;
|
||||
const openSecurityCenter = async () => {
|
||||
if (loadingSecurityCenter) return;
|
||||
loadingSecurityCenter = true;
|
||||
devicesSheet.hidden = false;
|
||||
closeDevices?.focus();
|
||||
renderDevices();
|
||||
renderPasskeys();
|
||||
renderSecurityActivity();
|
||||
});
|
||||
try {
|
||||
await securityFeatures.run('security-center', {
|
||||
trigger: devicesButton,
|
||||
status: devicesStatus,
|
||||
retryLabel: 'Tap Active devices to retry.',
|
||||
}, () => {
|
||||
devicesButton.removeEventListener('click', openSecurityCenter);
|
||||
root.attachSecurityCenter(boundary).open();
|
||||
});
|
||||
} finally {
|
||||
loadingSecurityCenter = false;
|
||||
}
|
||||
};
|
||||
if (devicesButton && devicesSheet) {
|
||||
devicesButton.addEventListener('click', openSecurityCenter);
|
||||
}
|
||||
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||
devicesSheet.hidden = true;
|
||||
devicesButton?.focus();
|
||||
});
|
||||
enrollPasskey?.addEventListener('click', async () => {
|
||||
enrollPasskey.disabled = true;
|
||||
devicesStatus.textContent = 'Waiting for your device passkey…';
|
||||
try {
|
||||
await boundary.enrollPasskey();
|
||||
devicesStatus.textContent = 'Passkey enrolled. You can use it at sign-in and authorization prompts.';
|
||||
await renderPasskeys();
|
||||
} catch (_error) {
|
||||
devicesStatus.textContent = 'Passkey enrollment was not completed. Try again.';
|
||||
} finally {
|
||||
enrollPasskey.disabled = false;
|
||||
}
|
||||
});
|
||||
boundary.refreshOfflineLease().then(valid => {
|
||||
if (valid) boundary.resumeQueuedWork();
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user