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

+12
View File
@@ -165,6 +165,18 @@ if (window.VIEWER_MODE) {
const _fr = document.getElementById('filterRole');
if (_fr) { _fr.value = _scopeRole; _fr.style.display = 'none'; }
}
// If this token is user-scoped, show a locked identity badge and hide irrelevant filters.
const _scopeUserRaw = (window.VIEWER_SCOPE || {}).user;
if (_scopeUserRaw && (Array.isArray(_scopeUserRaw) ? _scopeUserRaw.length : _scopeUserRaw)) {
const _fr = document.getElementById('filterRole');
if (_fr) _fr.style.display = 'none';
const _badge = document.getElementById('viewerIdentityBadge');
if (_badge) {
_badge.textContent = (window.VIEWER_SCOPE || {}).display_name
|| (Array.isArray(_scopeUserRaw) ? _scopeUserRaw[0] : _scopeUserRaw);
_badge.style.display = '';
}
}
try { loadTrend(); } catch(e) {}
} else {
(async function() {
+195
View File
@@ -0,0 +1,195 @@
// ── Scan history browser ──────────────────────────────────────────────────────
// Lets the user load and browse results from any past scan session without
// running a new scan. Sessions are groups of concurrent M365 + Google + File
// scans (same 300-second window used by get_session_items on the server).
import { S } from './state.js';
const _SRC_LABELS = {
email: 'Outlook',
onedrive: 'OneDrive',
sharepoint: 'SharePoint',
teams: 'Teams',
gmail: 'Gmail',
gdrive: 'Google Drive',
local: 'Lokal',
smb: 'SMB',
};
let _sessions = null; // cached list; null = stale
let _latestRefScanId = null; // ref_scan_id of the newest session
// ── Session cache ─────────────────────────────────────────────────────────────
async function _fetchSessions() {
try {
const r = await fetch('/api/db/sessions');
_sessions = await r.json();
} catch(e) {
_sessions = [];
}
_latestRefScanId = _sessions.length ? _sessions[0].ref_scan_id : null;
return _sessions;
}
function invalidateHistoryCache() {
_sessions = null;
_latestRefScanId = null;
}
// ── Load a session into the results grid ──────────────────────────────────────
async function loadHistorySession(refScanId) {
// refScanId: null → latest session, positive int → specific session
let resolvedRef = refScanId;
if (resolvedRef === null) {
const sessions = _sessions !== null ? _sessions : await _fetchSessions();
if (!sessions.length) {
// No scans in DB — nothing to show
window.loadLastScanSummary?.();
return;
}
resolvedRef = sessions[0].ref_scan_id;
}
try {
const r = await fetch('/api/db/flagged?ref=' + resolvedRef);
const items = await r.json();
closeHistoryPicker();
if (!Array.isArray(items) || items.length === 0) {
S._historyRefScanId = null;
_setHistoryBanner(false);
window.loadLastScanSummary?.();
return;
}
S._historyRefScanId = resolvedRef;
S.flaggedData = items;
S.filteredData = [];
const grid = document.getElementById('grid');
const emptyState = document.getElementById('emptyState');
const lastScan = document.getElementById('lastScanSummary');
if (emptyState) emptyState.style.display = 'none';
if (lastScan) lastScan.style.display = 'none';
if (grid) { grid.innerHTML = ''; grid.style.display = 'grid'; }
window.renderGrid(items);
try { window.markOverdueCards(); } catch(_) {}
try { window.loadTrend(); } catch(_) {}
_setHistoryBanner(true, resolvedRef);
} catch(e) {
console.error('[history] failed to load session:', e);
}
}
// ── Banner ────────────────────────────────────────────────────────────────────
function _setHistoryBanner(visible, resolvedRef) {
const banner = document.getElementById('historyBanner');
const bannerTxt = document.getElementById('historyBannerText');
const latestBtn = document.getElementById('historyLatestBtn');
if (!banner) return;
if (!visible) { banner.style.display = 'none'; return; }
const sess = (_sessions || []).find(s => s.ref_scan_id === resolvedRef);
let label = '';
if (sess) {
const date = new Date(sess.started_at * 1000).toLocaleDateString(undefined,
{day: 'numeric', month: 'short', year: 'numeric'});
const time = new Date(sess.started_at * 1000).toLocaleTimeString(undefined,
{hour: '2-digit', minute: '2-digit'});
const srcStr = (sess.sources || []).map(s => _SRC_LABELS[s] || s).join(' · ');
label = date + ' ' + time
+ (srcStr ? ' · ' + srcStr : '')
+ ' · ' + sess.flagged_count + ' ' + t('history_items', 'items');
} else {
label = S.flaggedData.length + ' ' + t('history_items', 'items');
}
if (bannerTxt) bannerTxt.textContent = label;
if (latestBtn) latestBtn.style.display = (resolvedRef !== _latestRefScanId) ? '' : 'none';
banner.style.display = 'flex';
}
function exitHistoryMode() {
S._historyRefScanId = null;
const banner = document.getElementById('historyBanner');
if (banner) banner.style.display = 'none';
closeHistoryPicker();
}
// ── Session picker dropdown ───────────────────────────────────────────────────
async function openHistoryPicker() {
const drop = document.getElementById('historyDropdown');
if (!drop) return;
// Toggle
if (drop.style.display !== 'none') { drop.style.display = 'none'; return; }
drop.innerHTML = '<div style="padding:10px 12px;font-size:12px;color:var(--muted)">'
+ t('lbl_loading', 'Loading\u2026') + '</div>';
drop.style.display = '';
const sessions = _sessions !== null ? _sessions : await _fetchSessions();
if (!sessions.length) {
drop.innerHTML = '<div style="padding:12px;font-size:12px;color:var(--muted);text-align:center">'
+ t('history_picker_empty', 'No past scans') + '</div>';
return;
}
drop.innerHTML = '';
sessions.forEach((sess, i) => {
const date = new Date(sess.started_at * 1000).toLocaleDateString(undefined,
{day: 'numeric', month: 'short', year: 'numeric'});
const time = new Date(sess.started_at * 1000).toLocaleTimeString(undefined,
{hour: '2-digit', minute: '2-digit'});
const srcStr = (sess.sources || []).map(s => _SRC_LABELS[s] || s).join(' · ');
const isActive = sess.ref_scan_id === S._historyRefScanId;
const row = document.createElement('div');
row.style.cssText = 'padding:8px 12px;cursor:pointer'
+ (i < sessions.length - 1 ? ';border-bottom:1px solid var(--border)' : '')
+ (isActive ? ';background:var(--bg)' : '');
row.innerHTML =
'<div style="display:flex;align-items:center;gap:6px;margin-bottom:2px">' +
'<span style="font-size:12px;font-weight:500;color:var(--text)">' + date + '</span>' +
'<span style="font-size:10px;color:var(--muted)">' + time + '</span>' +
(sess.delta
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--muted);color:#fff;font-weight:600">'
+ t('history_delta_badge', 'Delta') + '</span>'
: '') +
(i === 0
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;font-weight:600">'
+ t('history_latest_badge', 'Latest') + '</span>'
: '') +
'</div>' +
'<div style="font-size:10px;color:var(--muted)">' +
srcStr + ' &nbsp;\u00b7&nbsp; ' + sess.flagged_count + ' ' + t('history_items', 'items') +
'</div>';
row.addEventListener('mouseenter', () => { if (!isActive) row.style.background = 'var(--surface)'; });
row.addEventListener('mouseleave', () => { row.style.background = isActive ? 'var(--bg)' : ''; });
row.addEventListener('click', () => loadHistorySession(sess.ref_scan_id));
drop.appendChild(row);
});
}
function closeHistoryPicker() {
const drop = document.getElementById('historyDropdown');
if (drop) drop.style.display = 'none';
}
// Close picker when clicking outside its container
document.addEventListener('click', e => {
const wrap = document.getElementById('historyPickerBtn')?.closest('[data-history-wrap]');
if (wrap && !wrap.contains(e.target)) closeHistoryPicker();
}, true);
// ── Window exports ────────────────────────────────────────────────────────────
window.loadHistorySession = loadHistorySession;
window.openHistoryPicker = openHistoryPicker;
window.closeHistoryPicker = closeHistoryPicker;
window.exitHistoryMode = exitHistoryMode;
window.invalidateHistoryCache = invalidateHistoryCache;
+1 -1
View File
@@ -502,7 +502,7 @@ function _sseWatchdog() {
}
if (!_initialStatusChecked) {
_initialStatusChecked = true;
if (!status.running) loadLastScanSummary();
if (!status.running) window.loadHistorySession?.(null);
}
// When no scan is running, we still keep polling — the SSE connection
// may have died and we need to detect the *next* scheduled scan.
+6 -1
View File
@@ -399,6 +399,7 @@ function _attachScanListeners(source) {
if (d.delta) checkDeltaStatus();
markOverdueCards();
loadTrend();
window.invalidateHistoryCache?.();
});
source.addEventListener('google_scan_done', function(e) {
var d = JSON.parse(e.data);
@@ -427,6 +428,7 @@ function _attachScanListeners(source) {
log('Google scan complete \u2014 ' + d.flagged_count + ' flagged of ' + d.total_scanned, 'ok');
markOverdueCards();
loadTrend();
window.invalidateHistoryCache?.();
});
source.addEventListener('file_scan_done', function(e) {
var d = JSON.parse(e.data);
@@ -452,9 +454,10 @@ function _attachScanListeners(source) {
applyFilters();
}
}
log('Bestandsscan fuldført \u2014 ' + d.flagged_count + ' flagget af ' + d.total_scanned, 'ok');
log('Bestandsscan fuldf\u00f8rt \u2014 ' + d.flagged_count + ' flagget af ' + d.total_scanned, 'ok');
markOverdueCards();
loadTrend();
window.invalidateHistoryCache?.();
});
// sse_replay_done marks end of buffer replay — log a note so the user knows
// earlier events above were replayed from an already-running scan
@@ -520,6 +523,8 @@ function startScan(resume) {
document.getElementById('statsSection').style.display = 'none';
document.getElementById('statsPill').style.display = 'none';
}
// Exit history mode — live SSE takes over
window.exitHistoryMode?.();
document.getElementById('resumeBanner').style.display = 'none';
document.getElementById('logPanel').innerHTML = '<div class="log-line log-live" id="logLive" style="display:none"></div>';
try { sessionStorage.removeItem(_LOG_SESSION_KEY); } catch(e) {}
+2
View File
@@ -28,4 +28,6 @@ export const S = {
_pendingGoogleSources: null,
// Sources
_fileSources: [],
// History browser
_historyRefScanId: null, // null = live/SSE, number = viewing a past session
};
+146 -10
View File
@@ -1,5 +1,6 @@
// ── Viewer token management (#33) ─────────────────────────────────────────────
// Share button → modal to create, copy, and revoke read-only viewer links.
import { S } from './state.js';
async function _getShareBaseUrl() {
// Use the machine's LAN IP so links work for remote users, not just localhost.
@@ -15,13 +16,126 @@ async function _getShareBaseUrl() {
return window.location.origin;
}
// ── User autocomplete for Share modal ────────────────────────────────────────
// Holds the resolved user when one is picked from the dropdown.
// Cleared on modal reset or when the input is edited manually.
let _selectedScopeUser = null; // { emails: string[], display_name: string }
let _userAcInit = false;
function _initUserAutocomplete() {
if (_userAcInit) return;
_userAcInit = true;
const input = document.getElementById('shareScopeUser');
const drop = document.getElementById('shareScopeUserDropdown');
if (!input || !drop) return;
input.addEventListener('input', () => {
_selectedScopeUser = null; // user edited manually — discard dropdown selection
_renderUserDropdown(input.value);
});
input.addEventListener('focus', () => _renderUserDropdown(input.value));
input.addEventListener('keydown', e => {
if (e.key === 'Escape') { drop.style.display = 'none'; }
if (e.key === 'ArrowDown') { e.preventDefault(); drop.querySelector('[data-uid]')?.focus(); }
});
drop.addEventListener('keydown', e => {
if (e.key === 'Escape') { drop.style.display = 'none'; input.focus(); }
if (e.key === 'ArrowDown') { e.preventDefault(); document.activeElement?.nextElementSibling?.focus(); }
if (e.key === 'ArrowUp') {
e.preventDefault();
const prev = document.activeElement?.previousElementSibling;
prev ? prev.focus() : input.focus();
}
if (e.key === 'Enter') {
const el = document.activeElement;
if (el?.dataset?.uid) _selectUser(parseInt(el.dataset.uid, 10));
}
});
document.addEventListener('click', e => {
if (!document.getElementById('shareScopeUserWrap')?.contains(e.target))
drop.style.display = 'none';
}, true);
}
function _renderUserDropdown(query) {
const drop = document.getElementById('shareScopeUserDropdown');
if (!drop) return;
const users = S._allUsers;
if (!users.length) { drop.style.display = 'none'; return; }
const q = (query || '').trim().toLowerCase();
const matches = (q
? users.filter(u =>
(u.displayName || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q) ||
(u.googleEmail || '').toLowerCase().includes(q))
: users
).slice(0, 8);
if (!matches.length) { drop.style.display = 'none'; return; }
drop.innerHTML = '';
matches.forEach((u, i) => {
const emails = [u.email, u.googleEmail].filter(Boolean);
const emailLbl = emails.join(', ');
const roleLbl = u.userRole === 'staff' ? t('share_scope_staff', 'Staff')
: u.userRole === 'student' ? t('share_scope_student', 'Students')
: '';
const row = document.createElement('div');
row.tabIndex = 0;
row.dataset.uid = i; // index into matches; resolved in _selectUser
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;cursor:pointer;font-size:12px'
+ (i < matches.length - 1 ? ';border-bottom:1px solid var(--border)' : '');
row.innerHTML =
'<div style="flex:1;min-width:0">' +
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
(u.displayName || emails[0] || '') +
(roleLbl ? ' <span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;font-weight:600">' + roleLbl + '</span>' : '') +
'</div>' +
'<div style="font-size:10px;color:var(--muted);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + emailLbl + '</div>' +
'</div>';
row.addEventListener('mouseenter', () => row.style.background = 'var(--surface)');
row.addEventListener('mouseleave', () => row.style.background = '');
row.addEventListener('focus', () => row.style.background = 'var(--surface)');
row.addEventListener('blur', () => row.style.background = '');
row.addEventListener('mousedown', e => {
e.preventDefault();
_selectUser(u);
});
drop.appendChild(row);
});
drop.style.display = '';
}
function _selectUser(u) {
const input = document.getElementById('shareScopeUser');
const drop = document.getElementById('shareScopeUserDropdown');
const emails = [u.email, u.googleEmail].filter(Boolean);
_selectedScopeUser = {
emails: emails,
display_name: u.displayName || emails[0] || '',
};
if (input) input.value = u.displayName || emails[0] || '';
if (drop) drop.style.display = 'none';
}
function _shareScopeTypeChanged() {
const type = document.getElementById('shareScopeType')?.value || '';
document.getElementById('shareScopeRoleWrap').style.display = type === 'role' ? '' : 'none';
document.getElementById('shareScopeUserWrap').style.display = type === 'user' ? '' : 'none';
if (type === 'user') _initUserAutocomplete();
}
function openShareModal() {
document.getElementById('shareBackdrop').classList.add('open');
document.getElementById('shareNewLinkRow').style.display = 'none';
document.getElementById('shareLabel').value = '';
document.getElementById('shareExpiry').value = '30';
const scopeSel = document.getElementById('shareScope');
if (scopeSel) scopeSel.value = '';
const scopeType = document.getElementById('shareScopeType');
if (scopeType) { scopeType.value = ''; _shareScopeTypeChanged(); }
_selectedScopeUser = null;
const scopeUser = document.getElementById('shareScopeUser');
if (scopeUser) scopeUser.value = '';
const scopeDrop = document.getElementById('shareScopeUserDropdown');
if (scopeDrop) scopeDrop.style.display = 'none';
_renderTokenList();
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
const el = document.getElementById('sharePinStatus');
@@ -54,17 +168,23 @@ async function _renderTokenList() {
const row = document.createElement('div');
row.style.cssText = 'display:flex;align-items:center;gap:8px;padding:6px 10px;background:var(--bg);border:1px solid var(--border);border-radius:6px;font-size:12px';
const roleVal = tok.scope?.role || '';
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Elever')
: roleVal === 'staff' ? t('share_scope_staff', 'Ansatte')
const roleLbl = roleVal === 'student' ? t('share_scope_student', 'Students')
: roleVal === 'staff' ? t('share_scope_staff', 'Staff')
: '';
const roleBadge = roleLbl
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--accent);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle">' + roleLbl + '</span>'
: '';
const userScope = tok.scope?.user;
const userLbl = tok.scope?.display_name
|| (Array.isArray(userScope) ? userScope.join(', ') : (userScope || ''));
const userBadge = userLbl
? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:var(--muted);color:#fff;margin-left:5px;font-weight:600;vertical-align:middle;max-width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;display:inline-block">' + userLbl + '</span>'
: '';
row.innerHTML =
'<div style="flex:1;min-width:0">' +
'<div style="font-weight:500;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' +
(tok.label || '<span style="color:var(--muted);font-style:italic">' + t('share_unlabelled', 'Unlabelled') + '</span>') +
roleBadge +
roleBadge + userBadge +
'</div>' +
'<div style="font-size:10px;color:var(--muted);margin-top:1px">' +
t('share_expires_prefix', 'Expires:') + ' ' + expires + ' &nbsp;·&nbsp; ' + t('share_last_used', 'Last used:') + ' ' + lastUsed +
@@ -82,12 +202,27 @@ async function _renderTokenList() {
}
async function createShareLink() {
const label = document.getElementById('shareLabel').value.trim();
const expiry = document.getElementById('shareExpiry').value;
const role = document.getElementById('shareScope')?.value || '';
const body = {label};
const label = document.getElementById('shareLabel').value.trim();
const expiry = document.getElementById('shareExpiry').value;
const scopeType = document.getElementById('shareScopeType')?.value || '';
const body = {label};
if (expiry) body.expires_days = parseInt(expiry);
if (role) body.scope = {role};
if (scopeType === 'role') {
const role = document.getElementById('shareScope')?.value || '';
if (role) body.scope = {role};
} else if (scopeType === 'user') {
if (_selectedScopeUser) {
body.scope = { user: _selectedScopeUser.emails, display_name: _selectedScopeUser.display_name };
} else {
// Manual entry fallback — treat raw input as a single email
const email = (document.getElementById('shareScopeUser')?.value || '').trim().toLowerCase();
if (!email || !email.includes('@')) {
alert(t('share_scope_user_invalid', 'Please enter a valid email address for the user scope.'));
return;
}
body.scope = { user: [email], display_name: email };
}
}
try {
const r = await fetch('/api/viewer/tokens', {
method: 'POST', headers: {'Content-Type':'application/json'},
@@ -240,6 +375,7 @@ async function stClearViewerPin() {
}
// ── Window exports ────────────────────────────────────────────────────────────
window._shareScopeTypeChanged = _shareScopeTypeChanged;
window.openShareModal = openShareModal;
window.closeShareModal = closeShareModal;
window.createShareLink = createShareLink;