Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
163bfad30a | ||
|
|
23c8e57a8e | ||
|
|
350bfc067d | ||
|
|
98aecd8255 |
No files matched your search
@@ -30,6 +30,8 @@ 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; }
|
||||
.authentication-alert { border-color:#d69e2e; background:#241b09; }
|
||||
.authentication-alert strong { color:#f6c453; }
|
||||
.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); }
|
||||
@@ -151,6 +153,12 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.my-work-card-main.review-trigger { width:100%; text-align:left; font:inherit; }
|
||||
.my-work-card:hover { border-color:var(--accent); }
|
||||
.my-work-card-title { display:block; margin:5px 0; font-weight:650; }
|
||||
.update-selection-controls { display:flex; align-items:center; gap:8px; flex-wrap:wrap; }
|
||||
.update-selection-controls button { min-height:44px; }
|
||||
.update-selector { min-height:44px; display:flex; align-items:center; gap:10px; padding:6px; border:1px solid #31577f; border-radius:8px; cursor:pointer; }
|
||||
.update-selector input { width:22px; height:22px; flex:0 0 auto; }
|
||||
.my-work-card.selection-active { border-color:#31577f; }
|
||||
.my-work-card.selected { border-color:#60a5fa; box-shadow:inset 4px 0 #60a5fa; }
|
||||
.later-actions, .today-actions { display:grid; grid-template-columns:repeat(auto-fit,minmax(120px,1fr)); gap:8px; }
|
||||
.later-actions button { min-height:44px; width:100%; }
|
||||
.today-actions button { min-height:44px; width:100%; }
|
||||
@@ -462,6 +470,7 @@ textarea { resize: vertical; min-height: 120px; }
|
||||
.work-settings:not([open]) > .work-settings-panel { display:none; }
|
||||
.work-settings-panel { display:grid; gap:10px; margin-top:8px; }
|
||||
.my-work-list { grid-template-columns:1fr; }
|
||||
.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }
|
||||
.my-work-card { min-width:0; overflow-x:hidden; }
|
||||
.card-planning > summary { min-height:44px; display:flex; align-items:center; justify-content:center; cursor:pointer; border:1px solid #60a5fa; border-radius:10px; font-weight:700; list-style:none; }
|
||||
.card-planning > summary::-webkit-details-marker { display:none; }
|
||||
|
||||
+60
-20
@@ -616,6 +616,13 @@
|
||||
},
|
||||
onStatus: message => { qs('#my-work-action-status').textContent = message; },
|
||||
});
|
||||
const notificationSelection = createNotificationSelection({
|
||||
limit: 50,
|
||||
onChange: () => {
|
||||
bulkConfirmationPending = false;
|
||||
renderMyWork();
|
||||
},
|
||||
});
|
||||
const notificationPager = createNotificationPager({
|
||||
load: fetchNotificationPage,
|
||||
onNotifications: items => {
|
||||
@@ -2086,6 +2093,8 @@
|
||||
filterMyWork(laterMyWork, 'all', selectedWorkMilestone) :
|
||||
filterMyWork(activeMyWork, selectedWorkFilter, selectedWorkMilestone);
|
||||
const incomplete = activeWorkStreams().some(stream => workPagination[stream]?.has_more);
|
||||
const selection = notificationSelection.snapshot();
|
||||
const selectedIds = new Set(selection.ids);
|
||||
qs('#my-work-list').innerHTML = visible.length ? visible.map(item => {
|
||||
const index = lastMyWork.findIndex(candidate => candidate.key === item.key && candidate.kind === item.kind);
|
||||
const routeItem = routedWorkItem(item);
|
||||
@@ -2099,7 +2108,13 @@
|
||||
(item.has_update ? ' <span class="pill">Unread update</span>' : '') +
|
||||
(item.deferred_until ? '<span class="small">Deferred until ' + escapeHtml(fmt(item.deferred_until)) + '</span>' : '') +
|
||||
(item.updated_at ? '<span class="small"> · Updated ' + escapeHtml(fmt(item.updated_at)) + '</span>' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
const selectable = selectedWorkFilter === 'update' && selection.active &&
|
||||
item.has_update && Number.isInteger(item.notification_id);
|
||||
const selector = selectable ?
|
||||
'<label class="update-selector"><input type="checkbox" data-select-notification-id="' + item.notification_id + '" aria-label="Select update ' + escAttr(item.key + ' ' + item.title) + '"' + (selectedIds.has(item.notification_id) ? ' checked' : '') + '> Select</label>' : '';
|
||||
const cardClasses = 'my-work-card' + (selectable ? ' selection-active' : '') +
|
||||
(selectedIds.has(item.notification_id) ? ' selected' : '');
|
||||
const markRead = item.has_update && Number.isInteger(item.notification_id) && !selection.active ?
|
||||
'<button class="mark-update-read" data-notification-id="' + item.notification_id + '">Mark read</button>' : '';
|
||||
const readUpdate = item.has_update && Number.isInteger(item.notification_id) ?
|
||||
'<a class="read-update" href="' + escAttr(createWorkRoute.serialize({ kind:'update', notification_id:item.notification_id })) + '" data-update-index="' + index + '">Read update</a>' : '';
|
||||
@@ -2115,19 +2130,30 @@
|
||||
const planningActions = selectedWorkFilter === 'later' ? laterActions :
|
||||
'<details class="card-planning" data-card-planning><summary aria-expanded="false">Plan or defer</summary><div class="card-planning-actions">' + todayActions + laterActions + '</div></details>';
|
||||
if (item.is_review) {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main review-trigger" href="' + escAttr(routeHref) + '" data-review-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'issue') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main issue-trigger" href="' + escAttr(routeHref) + '" data-issue-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
if (item.kind === 'pull') {
|
||||
return '<article class="my-work-card"><a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main pull-trigger" href="' + escAttr(routeHref) + '" data-pull-index="' + index + '">' + contents + '</a>' + readUpdate + markRead + planningActions + '</article>';
|
||||
}
|
||||
return '<article class="my-work-card"><a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||||
return '<article class="' + cardClasses + '">' + selector + '<a class="my-work-card-main update-trigger" href="' + escAttr(routeHref) + '" data-update-index="' + index + '">' + contents + '</a>' + markRead + planningActions + '</article>';
|
||||
}).join('') : '<div class="muted">' + (incomplete ?
|
||||
'More work is available. Load the next page.' :
|
||||
'No ' + (selectedWorkFilter === 'attention' ? 'items need attention' : (selectedWorkFilter === 'review' ? 'reviews' : (selectedWorkFilter === 'update' ? 'unread updates' : (selectedWorkFilter === 'later' ? 'deferred work' : (selectedWorkFilter === 'all' ? 'work' : selectedWorkFilter + ' items'))))) + '.') + '</div>';
|
||||
cardPlanning.wire();
|
||||
document.querySelectorAll('[data-select-notification-id]').forEach(input => {
|
||||
input.addEventListener('change', () => {
|
||||
const notificationId = Number(input.dataset.selectNotificationId);
|
||||
const result = input.checked ? notificationSelection.select(notificationId) :
|
||||
notificationSelection.toggle(notificationId);
|
||||
if (result === 'limit') {
|
||||
input.checked = false;
|
||||
qs('#my-work-action-status').textContent = 'Select up to 50 updates per batch.';
|
||||
}
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('[data-review-index]').forEach(button => {
|
||||
button.addEventListener('click', event => { event.preventDefault(); openRoutedWork(lastMyWork[Number(button.dataset.reviewIndex)], button); });
|
||||
});
|
||||
@@ -2246,20 +2272,22 @@
|
||||
document.querySelector('[data-today-move="' + button.dataset.todayMove + '"][data-work-index="' + button.dataset.workIndex + '"]')?.focus();
|
||||
});
|
||||
});
|
||||
const allIds = notificationIds(visible);
|
||||
const ids = allIds.slice(0, Math.min(lastNotifications.length, 50));
|
||||
const bulkBar = qs('#bulk-mark-read-bar');
|
||||
const bulkButton = qs('#bulk-mark-read');
|
||||
bulkBar.hidden = selectedWorkFilter !== 'update' || ids.length === 0;
|
||||
const updateIds = notificationIds(visible);
|
||||
const selectionControls = qs('#update-selection-controls');
|
||||
selectionControls.hidden = selectedWorkFilter !== 'update' || updateIds.length === 0;
|
||||
qs('#select-updates').hidden = selection.active;
|
||||
qs('#cancel-update-selection').hidden = !selection.active;
|
||||
qs('#update-selection-status').textContent = selection.active ?
|
||||
selection.count + ' of 50 selected' : 'Choose specific updates to keep important work unread.';
|
||||
bulkBar.hidden = selectedWorkFilter !== 'update' || !selection.active;
|
||||
qs('#load-more-notifications').hidden =
|
||||
selectedWorkFilter !== 'update' || !notificationPagination.has_more;
|
||||
bulkButton.disabled = bulkMarkPending;
|
||||
const bulkLabel = allIds.length > ids.length ?
|
||||
'next ' + ids.length + ' of ' + allIds.length + ' loaded updates' :
|
||||
'all ' + ids.length + ' updates';
|
||||
bulkButton.disabled = bulkMarkPending || selection.count === 0;
|
||||
bulkButton.textContent = bulkConfirmationPending ?
|
||||
'Confirm marking ' + bulkLabel + ' read' :
|
||||
'Mark ' + bulkLabel + ' read';
|
||||
'Confirm marking ' + selection.count + ' selected read' :
|
||||
'Mark ' + selection.count + ' selected read';
|
||||
workRoute.setItems(lastMyWork);
|
||||
}
|
||||
|
||||
@@ -5161,32 +5189,44 @@
|
||||
button.disabled = false;
|
||||
}
|
||||
});
|
||||
qs('#select-updates').addEventListener('click', () => {
|
||||
notificationSelection.start();
|
||||
qs('#update-selection-status').textContent = '0 of 50 selected';
|
||||
document.querySelector('[data-select-notification-id]')?.focus();
|
||||
});
|
||||
qs('#cancel-update-selection').addEventListener('click', () => {
|
||||
notificationSelection.cancel();
|
||||
qs('#select-updates').focus();
|
||||
});
|
||||
qs('#bulk-mark-read').addEventListener('click', async () => {
|
||||
const allIds = notificationIds(filterMyWork(lastMyWork, 'update'));
|
||||
const ids = allIds.slice(0, 50);
|
||||
if (!ids.length || bulkMarkPending) return;
|
||||
const selection = notificationSelection.snapshot();
|
||||
if (!selection.ids.length || bulkMarkPending) return;
|
||||
if (!bulkConfirmationPending) {
|
||||
bulkConfirmationPending = true;
|
||||
qs('#my-work-action-status').textContent = 'Confirm to mark all visible updates read.';
|
||||
qs('#my-work-action-status').textContent = 'Confirm to mark only the selected updates read.';
|
||||
renderMyWork();
|
||||
return;
|
||||
}
|
||||
bulkConfirmationPending = false;
|
||||
bulkMarkPending = true;
|
||||
renderMyWork();
|
||||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, ids);
|
||||
const result = await bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids);
|
||||
if (result) {
|
||||
const marked = new Set(result.marked);
|
||||
lastNotifications = lastNotifications.filter(item => !marked.has(item.id));
|
||||
notificationSelection.retain(result.failed);
|
||||
if (!result.failed.length) notificationSelection.cancel();
|
||||
}
|
||||
bulkMarkPending = false;
|
||||
renderMyWork();
|
||||
(document.querySelector('[data-notification-id]') || qs('[data-work-filter="update"]'))?.focus();
|
||||
(document.querySelector('[data-select-notification-id]') || qs('#select-updates'))?.focus();
|
||||
});
|
||||
document.querySelectorAll('[data-work-filter]').forEach(button => {
|
||||
button.setAttribute('aria-pressed', String(button.dataset.workFilter === selectedWorkFilter));
|
||||
button.addEventListener('click', () => {
|
||||
const leavingUpdates = selectedWorkFilter === 'update' && button.dataset.workFilter !== 'update';
|
||||
selectedWorkFilter = button.dataset.workFilter;
|
||||
if (leavingUpdates && notificationSelection.snapshot().active) notificationSelection.cancel();
|
||||
savedWorkFilter = selectedWorkFilter;
|
||||
launchFilterResolved = true;
|
||||
try {
|
||||
|
||||
@@ -116,6 +116,11 @@
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
<div class="update-selection-controls" id="update-selection-controls" hidden>
|
||||
<button id="select-updates" type="button">Select updates</button>
|
||||
<button id="cancel-update-selection" type="button" hidden>Cancel selection</button>
|
||||
<span class="small" id="update-selection-status" role="status" aria-live="polite"></span>
|
||||
</div>
|
||||
<div class="my-work-list" id="my-work-list"></div>
|
||||
<div class="small" id="work-page-status" aria-live="polite"></div>
|
||||
<button class="load-more-work" id="load-more-work" type="button" hidden>Load older work</button>
|
||||
|
||||
@@ -178,6 +178,62 @@ function createBulkNotificationAcknowledger({ markRead, onItems, onStatus }) {
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationSelection({ limit = 50, onChange = () => {} } = {}) {
|
||||
const maximum = Number.isInteger(limit) && limit > 0 ? limit : 50;
|
||||
let active = false;
|
||||
const selected = new Set();
|
||||
const snapshot = () => ({
|
||||
active,
|
||||
ids: Array.from(selected),
|
||||
count: selected.size,
|
||||
limit: maximum,
|
||||
at_limit: selected.size >= maximum,
|
||||
});
|
||||
const publish = () => onChange(snapshot());
|
||||
const select = notificationId => {
|
||||
if (!active || !Number.isInteger(notificationId)) return 'inactive';
|
||||
if (selected.has(notificationId)) return 'already-selected';
|
||||
if (selected.size >= maximum) return 'limit';
|
||||
selected.add(notificationId);
|
||||
publish();
|
||||
return 'selected';
|
||||
};
|
||||
return {
|
||||
start() {
|
||||
if (active) return snapshot();
|
||||
active = true;
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
select,
|
||||
toggle(notificationId) {
|
||||
if (!selected.has(notificationId)) return select(notificationId);
|
||||
selected.delete(notificationId);
|
||||
publish();
|
||||
return 'deselected';
|
||||
},
|
||||
retain(notificationIds) {
|
||||
const allowed = new Set((notificationIds || []).filter(Number.isInteger));
|
||||
let changed = false;
|
||||
selected.forEach(id => {
|
||||
if (!allowed.has(id)) {
|
||||
selected.delete(id);
|
||||
changed = true;
|
||||
}
|
||||
});
|
||||
if (changed) publish();
|
||||
return snapshot();
|
||||
},
|
||||
cancel() {
|
||||
active = false;
|
||||
selected.clear();
|
||||
publish();
|
||||
return snapshot();
|
||||
},
|
||||
snapshot,
|
||||
};
|
||||
}
|
||||
|
||||
function createNotificationPager({ load, onNotifications, onPagination, onStatus }) {
|
||||
let pagination = { page: 1, total: 0, has_more: false };
|
||||
let pending = false;
|
||||
@@ -782,6 +838,7 @@ if (typeof module !== 'undefined' && module.exports) {
|
||||
buildMyWork.createNotificationAcknowledger = createNotificationAcknowledger;
|
||||
buildMyWork.notificationIds = notificationIds;
|
||||
buildMyWork.createBulkNotificationAcknowledger = createBulkNotificationAcknowledger;
|
||||
buildMyWork.createNotificationSelection = createNotificationSelection;
|
||||
buildMyWork.createNotificationPager = createNotificationPager;
|
||||
buildMyWork.createWorkPager = createWorkPager;
|
||||
buildMyWork.createNotificationReader = createNotificationReader;
|
||||
|
||||
@@ -142,6 +142,20 @@
|
||||
}
|
||||
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',
|
||||
@@ -665,6 +679,21 @@
|
||||
return response.json();
|
||||
}
|
||||
|
||||
function formatAuthenticationAlert(alert) {
|
||||
const failed = Math.max(0, Number(alert?.failed_count) || 0);
|
||||
const blocked = Math.max(0, Number(alert?.blocked_count) || 0);
|
||||
const method = alert?.method === 'passkey' ? 'passkey' : 'token';
|
||||
const title = `${failed} failed ${method} sign-in${failed === 1 ? '' : 's'}`
|
||||
+ (blocked ? ` · ${blocked} blocked` : '');
|
||||
const formatTime = value => new Date(Number(value) * 1000).toLocaleString(
|
||||
'en-US', { timeZone: 'UTC' },
|
||||
);
|
||||
return {
|
||||
title,
|
||||
detail: `${formatTime(alert?.first_at)} – ${formatTime(alert?.last_at)}`,
|
||||
};
|
||||
}
|
||||
|
||||
async function listSecurityEvents(cursor = null) {
|
||||
const query = new URLSearchParams({ limit: '25' });
|
||||
if (Number.isInteger(cursor) && cursor > 0) query.set('cursor', String(cursor));
|
||||
@@ -673,6 +702,8 @@
|
||||
const payload = await response.json();
|
||||
return {
|
||||
events: Array.isArray(payload.events) ? payload.events : [],
|
||||
authentication_alerts: Array.isArray(payload.authentication_alerts)
|
||||
? payload.authentication_alerts : [],
|
||||
next_cursor: Number.isInteger(payload.next_cursor) ? payload.next_cursor : null,
|
||||
};
|
||||
}
|
||||
@@ -722,6 +753,7 @@
|
||||
listActiveDevices,
|
||||
listPasskeys,
|
||||
listSecurityEvents,
|
||||
formatAuthenticationAlert,
|
||||
enrollPasskey,
|
||||
revokeActiveDevice,
|
||||
revokePasskey,
|
||||
|
||||
@@ -49,6 +49,9 @@ class LoginAttemptStore:
|
||||
window_seconds: int,
|
||||
max_entries: int = 10_000,
|
||||
lock_timeout_seconds: float = 0.1,
|
||||
alert_bucket_seconds: int = 60 * 60,
|
||||
alert_retention_seconds: int = 30 * 24 * 60 * 60,
|
||||
max_alert_buckets: int = 720,
|
||||
) -> None:
|
||||
self.path = Path(path)
|
||||
self.clock = clock
|
||||
@@ -56,6 +59,9 @@ class LoginAttemptStore:
|
||||
self.window_seconds = max(1, window_seconds)
|
||||
self.max_entries = max(1, max_entries)
|
||||
self.lock_timeout_seconds = lock_timeout_seconds
|
||||
self.alert_bucket_seconds = max(1, alert_bucket_seconds)
|
||||
self.alert_retention_seconds = max(1, alert_retention_seconds)
|
||||
self.max_alert_buckets = max(1, max_alert_buckets)
|
||||
|
||||
@staticmethod
|
||||
def _digest(source: str) -> str:
|
||||
@@ -85,6 +91,19 @@ class LoginAttemptStore:
|
||||
)
|
||||
"""
|
||||
)
|
||||
connection.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS login_alerts (
|
||||
method TEXT NOT NULL,
|
||||
bucket_started_at INTEGER NOT NULL,
|
||||
failed_count INTEGER NOT NULL DEFAULT 0,
|
||||
blocked_count INTEGER NOT NULL DEFAULT 0,
|
||||
first_at INTEGER NOT NULL,
|
||||
last_at INTEGER NOT NULL,
|
||||
PRIMARY KEY (method, bucket_started_at)
|
||||
)
|
||||
"""
|
||||
)
|
||||
return connection
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
@@ -154,7 +173,35 @@ class LoginAttemptStore:
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def record_failure(self, source: str) -> None:
|
||||
def _record_alert(
|
||||
self, connection: sqlite3.Connection, method: str, *, blocked: bool, now: float
|
||||
) -> None:
|
||||
safe_method = method if method in {"token", "passkey"} else "unknown"
|
||||
occurred_at = int(now)
|
||||
bucket = occurred_at - (occurred_at % self.alert_bucket_seconds)
|
||||
failed = 0 if blocked else 1
|
||||
blocked_count = 1 if blocked else 0
|
||||
connection.execute(
|
||||
"""
|
||||
INSERT INTO login_alerts(
|
||||
method, bucket_started_at, failed_count, blocked_count, first_at, last_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(method, bucket_started_at) DO UPDATE SET
|
||||
failed_count = failed_count + excluded.failed_count,
|
||||
blocked_count = blocked_count + excluded.blocked_count,
|
||||
first_at = MIN(first_at, excluded.first_at),
|
||||
last_at = MAX(last_at, excluded.last_at)
|
||||
""",
|
||||
(safe_method, bucket, failed, blocked_count, occurred_at, occurred_at),
|
||||
)
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE (method, bucket_started_at) NOT IN "
|
||||
"(SELECT method, bucket_started_at FROM login_alerts "
|
||||
"ORDER BY bucket_started_at DESC LIMIT ?)",
|
||||
(self.max_alert_buckets,),
|
||||
)
|
||||
|
||||
def record_failure(self, source: str, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
source_hash = self._digest(source)
|
||||
try:
|
||||
@@ -188,11 +235,52 @@ class LoginAttemptStore:
|
||||
""",
|
||||
(self.max_entries,),
|
||||
)
|
||||
self._record_alert(connection, method, blocked=False, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def record_blocked(self, *, method: str = "token") -> None:
|
||||
now = self.clock()
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute("BEGIN IMMEDIATE")
|
||||
self._record_alert(connection, method, blocked=True, now=now)
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
|
||||
def list_alerts(self, *, limit: int = 24) -> list[dict[str, int | str]]:
|
||||
bounded_limit = min(100, max(1, limit))
|
||||
now = int(self.clock())
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
connection.execute(
|
||||
"DELETE FROM login_alerts WHERE last_at < ?",
|
||||
(now - self.alert_retention_seconds,),
|
||||
)
|
||||
rows = connection.execute(
|
||||
"SELECT method, failed_count, blocked_count, first_at, last_at "
|
||||
"FROM login_alerts ORDER BY bucket_started_at DESC LIMIT ?",
|
||||
(bounded_limit,),
|
||||
).fetchall()
|
||||
except (OSError, sqlite3.Error) as exc:
|
||||
raise LoginAttemptStoreError(
|
||||
"Sign-in throttling is temporarily unavailable"
|
||||
) from exc
|
||||
return [
|
||||
{
|
||||
"method": row[0],
|
||||
"failed_count": row[1],
|
||||
"blocked_count": row[2],
|
||||
"first_at": row[3],
|
||||
"last_at": row[4],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
def clear(self, source: str) -> None:
|
||||
try:
|
||||
with self._connect() as connection:
|
||||
|
||||
+13
-1
@@ -947,6 +947,14 @@ async def sign_in(payload: DashboardSignIn, request: Request, response: Response
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
if retry_after:
|
||||
try:
|
||||
await asyncio.to_thread(attempts.record_blocked, method="token")
|
||||
except LoginAttemptStoreError:
|
||||
return JSONResponse(
|
||||
{"detail": "Sign-in throttling is temporarily unavailable"},
|
||||
status_code=503,
|
||||
headers={"Cache-Control": "no-store"},
|
||||
)
|
||||
return JSONResponse(
|
||||
{"detail": "Too many sign-in attempts"},
|
||||
status_code=429,
|
||||
@@ -1034,7 +1042,10 @@ async def list_security_events(
|
||||
page = await asyncio.to_thread(
|
||||
_security_event_store().list, limit=limit, cursor=cursor
|
||||
)
|
||||
except SecurityEventStoreError:
|
||||
authentication_alerts = await asyncio.to_thread(
|
||||
_login_attempt_store().list_alerts, limit=24
|
||||
)
|
||||
except (SecurityEventStoreError, LoginAttemptStoreError):
|
||||
raise HTTPException(
|
||||
status_code=503, detail="Security activity is temporarily unavailable"
|
||||
)
|
||||
@@ -1052,6 +1063,7 @@ async def list_security_events(
|
||||
}
|
||||
for event in page.events
|
||||
],
|
||||
"authentication_alerts": authentication_alerts,
|
||||
"next_cursor": page.next_cursor,
|
||||
},
|
||||
headers={"Cache-Control": "no-store"},
|
||||
|
||||
@@ -825,6 +825,44 @@ async def test_sign_in_throttles_repeated_failures_with_retry_guidance(access_co
|
||||
assert blocked.headers["cache-control"] == "no-store"
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_security_activity_surfaces_failed_and_blocked_token_sign_ins(access_control):
|
||||
attacker_transport = httpx.ASGITransport(app=main.app, client=("203.0.113.7", 1234))
|
||||
operator_transport = httpx.ASGITransport(app=main.app, client=("198.51.100.4", 1234))
|
||||
async with httpx.AsyncClient(
|
||||
transport=attacker_transport, base_url="https://test"
|
||||
) as attacker:
|
||||
failures = [
|
||||
await attacker.post(
|
||||
"/api/v1/session",
|
||||
json={"access_token": "wrong", "device_label": "Unknown"},
|
||||
)
|
||||
for _ in range(4)
|
||||
]
|
||||
async with httpx.AsyncClient(
|
||||
transport=operator_transport, base_url="https://test"
|
||||
) as operator:
|
||||
signed_in = await operator.post(
|
||||
"/api/v1/session",
|
||||
json={
|
||||
"access_token": "correct horse battery staple",
|
||||
"device_label": "Phone",
|
||||
},
|
||||
)
|
||||
activity = await operator.get("/api/v1/security-events")
|
||||
|
||||
assert [response.status_code for response in failures] == [401, 401, 401, 429]
|
||||
assert signed_in.status_code == 200
|
||||
assert activity.status_code == 200
|
||||
assert activity.headers["cache-control"] == "no-store"
|
||||
alerts = activity.json()["authentication_alerts"]
|
||||
assert len(alerts) == 1
|
||||
assert alerts[0]["method"] == "token"
|
||||
assert alerts[0]["failed_count"] == 3
|
||||
assert alerts[0]["blocked_count"] == 1
|
||||
assert alerts[0]["first_at"] <= alerts[0]["last_at"]
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_successful_sign_in_clears_prior_failures(access_control):
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.8", 1234))
|
||||
@@ -1555,6 +1593,9 @@ async def test_sign_in_throttle_lookup_does_not_block_the_event_loop(
|
||||
time.sleep(0.15)
|
||||
return 17
|
||||
|
||||
def record_blocked(self, *, method):
|
||||
assert method == "token"
|
||||
|
||||
monkeypatch.setattr(main, "_login_attempt_store", lambda: SlowAttempts())
|
||||
transport = httpx.ASGITransport(app=main.app, client=("203.0.113.10", 1234))
|
||||
async with httpx.AsyncClient(transport=transport, base_url="https://test") as client:
|
||||
|
||||
@@ -736,6 +736,34 @@ process.stdout.write(JSON.stringify(state));
|
||||
]
|
||||
|
||||
|
||||
def test_security_activity_formats_failed_sign_in_alerts_for_mobile_scanability():
|
||||
result = run_session_scenario(
|
||||
"""
|
||||
state.formatted = boundary.formatAuthenticationAlert({
|
||||
method:'token', failed_count:12, blocked_count:3, first_at:1000, last_at:1060
|
||||
});
|
||||
process.stdout.write(JSON.stringify(state));
|
||||
"""
|
||||
)
|
||||
|
||||
assert result["formatted"] == {
|
||||
"title": "12 failed token sign-ins · 3 blocked",
|
||||
"detail": "1/1/1970, 12:16:40 AM – 1/1/1970, 12:17:40 AM",
|
||||
}
|
||||
|
||||
|
||||
def test_security_activity_renders_authentication_alerts_before_journal_events():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
alerts = source.index("page.authentication_alerts.forEach")
|
||||
events = source.index("page.events.forEach")
|
||||
assert alerts < events
|
||||
assert "boundary.formatAuthenticationAlert(alert)" in source
|
||||
assert "row.className = 'security-event authentication-alert'" in source
|
||||
css = (ROOT / "frontend" / "dashboard.css").read_text()
|
||||
assert ".authentication-alert { border-color:#d69e2e;" in css
|
||||
|
||||
|
||||
def test_security_activity_explains_pending_outcome_confirmation():
|
||||
source = SESSION_JS.read_text()
|
||||
|
||||
|
||||
@@ -74,6 +74,100 @@ def test_failure_ledger_evicts_oldest_sources_at_its_size_limit(tmp_path):
|
||||
assert connection.execute("SELECT COUNT(*) FROM login_attempts").fetchone() == (2,)
|
||||
|
||||
|
||||
def test_failed_sign_ins_are_aggregated_across_workers_and_survive_throttle_clear(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
first = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=300,
|
||||
)
|
||||
second = LoginAttemptStore(
|
||||
database,
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=300,
|
||||
)
|
||||
|
||||
first.record_failure("203.0.113.7", method="token")
|
||||
now[0] = 1_010.0
|
||||
second.record_failure("198.51.100.4", method="token")
|
||||
first.clear("203.0.113.7")
|
||||
|
||||
assert second.list_alerts() == [
|
||||
{
|
||||
"method": "token",
|
||||
"failed_count": 2,
|
||||
"blocked_count": 0,
|
||||
"first_at": 1_000,
|
||||
"last_at": 1_010,
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
def test_rate_blocked_sign_ins_increment_a_distinct_privacy_safe_count(tmp_path):
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: 1_000.0,
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
)
|
||||
|
||||
store.record_blocked(method="token")
|
||||
|
||||
assert store.list_alerts() == [
|
||||
{
|
||||
"method": "token",
|
||||
"failed_count": 0,
|
||||
"blocked_count": 1,
|
||||
"first_at": 1_000,
|
||||
"last_at": 1_000,
|
||||
}
|
||||
]
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
columns = [row[1] for row in connection.execute("PRAGMA table_info(login_alerts)")]
|
||||
assert "source_hash" not in columns
|
||||
|
||||
|
||||
def test_expired_sign_in_alert_buckets_are_not_returned(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_retention_seconds=300,
|
||||
)
|
||||
store.record_failure("203.0.113.7")
|
||||
|
||||
now[0] = 1_301.0
|
||||
|
||||
assert store.list_alerts() == []
|
||||
|
||||
|
||||
def test_sign_in_alert_bucket_count_is_bounded_during_rotating_source_floods(tmp_path):
|
||||
now = [1_000.0]
|
||||
store = LoginAttemptStore(
|
||||
tmp_path / "login-attempts.sqlite3",
|
||||
clock=lambda: now[0],
|
||||
max_failures=3,
|
||||
window_seconds=60,
|
||||
alert_bucket_seconds=60,
|
||||
max_alert_buckets=2,
|
||||
)
|
||||
for index, timestamp in enumerate((1_000.0, 1_060.0, 1_120.0)):
|
||||
now[0] = timestamp
|
||||
store.record_failure(f"203.0.113.{index}")
|
||||
|
||||
with sqlite3.connect(store.path) as connection:
|
||||
count = connection.execute("SELECT COUNT(*) FROM login_alerts").fetchone()[0]
|
||||
|
||||
assert count == 2
|
||||
|
||||
|
||||
def test_named_admission_budget_is_atomic_across_instances_and_source_scoped(tmp_path):
|
||||
now = [1_000.0]
|
||||
database = tmp_path / "login-attempts.sqlite3"
|
||||
|
||||
+79
-8
@@ -3312,6 +3312,68 @@ Promise.all([first, duplicate]).then(results => process.stdout.write(JSON.string
|
||||
]
|
||||
|
||||
|
||||
def test_notification_selection_tracks_a_bounded_subset_and_clears_on_cancel():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const selection = buildMyWork.createNotificationSelection({{
|
||||
limit: 2,
|
||||
onChange: state => states.push(state),
|
||||
}});
|
||||
selection.start();
|
||||
const first = selection.select(42);
|
||||
const duplicate = selection.select(42);
|
||||
selection.toggle(42);
|
||||
selection.select(43);
|
||||
selection.select(44);
|
||||
const second = selection.select(45);
|
||||
selection.cancel();
|
||||
process.stdout.write(JSON.stringify({{first, duplicate, second, states, snapshot:selection.snapshot()}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["first"] == "selected"
|
||||
assert output["duplicate"] == "already-selected"
|
||||
assert output["second"] == "limit"
|
||||
assert output["states"] == [
|
||||
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [42], "count": 1, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [43], "count": 1, "limit": 2, "at_limit": False},
|
||||
{"active": True, "ids": [43, 44], "count": 2, "limit": 2, "at_limit": True},
|
||||
{"active": False, "ids": [], "count": 0, "limit": 2, "at_limit": False},
|
||||
]
|
||||
assert output["snapshot"] == output["states"][-1]
|
||||
|
||||
|
||||
def test_notification_selection_retains_only_failed_ids_after_partial_acknowledgement():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
const states = [];
|
||||
const selection = buildMyWork.createNotificationSelection({{
|
||||
onChange: state => states.push(state),
|
||||
}});
|
||||
selection.start();
|
||||
selection.select(42);
|
||||
selection.select(43);
|
||||
selection.select(44);
|
||||
const retained = selection.retain([44, 44, 99]);
|
||||
process.stdout.write(JSON.stringify({{retained, states}}));
|
||||
"""
|
||||
result = subprocess.run(
|
||||
["node", "-e", script], check=True, capture_output=True, text=True
|
||||
)
|
||||
output = json.loads(result.stdout)
|
||||
|
||||
assert output["retained"] == {
|
||||
"active": True, "ids": [44], "count": 1, "limit": 50, "at_limit": False,
|
||||
}
|
||||
assert output["states"][-1] == output["retained"]
|
||||
|
||||
|
||||
def test_notification_pager_is_single_flight_and_merges_unique_updates():
|
||||
script = f"""
|
||||
const buildMyWork = require({json.dumps(str(MY_WORK))});
|
||||
@@ -4555,18 +4617,26 @@ async def test_mobile_update_sheet_has_persistent_accessible_reply_composer():
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_updates_view_offers_confirmed_sticky_mobile_bulk_acknowledgement():
|
||||
async def test_updates_view_offers_accessible_sticky_mobile_subset_selection():
|
||||
html = await dashboard()
|
||||
|
||||
assert 'id="bulk-mark-read"' in html
|
||||
assert 'id="bulk-mark-read-bar"' in html
|
||||
assert 'class="my-work-bulk"' in html
|
||||
assert 'id="select-updates"' in html
|
||||
assert 'id="cancel-update-selection"' in html
|
||||
assert 'id="update-selection-status"' in html
|
||||
assert 'aria-live="polite"' in html
|
||||
assert 'class="update-selector"' in html
|
||||
assert 'type="checkbox"' in html
|
||||
assert 'Select update ' in html
|
||||
assert '.update-selector { min-height:44px;' in html
|
||||
assert '.my-work-bulk { position:sticky;' in html
|
||||
assert 'padding-bottom:calc(10px + env(safe-area-inset-bottom));' in html
|
||||
assert '.my-work-bulk button { min-height:44px; width:100%; }' in html
|
||||
assert "'next ' + ids.length + ' of ' + allIds.length + ' loaded updates'" in html
|
||||
assert "'Confirm marking ' + bulkLabel + ' read'" in html
|
||||
assert "const ids = allIds.slice(0, 50)" in html
|
||||
assert '.my-work-bulk { bottom:calc(56px + env(safe-area-inset-bottom)); }' in html
|
||||
assert "'Mark ' + selection.count + ' selected read'" in html
|
||||
assert "'Confirm marking ' + selection.count + ' selected read'" in html
|
||||
assert 'bulkNotificationAcknowledger.acknowledge(lastMyWork, selection.ids)' in html
|
||||
assert 'notificationSelection.retain(result.failed)' in html
|
||||
assert "notificationSelection.cancel()" in html
|
||||
assert "createBulkNotificationAcknowledger" in html
|
||||
assert "api/v1/notifications/read" in html
|
||||
assert "body: JSON.stringify({ ids })" in html
|
||||
@@ -4583,7 +4653,8 @@ async def test_updates_view_discloses_incomplete_inbox_and_loads_more_on_mobile(
|
||||
assert "api/v1/notifications?page=" in html
|
||||
assert "snapshot.notification_pagination" in html
|
||||
assert "notificationPager.loadMore(lastNotifications)" in html
|
||||
assert "Math.min(lastNotifications.length, 50)" in html
|
||||
assert "createNotificationSelection({" in html
|
||||
assert "limit: 50" in html
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
||||
@@ -53,6 +53,7 @@ async def test_authenticated_security_activity_lists_private_sign_in_history(sec
|
||||
"status": "completed",
|
||||
}
|
||||
],
|
||||
"authentication_alerts": [],
|
||||
"next_cursor": None,
|
||||
}
|
||||
persisted = (security_access / "security.sqlite3").read_bytes()
|
||||
|
||||
Reference in new issue
Block a user