feat: lazy-load security center (Closes #605)
This commit is contained in:
parent
17823352f2
commit
fb58ba30b2
|
|
@ -47,7 +47,8 @@ function createFeatureLoader({ document, urls, timeoutMs = 10000 }) {
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
if (status) {
|
if (status) {
|
||||||
const label = name === 'issue-capture' ? 'Issue capture' :
|
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;
|
status.textContent = label + ' could not load. ' + retryLabel;
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
|
|
|
||||||
|
|
@ -831,6 +831,7 @@
|
||||||
|
|
||||||
<script src="static/session.js"></script>
|
<script src="static/session.js"></script>
|
||||||
<script src="static/feature-loader.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/markdown.js"></script>
|
||||||
<script src="static/commands.js"></script>
|
<script src="static/commands.js"></script>
|
||||||
<script src="static/search-preview.js"></script>
|
<script src="static/search-preview.js"></script>
|
||||||
|
|
|
||||||
185
frontend/security-center.js
Normal file
185
frontend/security-center.js
Normal file
|
|
@ -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/icons/stackchain-512.png',
|
||||||
BASE + 'static/session.js',
|
BASE + 'static/session.js',
|
||||||
BASE + 'static/feature-loader.js',
|
BASE + 'static/feature-loader.js',
|
||||||
|
BASE + 'static/security-center.js',
|
||||||
BASE + 'static/markdown.js',
|
BASE + 'static/markdown.js',
|
||||||
BASE + 'static/commands.js',
|
BASE + 'static/commands.js',
|
||||||
BASE + 'static/search-preview.js',
|
BASE + 'static/search-preview.js',
|
||||||
|
|
|
||||||
|
|
@ -36,192 +36,42 @@
|
||||||
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
if (allDevicesButton) allDevicesButton.addEventListener('click', () => boundary.signOutAllDevices());
|
||||||
const devicesButton = root.document.getElementById('active-devices');
|
const devicesButton = root.document.getElementById('active-devices');
|
||||||
const devicesSheet = root.document.getElementById('active-devices-sheet');
|
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 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 closeDevices = root.document.getElementById('close-active-devices');
|
||||||
const enrollPasskey = root.document.getElementById('enroll-passkey');
|
const securityFeatures = root.createFeatureLoader({
|
||||||
const activityList = root.document.getElementById('security-activity-list');
|
document: root.document,
|
||||||
const activityStatus = root.document.getElementById('security-activity-status');
|
urls: {
|
||||||
const loadMoreActivity = root.document.getElementById('load-more-security-activity');
|
'security-center': root.document.querySelector(
|
||||||
let activityCursor = null;
|
'meta[name="stackchain-feature-security-center"]'
|
||||||
const renderDevices = async () => {
|
)?.content || '',
|
||||||
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);
|
let loadingSecurityCenter = false;
|
||||||
devicesList.append(row);
|
const openSecurityCenter = async () => {
|
||||||
});
|
if (loadingSecurityCenter) return;
|
||||||
devicesStatus.textContent = devices.length ? `${devices.length} active device${devices.length === 1 ? '' : 's'}` : 'No active devices.';
|
loadingSecurityCenter = true;
|
||||||
} 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', () => {
|
|
||||||
devicesSheet.hidden = false;
|
devicesSheet.hidden = false;
|
||||||
closeDevices?.focus();
|
closeDevices?.focus();
|
||||||
renderDevices();
|
try {
|
||||||
renderPasskeys();
|
await securityFeatures.run('security-center', {
|
||||||
renderSecurityActivity();
|
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', () => {
|
if (closeDevices && devicesSheet) closeDevices.addEventListener('click', () => {
|
||||||
devicesSheet.hidden = true;
|
devicesSheet.hidden = true;
|
||||||
devicesButton?.focus();
|
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 => {
|
boundary.refreshOfflineLease().then(valid => {
|
||||||
if (valid) boundary.resumeQueuedWork();
|
if (valid) boundary.resumeQueuedWork();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ FEATURE_SOURCES = {
|
||||||
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
"pull-workflow": ("static/pull-sheet.js", "static/review-sheet.js"),
|
||||||
"push-notifications": ("static/push-notifications.js",),
|
"push-notifications": ("static/push-notifications.js",),
|
||||||
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
"device-setup": ("static/install-app.js", "static/mobile-device-setup.js"),
|
||||||
|
"security-center": ("static/security-center.js",),
|
||||||
"today-timer": (
|
"today-timer": (
|
||||||
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
"static/mobile-task-dock.js", "static/today-timer.js", "static/today-recap.js",
|
||||||
"static/today-rollover.js",
|
"static/today-rollover.js",
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ from tests.dashboard_bundle import dashboard
|
||||||
|
|
||||||
ROOT = Path(__file__).resolve().parents[1]
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
SESSION_JS = ROOT / "frontend" / "session.js"
|
SESSION_JS = ROOT / "frontend" / "session.js"
|
||||||
|
SECURITY_CENTER_JS = ROOT / "frontend" / "security-center.js"
|
||||||
|
|
||||||
|
|
||||||
def run_session_scenario(scenario: str) -> dict:
|
def run_session_scenario(scenario: str) -> dict:
|
||||||
|
|
@ -753,7 +754,7 @@ process.stdout.write(JSON.stringify(state));
|
||||||
|
|
||||||
|
|
||||||
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
||||||
source = SESSION_JS.read_text()
|
source = SECURITY_CENTER_JS.read_text()
|
||||||
|
|
||||||
alerts = source.index("page.authentication_alerts.forEach")
|
alerts = source.index("page.authentication_alerts.forEach")
|
||||||
events = source.index("page.events.forEach")
|
events = source.index("page.events.forEach")
|
||||||
|
|
@ -765,14 +766,14 @@ def test_security_activity_renders_authentication_alerts_before_journal_events()
|
||||||
|
|
||||||
|
|
||||||
def test_security_activity_explains_pending_outcome_confirmation():
|
def test_security_activity_explains_pending_outcome_confirmation():
|
||||||
source = SESSION_JS.read_text()
|
source = SECURITY_CENTER_JS.read_text()
|
||||||
|
|
||||||
assert "Outcome confirmation pending" in source
|
assert "Outcome confirmation pending" in source
|
||||||
assert "event.status === 'pending'" in source
|
assert "event.status === 'pending'" in source
|
||||||
|
|
||||||
|
|
||||||
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||||
source = SESSION_JS.read_text()
|
source = SECURITY_CENTER_JS.read_text()
|
||||||
|
|
||||||
assert "passkey_enrolled: 'Passkey enrolled'" in source
|
assert "passkey_enrolled: 'Passkey enrolled'" in source
|
||||||
assert "title.textContent = labels[event.kind]" in source
|
assert "title.textContent = labels[event.kind]" in source
|
||||||
|
|
@ -780,7 +781,7 @@ def test_security_activity_labels_passkey_enrollment_without_html_rendering():
|
||||||
|
|
||||||
|
|
||||||
def test_security_activity_labels_consequential_pull_review_decisions():
|
def test_security_activity_labels_consequential_pull_review_decisions():
|
||||||
source = SESSION_JS.read_text()
|
source = SECURITY_CENTER_JS.read_text()
|
||||||
|
|
||||||
assert "pull_review_approved: 'Pull request approved'" in source
|
assert "pull_review_approved: 'Pull request approved'" in source
|
||||||
assert "pull_review_changes_requested: 'Changes requested'" in source
|
assert "pull_review_changes_requested: 'Changes requested'" in source
|
||||||
|
|
@ -818,6 +819,6 @@ async def test_dashboard_loads_session_boundary_first_and_offers_sign_out():
|
||||||
|
|
||||||
|
|
||||||
def test_security_activity_names_confirmed_gitea_time_logging():
|
def test_security_activity_names_confirmed_gitea_time_logging():
|
||||||
source = SESSION_JS.read_text()
|
source = SECURITY_CENTER_JS.read_text()
|
||||||
|
|
||||||
assert "gitea_time_logged: 'Gitea time logged'" in source
|
assert "gitea_time_logged: 'Gitea time logged'" in source
|
||||||
|
|
|
||||||
|
|
@ -89,3 +89,22 @@ console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appen
|
||||||
"opened": 1,
|
"opened": 1,
|
||||||
"appends": 2,
|
"appends": 2,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_security_center_failure_is_accessible_and_retryable_from_active_devices():
|
||||||
|
result = run_loader("""
|
||||||
|
const trigger={disabled:false}; const status={textContent:''}; let opened=0;
|
||||||
|
const loader=createFeatureLoader({document, urls:{'security-center':'feature-security.js'}, timeoutMs:100});
|
||||||
|
const failed=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||||
|
state.node.onerror(); const first=await failed; const failedStatus=status.textContent;
|
||||||
|
const retry=loader.run('security-center',{trigger,status,retryLabel:'Tap Active devices to retry.'},()=>{opened++;});
|
||||||
|
state.node.onload(); const second=await retry;
|
||||||
|
console.log(JSON.stringify({first,second,failedStatus,opened,appends:state.appends}));
|
||||||
|
""")
|
||||||
|
assert result == {
|
||||||
|
"first": False,
|
||||||
|
"second": True,
|
||||||
|
"failedStatus": "Security center could not load. Tap Active devices to retry.",
|
||||||
|
"opened": 1,
|
||||||
|
"appends": 2,
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -49,7 +49,7 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
|
|
||||||
assert set(first.feature_bundles) == {
|
assert set(first.feature_bundles) == {
|
||||||
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
"comment-actions", "issue-capture", "pull-workflow", "push-notifications", "device-setup",
|
||||||
"today-timer",
|
"today-timer", "security-center",
|
||||||
}
|
}
|
||||||
assert (
|
assert (
|
||||||
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
f'<script src="{first.feature_bundles["today-timer"].runtime_name}"></script>\n'
|
||||||
|
|
@ -63,12 +63,19 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert b"function createReviewController" not in first.runtime_bytes
|
assert b"function createReviewController" not in first.runtime_bytes
|
||||||
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
assert b"function createPullSheet" in pull_workflow.runtime_bytes
|
||||||
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
assert b"function createReviewController" in pull_workflow.runtime_bytes
|
||||||
|
security_center = first.feature_bundles["security-center"]
|
||||||
|
assert b"function attachSecurityCenter" not in first.runtime_bytes
|
||||||
|
assert b"function attachSecurityCenter" in security_center.runtime_bytes
|
||||||
|
assert b"gitea_time_logged" not in first.runtime_bytes
|
||||||
|
assert b"gitea_time_logged" in security_center.runtime_bytes
|
||||||
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
# The recap adds only startup wiring; its UI remains in the lazy Today bundle.
|
||||||
assert len(first.runtime_gzip_bytes) <= 96 * 1024
|
assert len(first.runtime_gzip_bytes) <= 95 * 1024
|
||||||
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-issue-capture" content="{capture.runtime_name}"' in first.dashboard_html
|
||||||
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
assert f'name="stackchain-feature-pull-workflow" content="{pull_workflow.runtime_name}"' in first.dashboard_html
|
||||||
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{capture.runtime_name}'" in first.service_worker_source
|
||||||
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
assert f"BASE + '{pull_workflow.runtime_name}'" in first.service_worker_source
|
||||||
|
assert f'name="stackchain-feature-security-center" content="{security_center.runtime_name}"' in first.dashboard_html
|
||||||
|
assert f"BASE + '{security_center.runtime_name}'" in first.service_worker_source
|
||||||
|
|
||||||
changed_frontend = tmp_path / "frontend"
|
changed_frontend = tmp_path / "frontend"
|
||||||
shutil.copytree(FRONTEND, changed_frontend)
|
shutil.copytree(FRONTEND, changed_frontend)
|
||||||
|
|
@ -85,6 +92,12 @@ def test_product_workflows_are_stable_lazy_feature_chunks(tmp_path):
|
||||||
assert pull_changed.runtime_name == first.runtime_name
|
assert pull_changed.runtime_name == first.runtime_name
|
||||||
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
assert pull_changed.feature_bundles["pull-workflow"].runtime_name != pull_workflow.runtime_name
|
||||||
|
|
||||||
|
security_source = changed_frontend / "security-center.js"
|
||||||
|
security_source.write_text(security_source.read_text() + "\n// security-center-only change\n")
|
||||||
|
security_changed = build_frontend(changed_frontend)
|
||||||
|
assert security_changed.runtime_name == first.runtime_name
|
||||||
|
assert security_changed.feature_bundles["security-center"].runtime_name != security_center.runtime_name
|
||||||
|
|
||||||
|
|
||||||
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
def test_shipped_browser_bundles_are_valid_javascript(tmp_path):
|
||||||
build = build_frontend(FRONTEND)
|
build = build_frontend(FRONTEND)
|
||||||
|
|
|
||||||
49
tests/test_security_center.py
Normal file
49
tests/test_security_center.py
Normal file
|
|
@ -0,0 +1,49 @@
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
SECURITY_CENTER = Path(__file__).parents[1] / "frontend" / "security-center.js"
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_security_center_loads_all_sections_concurrently_and_is_awaitable():
|
||||||
|
harness = f"""
|
||||||
|
const attachSecurityCenter=require({json.dumps(str(SECURITY_CENTER))});
|
||||||
|
const calls=[];
|
||||||
|
const element=()=>({{
|
||||||
|
hidden:true, disabled:false, textContent:'', children:[],
|
||||||
|
addEventListener(){{}}, focus(){{this.focused=true;}},
|
||||||
|
replaceChildren(){{this.children=[];}}, append(value){{this.children.push(value);}},
|
||||||
|
}});
|
||||||
|
const ids={{
|
||||||
|
'active-devices':element(), 'active-devices-sheet':element(),
|
||||||
|
'active-devices-list':element(), 'active-devices-status':element(),
|
||||||
|
'enrolled-passkeys-list':element(), 'enrolled-passkeys-status':element(),
|
||||||
|
'enroll-passkey':element(), 'security-activity-list':element(),
|
||||||
|
'security-activity-status':element(), 'load-more-security-activity':element(),
|
||||||
|
'close-active-devices':element(),
|
||||||
|
}};
|
||||||
|
const root={{document:{{getElementById:id=>ids[id]||null,createElement:()=>element()}}}};
|
||||||
|
const boundary={{
|
||||||
|
listActiveDevices:async()=>{{calls.push('devices');return[];}},
|
||||||
|
listPasskeys:async()=>{{calls.push('passkeys');return[];}},
|
||||||
|
listSecurityEvents:async()=>{{calls.push('activity');return{{events:[],authentication_alerts:[],next_cursor:null}};}},
|
||||||
|
}};
|
||||||
|
(async()=>{{
|
||||||
|
const controller=attachSecurityCenter({{root,boundary}});
|
||||||
|
const loading=controller.open();
|
||||||
|
const awaitable=Boolean(loading&&typeof loading.then==='function');
|
||||||
|
if (loading) await loading;
|
||||||
|
console.log(JSON.stringify({{calls,awaitable,hidden:ids['active-devices-sheet'].hidden,focused:ids['close-active-devices'].focused||false}}));
|
||||||
|
}})().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) == {
|
||||||
|
"calls": ["devices", "passkeys", "activity"],
|
||||||
|
"awaitable": True,
|
||||||
|
"hidden": False,
|
||||||
|
"focused": True,
|
||||||
|
}
|
||||||
|
|
@ -661,6 +661,7 @@ def test_install_precaches_complete_subpath_scoped_app_shell():
|
||||||
"/dashboard/static/icons/stackchain-512.png",
|
"/dashboard/static/icons/stackchain-512.png",
|
||||||
"/dashboard/static/session.js",
|
"/dashboard/static/session.js",
|
||||||
"/dashboard/static/feature-loader.js",
|
"/dashboard/static/feature-loader.js",
|
||||||
|
"/dashboard/static/security-center.js",
|
||||||
"/dashboard/static/markdown.js",
|
"/dashboard/static/markdown.js",
|
||||||
"/dashboard/static/commands.js",
|
"/dashboard/static/commands.js",
|
||||||
"/dashboard/static/search-preview.js",
|
"/dashboard/static/search-preview.js",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user