feat: scan history browser, user-scoped viewer tokens, export fixes, email fixes (v1.6.20)

- Scan history browser (history.js, GET /api/db/sessions, get_sessions(),
  get_session_items(ref_scan_id)) — review any past session without rescanning
- User-scoped viewer tokens (#34) — scope by individual employee across M365
  and GWS; autocomplete from Accounts list; dual-email support
- Fix: GWS scan never marked finished (end_scan → finish_scan) and emitted
  wrong SSE event (scan_done → google_scan_done), excluding GWS items from all
  exports
- Fix: file scan begin_scan called with wrong keyword args (TypeError swallowed),
  so local/SMB items were never written to DB
- Fix: Graph sendMail reported failure on success — _post() now returns {} on
  empty 202 response instead of raising JSONDecodeError
- Fix: Graph error hidden behind generic "No SMTP host" message when both Graph
  and SMTP were unavailable
- Fix: Gmail vs Google Workspace SMTP error messages distinguished by username
  domain; Workspace errors point to admin console, not personal security settings
- Docs: update README, MANUAL-EN, MANUAL-DA, CLAUDE.md, TODO.md, CHANGELOG.md

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
StyxX65andClaude Sonnet 4.6 committed 2026-04-18 13:57:54 +02:00
1 parent e64d7eb958
commit c9aab19a97
24 files changed
+753 -70

No files matched your search

+50 -4
View File
@@ -442,17 +442,63 @@ class ScanDB:
result.append(d)
return result
def get_session_items(self, window_seconds: int = 300) -> list[dict]:
def get_sessions(self, limit: int = 50, window_seconds: int = 300) -> list[dict]:
"""Return scan sessions (groups of concurrent scans) newest-first.
Concurrent M365 + Google + File scans each get their own scan_id but start
within seconds of each other. This method groups them into logical sessions
by the same 300-second window used by get_session_items().
"""
rows = self._connect().execute(
"""SELECT id, started_at, finished_at, sources, flagged_count, total_scanned, delta
FROM scans WHERE finished_at IS NOT NULL ORDER BY started_at ASC"""
).fetchall()
# Group consecutive scans started within window_seconds of each other
groups: list[list[dict]] = []
for r in rows:
d = dict(r)
d["sources"] = json.loads(d.get("sources") or "[]")
if groups and d["started_at"] - groups[-1][0]["started_at"] <= window_seconds:
groups[-1].append(d)
else:
groups.append([d])
# Build session summaries newest-first
sessions: list[dict] = []
for grp in reversed(groups):
ref = grp[-1] # highest scan_id in group (last in ASC order)
sessions.append({
"ref_scan_id": ref["id"],
"started_at": grp[0]["started_at"],
"finished_at": ref.get("finished_at"),
"sources": list({s for g in grp for s in g["sources"]}),
"flagged_count": sum(g["flagged_count"] or 0 for g in grp),
"total_scanned": sum(g["total_scanned"] or 0 for g in grp),
"delta": any(bool(g["delta"]) for g in grp),
})
if len(sessions) >= limit:
break
return sessions
def get_session_items(self, window_seconds: int = 300,
ref_scan_id: int | None = None) -> list[dict]:
"""Return flagged items from all scans in the same session as the latest scan.
A session is all scans whose started_at is within *window_seconds* of the
most recently started completed scan. This captures concurrent M365, Google,
and file scans which each create their own scan_id but start within seconds
of each other.
If *ref_scan_id* is given, the session is anchored to that scan's started_at
instead of the latest scan.
"""
row = self._connect().execute(
"SELECT started_at FROM scans WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1"
).fetchone()
if ref_scan_id:
row = self._connect().execute(
"SELECT started_at FROM scans WHERE id=?", (ref_scan_id,)
).fetchone()
else:
row = self._connect().execute(
"SELECT started_at FROM scans WHERE finished_at IS NOT NULL ORDER BY id DESC LIMIT 1"
).fetchone()
if not row:
return []
latest_start = row[0]