Initial commit

This commit is contained in:
Henrik Højmark committed 2026-04-11 04:38:11 +02:00
commit 9c7df76fbd
91 files changed
+36572

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
# static/js — JS Rules
## Profile dropdown — loader model
Profiles are **loaders**, not persistent modes. Selecting one pushes settings into the sidebar; the sidebar is always the live state.
- `_setProfileClearBtn(visible)` must be called alongside every assignment to `S._activeProfileId`.
- **Do not re-add a selectable `value=""` option to `#profileSelect`** — deliberately removed in v1.6.6.
## Profile editor source panel race condition
`_pmgmtSaveFullEdit` detects whether Google/file checkboxes have rendered by querying the DOM directly:
```javascript
const googleRendered = !!document.querySelector('#peSourcesPanel input[data-source-type="google"]');
const fileRendered = !!document.querySelector('#peSourcesPanel input[data-source-type="file"]');
```
Never revert to `!!window._googleConnected` / `_fileSources.length > 0` — those async proxies can be `true` before the panel has rendered, silently clearing the user's source selection on save.
## Progress bar phase parsing
`_setProgressPhase(phase)` in `scan.js` parses the phase string against `_PHASE_SOURCE_MAP`:
1. Source found **and** ` — ` (em-dash) present → split, resolve via `_resolveDisplayName()`, update `S._progressCurrentUser`.
2. Source found **but no dash** → show pill + `S._progressCurrentUser` (handles sub-phases like folder counts).
3. No source match → plain text fallback.
`_PHASE_SOURCE_MAP` ordering matters — `Google Workspace` must appear before `Gmail` in the map. The email regex uses `/iu` flags — do not drop the `i`.
## Gotchas
- **Profile editor accounts** — default to unchecked. Only explicitly saved `user_ids` are checked.
- **Date presets** — stored as `years * 365` (integer days). Do not use `* 365.25`.
+198
View File
@@ -0,0 +1,198 @@
import { S } from './state.js';
// ── Auth ─────────────────────────────────────────────────────────────────────
function handleSignIn() {
try {
startAuth().catch(function(e) {
alert('Sign-in error: ' + (e.message || String(e)));
});
} catch(e) {
alert('Sign-in error: ' + (e.message || String(e)));
}
}
async function startAuth() {
const clientId = document.getElementById('clientId').value.trim();
const tenantId = document.getElementById('tenantId').value.trim();
const clientSecret = document.getElementById('clientSecret').value.trim();
if (!clientId || !tenantId) { alert('Enter Client ID and Tenant ID'); return; }
// Persist credentials first so they survive restarts regardless of auth outcome
await fetch('/api/auth/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({client_id: clientId, tenant_id: tenantId, client_secret: clientSecret})
});
const r = await fetch('/api/auth/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({client_id: clientId, tenant_id: tenantId, client_secret: clientSecret})
});
const d = await r.json();
if (d.error) { alert(d.error); return; }
if (d.mode === 'application') {
// App mode — token acquired immediately, no device code step needed
document.getElementById('configForm').style.display = 'none';
document.getElementById('deviceCodeBackdrop').classList.add('open');
document.getElementById('deviceCode').textContent = '—';
document.getElementById('authStatus').className = 'auth-status success';
document.getElementById('authStatus').textContent = '✓ Connected (Application mode — org-wide access)';
setTimeout(onAuthenticated, 900);
return;
}
document.getElementById('configForm').style.display = 'none';
document.getElementById('deviceCodeBackdrop').classList.add('open');
document.getElementById('deviceCode').textContent = d.user_code;
pollAuth();
}
async function pollAuth() {
const r = await fetch('/api/auth/poll', {method: 'POST'});
const d = await r.json();
if (d.status === 'pending') {
setTimeout(pollAuth, 3000);
} else if (d.status === 'ok') {
document.getElementById('authStatus').className = 'auth-status success';
document.getElementById('authStatus').textContent = '✓ Signed in!';
setTimeout(onAuthenticated, 800);
} else {
document.getElementById('authStatus').className = 'auth-status error';
document.getElementById('authStatus').textContent = '✗ ' + (d.error || 'Sign-in failed');
document.getElementById('configForm').style.display = 'block';
document.getElementById('deviceCodeBackdrop').classList.remove('open');
}
}
function cancelAuth() {
document.getElementById('configForm').style.display = 'block';
document.getElementById('deviceCodeBackdrop').classList.remove('open');
}
let _currentDisplayName = '';
function _setModeBadge(isAppMode, displayName) {
S._currentAppMode = isAppMode;
_currentDisplayName = displayName || '';
// Keep Sources modal status dot in sync if it's open
const dot = document.getElementById('srcM365StatusDot');
if (dot) dot.className = 'srcmgmt-status ' + (isAppMode !== null && isAppMode !== undefined ? 'green' : 'grey');
}
async function onAuthenticated() {
const r = await fetch('/api/auth/status');
const d = await r.json();
if (d.display_name || d.displayName || d.email) {
_setModeBadge(d.app_mode, d.display_name || d.displayName || d.email);
}
document.getElementById('authScreen').style.display = 'none';
document.getElementById('scannerScreen').style.display = 'flex';
loadUsers();
loadTrend(); // show existing trend if DB has history
loadProfiles(); // populate profile dropdown (15c)
}
function reconfigure() {
// Show the auth screen with current credentials pre-filled so user can
// update the client secret without losing client_id / tenant_id.
document.getElementById('scannerScreen').style.display = 'none';
document.getElementById('authScreen').style.display = 'flex';
document.getElementById('configForm').style.display = 'block';
document.getElementById('deviceCodeBackdrop').classList.remove('open');
}
async function signOut() {
await fetch('/api/auth/signout', {method: 'POST'});
document.getElementById('scannerScreen').style.display = 'none';
document.getElementById('authScreen').style.display = 'flex';
document.getElementById('configForm').style.display = 'block';
document.getElementById('deviceCodeBackdrop').classList.remove('open');
S.flaggedData = []; S.filteredData = [];
document.getElementById('grid').innerHTML = '';
document.getElementById('grid').style.display = 'none';
const _lss2 = document.getElementById('lastScanSummary'); if (_lss2) _lss2.style.display = 'none';
document.getElementById('emptyState').style.display = 'flex';
}
// ── Check auth on load ────────────────────────────────────────────────────────
// Date presets
(function() {
const presets = document.querySelectorAll('.date-preset');
const hidden = document.getElementById('olderThan');
const dateIn = document.getElementById('olderThanDate');
function setPreset(btn) {
presets.forEach(p => p.classList.remove('selected'));
btn.classList.add('selected');
const years = parseInt(btn.dataset.years);
if (years === 0) {
hidden.value = '0';
dateIn.value = new Date().toISOString().slice(0, 10);
} else {
const d = new Date();
d.setFullYear(d.getFullYear() - years);
hidden.value = Math.round(years * 365.25).toString();
dateIn.value = d.toISOString().slice(0, 10);
}
}
presets.forEach(btn => btn.addEventListener('click', () => setPreset(btn)));
dateIn.addEventListener('change', () => {
presets.forEach(p => p.classList.remove('selected'));
if (dateIn.value) {
const diffDays = Math.round((Date.now() - new Date(dateIn.value)) / 86400000);
hidden.value = diffDays.toString();
} else {
hidden.value = '0';
}
});
// Trigger default (2yr selected)
const def = document.querySelector('.date-preset.selected');
if (def) setPreset(def);
// Toggle attach size row visibility
document.getElementById('optAttachments').addEventListener('change', function() {
document.getElementById('attachSizeRow').style.opacity = this.checked ? '1' : '0.4';
});
})();
// ── Viewer mode bootstrap ─────────────────────────────────────────────────────
if (window.VIEWER_MODE) {
document.body.classList.add('viewer-mode');
document.getElementById('authScreen').style.display = 'none';
document.getElementById('scannerScreen').style.display = 'flex';
try { loadTrend(); } catch(e) {}
} else {
(async function() {
try {
const r = await fetch('/api/auth/status');
const d = await r.json();
if (d.authenticated) {
// Load saved credentials into fields
if (d.client_id) document.getElementById('clientId').value = d.client_id;
if (d.tenant_id) document.getElementById('tenantId').value = d.tenant_id;
if (d.client_secret) document.getElementById('clientSecret').value = d.client_secret;
_setModeBadge(d.app_mode, d.display_name || d.email || '');
document.getElementById('authScreen').style.display = 'none';
document.getElementById('scannerScreen').style.display = 'flex';
try { loadUsers(); } catch(e) {}
try { loadProfiles(); } catch(e) {}
try { loadTrend(); } catch(e) {}
} else {
// Pre-fill saved credentials
if (d.client_id) document.getElementById('clientId').value = d.client_id;
if (d.tenant_id) document.getElementById('tenantId').value = d.tenant_id;
if (d.client_secret) document.getElementById('clientSecret').value = d.client_secret;
}
} catch(e) { console.error('Auth status check failed:', e); }
})();
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.handleSignIn = handleSignIn;
window.startAuth = startAuth;
window.pollAuth = pollAuth;
window.cancelAuth = cancelAuth;
window._setModeBadge = _setModeBadge;
window.onAuthenticated = onAuthenticated;
window.reconfigure = reconfigure;
window.signOut = signOut;
window._currentDisplayName = _currentDisplayName;
+684
View File
@@ -0,0 +1,684 @@
import { S } from './state.js';
// ── Unified Source Management (#17) ──────────────────────────────────────────
function openSourcesMgmt(tab) {
document.getElementById('srcMgmtBackdrop').classList.add('open');
switchSrcTab(tab || 'm365');
smRefreshStatus();
smGoogleRefreshStatus();
srcFileRenderList();
}
function closeSourcesMgmt() {
document.getElementById('srcMgmtBackdrop').classList.remove('open');
}
function switchSrcTab(tab) {
['m365','google','files'].forEach(function(t) {
document.getElementById('srcPane' + t.charAt(0).toUpperCase() + t.slice(1))
.classList.toggle('active', t === tab);
const btn = document.getElementById('srcTab' + t.charAt(0).toUpperCase() + t.slice(1));
if (btn) btn.classList.toggle('active', t === tab);
});
// Capitalise pane ids correctly: srcPaneM365, srcPaneGoogle, srcPaneFiles
const paneMap = {m365:'M365', google:'Google', files:'Files'};
['m365','google','files'].forEach(function(t) {
const pane = document.getElementById('srcPane' + paneMap[t]);
if (pane) pane.classList.toggle('active', t === tab);
const btn = document.getElementById('srcTab' + paneMap[t]);
if (btn) btn.classList.toggle('active', t === tab);
});
}
// ── M365 pane ─────────────────────────────────────────────────────────────────
function smRefreshStatus() {
const dot = document.getElementById('srcM365StatusDot');
const label = document.getElementById('srcM365StatusLabel');
const sub = document.getElementById('srcM365StatusSub');
const disc = document.getElementById('smDisconnectBtn');
const st = document.getElementById('smConnStatus');
if (!dot) return;
// Load saved credentials and auth status from the correct endpoints
fetch('/api/auth/status').then(function(r){ return r.json(); }).then(function(d) {
// Pre-fill credential fields
const cidEl = document.getElementById('smClientId');
const tidEl = document.getElementById('smTenantId');
const secEl = document.getElementById('smClientSecret');
if (cidEl && d.client_id) cidEl.value = d.client_id;
if (tidEl && d.tenant_id) tidEl.value = d.tenant_id;
if (secEl && d.client_secret) secEl.value = d.client_secret.length > 4 ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : '';
if (d.authenticated) {
dot.className = 'srcmgmt-status green';
const who = d.display_name || d.email || '';
const mode = d.app_mode ? t('m365_mode_app_short','App mode') : t('m365_mode_delegated_short','Delegated');
label.textContent = who || t('m365_srcmgmt_connected','Connected');
sub.textContent = mode + (d.email && d.display_name ? ' \u00b7 ' + d.email : '');
if (disc) disc.style.display = '';
if (st) st.textContent = '';
} else {
dot.className = 'srcmgmt-status grey';
label.textContent = t('m365_srcmgmt_not_connected','Not connected');
sub.textContent = '';
if (disc) disc.style.display = 'none';
if (st) st.textContent = '';
}
}).catch(function(){
if (dot) dot.className = 'srcmgmt-status grey';
});
}
async function smConnect() {
const cid = document.getElementById('smClientId').value.trim();
const tid = document.getElementById('smTenantId').value.trim();
const rawSec = document.getElementById('smClientSecret').value;
// If field shows placeholder dots and user hasn't changed it, use saved secret (send empty to keep it)
const sec = (rawSec === '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022') ? '' : rawSec.trim();
const st = document.getElementById('smConnStatus');
if (!cid || !tid) { st.style.color='var(--danger)'; st.textContent=t('m365_err_creds_required','Client ID and Tenant ID required'); return; }
st.style.color='var(--muted)'; st.textContent=t('m365_connecting','Connecting...');
// Persist credentials
await fetch('/api/auth/config', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({client_id:cid, tenant_id:tid, client_secret:sec})
});
// Start auth — same as the auth screen flow
try {
const r = await fetch('/api/auth/start', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({client_id:cid, tenant_id:tid, client_secret:sec})
});
const d = await r.json();
if (d.error) { st.style.color='var(--danger)'; st.textContent=d.error; return; }
if (d.mode === 'application') {
// App mode — no device code needed
st.style.color='var(--accent)'; st.textContent='\u2714 '+t('m365_connected','Connected');
closeSourcesMgmt();
setTimeout(onAuthenticated, 400);
} else {
// Delegated — show device code flow, close modal
closeSourcesMgmt();
document.getElementById('clientId').value = cid;
document.getElementById('tenantId').value = tid;
document.getElementById('clientSecret').value = sec;
document.getElementById('configForm').style.display = 'none';
document.getElementById('authScreen').style.display = 'flex';
document.getElementById('deviceCodeBackdrop').classList.add('open');
document.getElementById('deviceCode').textContent = d.user_code || '\u2014';
pollAuth();
}
} catch(e) { st.style.color='var(--danger)'; st.textContent=e.message; }
}
function smDisconnect() {
if (!confirm(t('m365_signout_confirm','Disconnect and clear credentials?'))) return;
fetch('/api/auth/signout', {method:'POST'}).then(function(){
closeSourcesMgmt();
signOut();
});
}
// ── Google Workspace pane ─────────────────────────────────────────────────────
// Parsed key dict held in memory while the pane is open — cleared on disconnect
var _googleKeyDict = null;
var _googleAuthMode = 'workspace';
function smGoogleSetMode(mode) {
_googleAuthMode = mode;
var saSection = document.getElementById('smGoogleSaSection');
var personalSection = document.getElementById('smGooglePersonalSection');
var wsSetup = document.getElementById('smGoogleWorkspaceSetup');
var btnWs = document.getElementById('smGoogleModeWorkspace');
var btnPl = document.getElementById('smGoogleModePersonal');
var isPersonal = (mode === 'personal');
if (saSection) saSection.style.display = isPersonal ? 'none' : '';
if (personalSection) personalSection.style.display = isPersonal ? '' : 'none';
if (wsSetup) wsSetup.style.display = isPersonal ? 'none' : '';
if (btnWs) { btnWs.style.background = isPersonal ? 'var(--surface)' : 'var(--accent)'; btnWs.style.color = isPersonal ? 'var(--text)' : '#fff'; }
if (btnPl) { btnPl.style.background = isPersonal ? 'var(--accent)' : 'var(--surface)'; btnPl.style.color = isPersonal ? '#fff' : 'var(--text)'; }
}
function smGoogleRefreshStatus() {
var wsPromise = fetch('/api/google/auth/status').then(function(r){ return r.json(); }).catch(function(){ return {}; });
var personalPromise = fetch('/api/google/personal/status').then(function(r){ return r.json(); }).catch(function(){ return {connected: false}; });
Promise.all([wsPromise, personalPromise]).then(function(results) {
var ws = results[0];
var personal = results[1];
var dot = document.getElementById('srcGoogleStatusDot');
var label = document.getElementById('srcGoogleStatusLabel');
var sub = document.getElementById('srcGoogleStatusSub');
var disc = document.getElementById('smGoogleDisconnectBtn');
var srcs = document.getElementById('smGoogleSourcesGroup');
var signOutBtn = document.getElementById('smGooglePersonalSignOutBtn');
var signInBtn = document.getElementById('smGooglePersonalSignInBtn');
if (!dot) return;
if (ws.libs_ok === false) {
dot.className = 'srcmgmt-status amber';
label.textContent = t('m365_google_libs_missing', 'Libraries not installed');
sub.textContent = 'pip install google-auth google-auth-httplib2 google-api-python-client';
if (disc) disc.style.display = 'none';
if (srcs) srcs.style.display = 'none';
return;
}
if (personal.connected) {
smGoogleSetMode('personal');
window._googleConnected = true;
dot.className = 'srcmgmt-status green';
label.textContent = personal.email || personal.displayName || t('m365_srcmgmt_connected', 'Connected');
sub.textContent = t('m365_google_mode_personal', 'Personal account');
if (disc) disc.style.display = 'none';
if (srcs) srcs.style.display = '';
if (signOutBtn) signOutBtn.style.display = '';
if (signInBtn) signInBtn.style.display = 'none';
} else if (ws.connected) {
smGoogleSetMode('workspace');
window._googleConnected = true;
dot.className = 'srcmgmt-status green';
label.textContent = ws.sa_email || t('m365_srcmgmt_connected', 'Connected');
sub.textContent = (ws.project_id ? ws.project_id + ' · ' : '') + (ws.admin_email || '');
if (disc) disc.style.display = '';
if (srcs) srcs.style.display = '';
if (signOutBtn) signOutBtn.style.display = 'none';
if (signInBtn) signInBtn.style.display = '';
var ae = document.getElementById('smGoogleAdminEmail');
if (ae && ws.admin_email && !ae.value) ae.value = ws.admin_email;
var gm = document.getElementById('smGoogleSrcGmail');
var gd = document.getElementById('smGoogleSrcDrive');
if (gm && ws.src_gmail !== undefined) gm.checked = !!ws.src_gmail;
if (gd && ws.src_drive !== undefined) gd.checked = !!ws.src_drive;
} else {
window._googleConnected = false;
dot.className = 'srcmgmt-status grey';
label.textContent = t('m365_srcmgmt_not_connected', 'Not connected');
sub.textContent = ws.error || personal.error || '';
if (disc) disc.style.display = 'none';
if (srcs) srcs.style.display = 'none';
if (signOutBtn) signOutBtn.style.display = 'none';
if (signInBtn) signInBtn.style.display = '';
}
renderSourcesPanel();
// If the profile editor is open and its source panel has no Google checkboxes yet,
// re-render it now that connection status is known.
if (document.getElementById('pmgmtEditor')?.classList.contains('open') &&
!document.querySelector('#peSourcesPanel input[data-source-type="google"]')) {
var _peCheckedIds = Array.from(document.querySelectorAll('#peSourcesPanel input[type=checkbox]'))
.filter(function(cb) { return cb.checked; }).map(function(cb) { return cb.dataset.sourceId; });
var _peProfile = window._pmgmtEditId ? (S._profiles.find(function(p) { return p.id === window._pmgmtEditId; }) || window._pmgmtNewDraft) : window._pmgmtNewDraft;
if (_peProfile) {
var _peSavedIds = (_peProfile.sources||[]).concat(_peProfile.google_sources||[]).concat(_peProfile.file_sources||[]);
_renderEditorSources(_peCheckedIds.concat(_peSavedIds));
}
}
if (window._googleConnected) {
_mergeGoogleUsers();
} else {
// Remove standalone Google users; reset merged 'both' users back to M365
S._allUsers = S._allUsers.filter(function(u){ return (u.platform||'m365') !== 'google'; });
S._allUsers.forEach(function(u) {
if (u.platform === 'both') { u.platform = 'm365'; delete u.googleEmail; }
});
renderAccountList();
}
}).catch(function() {
var dot = document.getElementById('srcGoogleStatusDot');
if (dot) dot.className = 'srcmgmt-status grey';
});
}
// Wire up file input to read + validate JSON immediately
(function() {
document.addEventListener('DOMContentLoaded', function() {
var fi = document.getElementById('smGoogleKeyFile');
if (!fi) return;
fi.addEventListener('change', function() {
var f = fi.files && fi.files[0];
if (!f) { _googleKeyDict = null; return; }
var reader = new FileReader();
reader.onload = function(e) {
try {
_googleKeyDict = JSON.parse(e.target.result);
var nameEl = document.getElementById('smGoogleKeyName');
if (nameEl) nameEl.textContent = _googleKeyDict.client_email ? '✔ ' + _googleKeyDict.client_email.split('@')[0] : '✔ loaded';
} catch(err) {
_googleKeyDict = null;
var st = document.getElementById('smGoogleConnStatus');
if (st) { st.style.color='var(--danger)'; st.textContent = t('m365_google_invalid_json','Invalid JSON file'); }
}
};
reader.readAsText(f);
});
});
})();
async function smGoogleConnect() {
var st = document.getElementById('smGoogleConnStatus');
var adminEmail = (document.getElementById('smGoogleAdminEmail') || {}).value || '';
if (!_googleKeyDict) {
if (st) { st.style.color='var(--danger)'; st.textContent = t('m365_google_key_required','Select a service account JSON key file'); }
return;
}
if (st) { st.style.color='var(--muted)'; st.textContent = t('m365_connecting','Connecting...'); }
try {
var r = await fetch('/api/google/auth/connect', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({key_json: _googleKeyDict, admin_email: adminEmail})
});
var d = await r.json();
if (d.error) {
if (st) { st.style.color='var(--danger)'; st.textContent = d.error; }
return;
}
if (st) { st.style.color='var(--accent)'; st.textContent = '✔ ' + t('m365_connected','Connected'); }
smGoogleRefreshStatus();
} catch(e) {
if (st) { st.style.color='var(--danger)'; st.textContent = e.message; }
}
}
function smGoogleDisconnect() {
if (!confirm(t('m365_signout_confirm','Disconnect and clear credentials?'))) return;
fetch('/api/google/auth/disconnect', {method:'POST'}).then(function() {
_googleKeyDict = null;
var fi = document.getElementById('smGoogleKeyFile');
if (fi) fi.value = '';
var nameEl = document.getElementById('smGoogleKeyName');
if (nameEl) nameEl.textContent = '';
var st = document.getElementById('smGoogleConnStatus');
if (st) st.textContent = '';
smGoogleRefreshStatus();
});
}
async function smGooglePersonalStart() {
var clientId = (document.getElementById('smGooglePersonalClientId') || {}).value || '';
var clientSecret = (document.getElementById('smGooglePersonalClientSecret') || {}).value || '';
var st = document.getElementById('smGooglePersonalConnStatus');
if (!clientId || !clientSecret) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_google_personal_creds_required', 'Client ID and secret required'); }
return;
}
if (st) { st.style.color = 'var(--muted)'; st.textContent = t('m365_connecting', 'Connecting...'); }
try {
var r = await fetch('/api/google/personal/start', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({client_id: clientId, client_secret: clientSecret})
});
var d = await r.json();
if (d.error) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = d.error; }
return;
}
var box = document.getElementById('smGoogleDeviceBox');
var codeEl = document.getElementById('smGoogleDeviceCode');
var urlEl = document.getElementById('smGoogleDeviceUrl');
var pollSt = document.getElementById('smGooglePollStatus');
if (box) box.style.display = '';
if (codeEl) codeEl.textContent = d.user_code || '—';
if (urlEl) { urlEl.href = d.verification_url || 'https://google.com/device'; urlEl.textContent = (d.verification_url || 'https://google.com/device').replace('https://', ''); }
if (pollSt) { pollSt.style.color = 'var(--muted)'; pollSt.textContent = '⏳ ' + t('m365_auth_waiting', 'Waiting for sign-in…'); }
if (st) st.textContent = '';
smGooglePersonalPoll();
} catch(e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
}
}
function smGooglePersonalPoll() {
fetch('/api/google/personal/poll', {method: 'POST'})
.then(function(r) { return r.json(); })
.then(function(d) {
var pollSt = document.getElementById('smGooglePollStatus');
if (d.status === 'pending') {
setTimeout(smGooglePersonalPoll, 3000);
} else if (d.status === 'ok') {
if (pollSt) { pollSt.style.color = 'var(--success)'; pollSt.textContent = '✓ ' + t('m365_connected', 'Connected'); }
setTimeout(function() {
var box = document.getElementById('smGoogleDeviceBox');
if (box) box.style.display = 'none';
smGoogleRefreshStatus();
}, 1000);
} else {
if (pollSt) { pollSt.style.color = 'var(--danger)'; pollSt.textContent = '✗ ' + (d.error || 'Sign-in failed'); }
setTimeout(function() {
var box = document.getElementById('smGoogleDeviceBox');
if (box) box.style.display = 'none';
}, 3000);
}
})
.catch(function() { setTimeout(smGooglePersonalPoll, 5000); });
}
function smGooglePersonalSignOut() {
if (!confirm(t('m365_signout_confirm', 'Disconnect and clear credentials?'))) return;
fetch('/api/google/personal/signout', {method: 'POST'}).then(function() {
smGoogleRefreshStatus();
});
}
// Returns {sources, options} reflecting current Google pane state — used by scan launcher
function getGoogleScanOptions() {
var sources = [];
if (document.getElementById('smGoogleSrcGmail') && document.getElementById('smGoogleSrcGmail').checked) sources.push('gmail');
if (document.getElementById('smGoogleSrcDrive') && document.getElementById('smGoogleSrcDrive').checked) sources.push('gdrive');
return {sources: sources, options: {}};
}
// ── File sources pane ─────────────────────────────────────────────────────────
function srcFileRenderList() {
const list = document.getElementById('srcFileList');
if (!list) return;
if (!S._fileSources.length) {
list.innerHTML = '<div class="fsrc-empty">'+t('m365_file_sources_empty','No file sources yet.')+'</div>';
return;
}
list.innerHTML = S._fileSources.map(function(s) {
const isSmb = s.path && (s.path.startsWith('//') || s.path.startsWith('\\\\'));
const icon = isSmb ? '\uD83C\uDF10' : '\uD83D\uDCC1';
const sid = _esc(s.id||'');
const slabel = _esc(s.label||s.path||'');
return '<div class="fsrc-row">'
+'<div class="fsrc-row-head">'
+'<span class="fsrc-row-label">'+icon+' '+slabel+'</span>'
+'<div class="fsrc-actions">'
+'<button class="btn-scan" onclick="srcFileScan(\''+sid+'\')">&#9654; '+t('m365_fsrc_scan_btn','Scan')+'</button>'
+'<button class="btn-edit" onclick="srcFileEdit(\''+sid+'\')" style="background:none;border:1px solid var(--border);color:var(--muted);padding:2px 7px;border-radius:4px;font-size:10px;cursor:pointer">'+t('m365_fsrc_edit_btn','Edit')+'</button>'
+'<button class="btn-del" onclick="srcFileDelete(\''+sid+'\',\''+slabel+'\')">'+t('m365_profile_delete','Delete')+'</button>'
+'</div></div>'
+'<div class="fsrc-row-path">'+_esc(s.path||'')+(s.smb_user?' \u00b7 \uD83D\uDC64 '+_esc(s.smb_user):'')+'</div>'
+'</div>';
}).join('');
}
function srcFileDetectSmb() {
const p = document.getElementById('srcFilePath').value;
const isSmb = p.startsWith('//') || p.startsWith('\\\\');
document.getElementById('srcFileSmbFields').style.display = isSmb ? 'flex' : 'none';
if (isSmb && !document.getElementById('srcFileSmbHost').value) {
document.getElementById('srcFileSmbHost').value = p.replace(/^[\/\\]+/,'').split(/[\/\\]/)[0];
}
}
function srcFileAutoName() {
const labelEl = document.getElementById('srcFileLabel');
if (labelEl._userEdited) return;
const p = document.getElementById('srcFilePath').value.trim();
if (!p) { labelEl.value=''; return; }
const parts = p.replace(/[\/\\]+$/,'').split(/[\/\\]/);
if ((p.startsWith('//')||p.startsWith('\\\\')) && parts.filter(function(x){return x;}).length>=2) {
const segs = parts.filter(function(x){return x;});
labelEl.value = segs[0]+(segs[1]?' / '+segs[1]:'');
} else {
labelEl.value = parts[parts.length-1]||p;
}
}
async function srcFileAdd() {
const label = document.getElementById('srcFileLabel').value.trim();
const path = document.getElementById('srcFilePath').value.trim();
const smbHost = document.getElementById('srcFileSmbHost').value.trim();
const smbUser = document.getElementById('srcFileSmbUser').value.trim();
const smbPw = document.getElementById('srcFileSmbPw').value;
const stat = document.getElementById('srcFileStatus');
if (!label) { stat.style.color='var(--danger)'; stat.textContent=t('m365_fsrc_name_required','Name is required.'); document.getElementById('srcFileLabel').focus(); return; }
if (!path) { stat.style.color='var(--danger)'; stat.textContent=t('m365_fsrc_path_required','Path is required.'); return; }
stat.style.color='var(--muted)'; stat.textContent=t('m365_fsrc_saving','Saving...');
if (smbPw && smbUser) {
try { await fetch('/api/file_sources/store_creds',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({smb_host:smbHost,smb_user:smbUser,password:smbPw})}); } catch(e){}
}
try {
const editId = document.getElementById('srcFileEditId');
const existingId = editId ? editId.value : '';
const body = {label, path, smb_host:smbHost, smb_user:smbUser};
if (existingId) body.id = existingId;
const r = await fetch('/api/file_sources/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
const d = await r.json();
if (d.error) { stat.style.color='var(--danger)'; stat.textContent=d.error; return; }
['srcFileLabel','srcFilePath','srcFileSmbHost','srcFileSmbUser','srcFileSmbPw'].forEach(function(id){const el=document.getElementById(id);if(el){el.value='';el._userEdited=false;}});
if (editId) editId.value='';
const addBtn=document.getElementById('srcFileAddBtn'); if(addBtn) addBtn.textContent=t('m365_fsrc_add_btn','Add');
document.getElementById('srcFileSmbFields').style.display='none';
stat.style.color='var(--accent)'; stat.textContent='\u2714 '+t('m365_fsrc_saved','Source saved');
await _loadFileSources();
srcFileRenderList();
log(t('m365_fsrc_saved','Source saved')+': '+label);
} catch(e){ stat.style.color='var(--danger)'; stat.textContent=e.message; }
}
function srcFileEdit(id) {
const s = S._fileSources.find(function(x){return x.id===id;});
if (!s) return;
const labelEl = document.getElementById('srcFileLabel');
const pathEl = document.getElementById('srcFilePath');
const hostEl = document.getElementById('srcFileSmbHost');
const userEl = document.getElementById('srcFileSmbUser');
const pwEl = document.getElementById('srcFileSmbPw');
const editId = document.getElementById('srcFileEditId');
if (labelEl) { labelEl.value = s.label||''; labelEl._userEdited = true; }
if (pathEl) pathEl.value = s.path||'';
if (hostEl) hostEl.value = s.smb_host||'';
if (userEl) userEl.value = s.smb_user||'';
if (pwEl) pwEl.value = s.smb_user ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : '';
if (editId) editId.value = id;
const isSmb = (s.path||'').startsWith('//') || (s.path||'').startsWith('\\\\');
const smbFields = document.getElementById('srcFileSmbFields');
if (smbFields) smbFields.style.display = isSmb ? 'flex' : 'none';
const btn = document.getElementById('srcFileAddBtn');
if (btn) btn.textContent = t('m365_fsrc_save_changes','Save changes');
const stat = document.getElementById('srcFileStatus');
if (stat) { stat.style.color='var(--muted)'; stat.textContent='Editing: '+_esc(s.label||s.path||''); }
}
async function srcFileDelete(id, label) {
if (!confirm(t('m365_profile_delete_confirm','Delete')+' "'+label+'"?')) return;
await fetch('/api/file_sources/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});
await _loadFileSources();
srcFileRenderList();
}
async function srcFileScan(id) {
const source = S._fileSources.find(function(s){ return s.id===id; });
if (!source) return;
closeSourcesMgmt();
log(t('m365_fsrc_scan_start','Starting file scan')+': '+(source.label||source.path));
try {
const r = await fetch('/api/file_scan/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(source)});
const d = await r.json();
if (d.error) log('File scan error: '+d.error,'err');
} catch(e){ log('File scan error: '+e.message,'err'); }
}
// Redirect old openFileSourcesModal() to the new unified modal
function openFileSourcesModal() { openSourcesMgmt('files'); }
function closeFileSourcesModal() { closeSourcesMgmt(); }
// ── File Sources (#8) ─────────────────────────────────────────────────────────
async function _loadFileSources() {
try {
const r = await fetch('/api/file_sources');
const d = await r.json();
S._fileSources = d.sources || [];
_renderFileSources(d.smb_available);
renderSourcesPanel();
// Re-apply any pending profile source selection (file sources render after load)
if (S._pendingProfileSources.length) {
document.querySelectorAll('#sourcesPanel input[data-source-type="file"]').forEach(function(cb) {
cb.checked = S._pendingProfileSources.includes(cb.dataset.sourceId);
});
S._pendingProfileSources = [];
}
// If the profile editor is open and has no file checkboxes yet, re-render it now.
if (document.getElementById('pmgmtEditor')?.classList.contains('open') &&
!document.querySelector('#peSourcesPanel input[data-source-type="file"]') &&
S._fileSources.length > 0) {
var _peCheckedIds = Array.from(document.querySelectorAll('#peSourcesPanel input[type=checkbox]'))
.filter(function(cb) { return cb.checked; }).map(function(cb) { return cb.dataset.sourceId; });
var _peProfile = window._pmgmtEditId ? (S._profiles.find(function(p) { return p.id === window._pmgmtEditId; }) || window._pmgmtNewDraft) : window._pmgmtNewDraft;
if (_peProfile) {
var _peSavedIds = (_peProfile.sources||[]).concat(_peProfile.google_sources||[]).concat(_peProfile.file_sources||[]);
_renderEditorSources(_peCheckedIds.concat(_peSavedIds));
}
}
} catch(e) {
const s = document.getElementById('fsrcStatus');
if (s) { s.style.color = 'var(--danger)'; s.textContent = 'Error: ' + e.message; }
}
}
function _renderFileSources() {
const list = document.getElementById('fsrcList');
if (!list) return;
if (!S._fileSources.length) {
list.innerHTML = '<div class="fsrc-empty">' + t('m365_file_sources_empty','No file sources yet.') + '</div>';
return;
}
list.innerHTML = S._fileSources.map(function(s) {
const isSmb = s.path && (s.path.startsWith('//') || s.path.startsWith('\\\\'));
const icon = isSmb ? '\uD83C\uDF10' : '\uD83D\uDCC1';
const userPart = s.smb_user ? ' \u00b7 \uD83D\uDC64 ' + _esc(s.smb_user) : '';
const sid = _esc(s.id || '');
const slabel = _esc(s.label || s.path || '');
return '<div class="fsrc-row">'
+ '<div class="fsrc-row-head">'
+ '<span class="fsrc-row-label">' + icon + ' ' + slabel + '</span>'
+ '<div class="fsrc-actions">'
+ '<button class="btn-scan" onclick="fsrcScan(\'' + sid + '\')">&#9654; ' + t('m365_fsrc_scan_btn','Scan') + '</button>'
+ '<button class="btn-del" onclick="fsrcDelete(\'' + sid + '\',\'' + slabel + '\')">' + t('m365_profile_delete','Delete') + '</button>'
+ '</div></div>'
+ '<div class="fsrc-row-path">' + _esc(s.path || '') + userPart + '</div>'
+ '</div>';
}).join('');
}
function fsrcDetectSmb() {
const p = document.getElementById('fsrcPath').value;
const isSmb = p.startsWith('//') || p.startsWith('\\\\');
document.getElementById('fsrcSmbFields').style.display = isSmb ? 'flex' : 'none';
if (isSmb && !document.getElementById('fsrcSmbHost').value) {
document.getElementById('fsrcSmbHost').value = p.replace(/^[\/\\]+/,'').split(/[\/\\]/)[0];
}
}
function fsrcAutoName() {
// Suggest a name from the path only if the user hasn't typed one yet
const labelEl = document.getElementById('fsrcLabel');
if (labelEl._userEdited) return;
const p = document.getElementById('fsrcPath').value.trim();
if (!p) { labelEl.value = ''; return; }
// Extract last meaningful path segment
const parts = p.replace(/[/\\]+$/, '').split(/[/\\]/);
const last = parts[parts.length - 1] || parts[parts.length - 2] || p;
// For SMB paths like //nas/share use "nas / share"
if ((p.startsWith('//') || p.startsWith('\\\\')) && parts.length >= 3) {
const host = parts.find(function(x){ return x.length > 0; }) || '';
const share = parts.filter(function(x){ return x.length > 0; })[1] || '';
labelEl.value = share ? host + ' / ' + share : host;
} else {
labelEl.value = last;
}
}
document.addEventListener('DOMContentLoaded', function() {
const labelEl = document.getElementById('fsrcLabel');
if (labelEl) {
labelEl.addEventListener('input', function() { labelEl._userEdited = !!labelEl.value; });
}
const srcFileLabelEl = document.getElementById('srcFileLabel');
if (srcFileLabelEl) {
srcFileLabelEl.addEventListener('input', function() { srcFileLabelEl._userEdited = !!srcFileLabelEl.value; });
}
});
async function fsrcAddSource() {
const path = document.getElementById('fsrcPath').value.trim();
const label = document.getElementById('fsrcLabel').value.trim() || path;
const smbHost = document.getElementById('fsrcSmbHost').value.trim();
const smbUser = document.getElementById('fsrcSmbUser').value.trim();
const smbPw = document.getElementById('fsrcSmbPw').value;
const stat = document.getElementById('fsrcStatus');
if (!label) { stat.style.color='var(--danger)'; stat.textContent=t('m365_fsrc_name_required','Name is required.'); document.getElementById('fsrcLabel').focus(); return; }
if (!path) { stat.style.color='var(--danger)'; stat.textContent=t('m365_fsrc_path_required','Path is required.'); return; }
stat.style.color='var(--muted)'; stat.textContent=t('m365_fsrc_saving','Saving...');
if (smbPw && smbUser) {
try { await fetch('/api/file_sources/store_creds',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({smb_host:smbHost,smb_user:smbUser,password:smbPw})}); } catch(e){}
}
try {
const r = await fetch('/api/file_sources/save',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({label,path,smb_host:smbHost,smb_user:smbUser})});
const d = await r.json();
if (d.error) { stat.style.color='var(--danger)'; stat.textContent=d.error; return; }
['fsrcLabel','fsrcPath','fsrcSmbHost','fsrcSmbUser','fsrcSmbPw'].forEach(function(id){const el=document.getElementById(id);if(el){el.value='';el._userEdited=false;}});
document.getElementById('fsrcSmbFields').style.display='none';
stat.style.color='var(--accent)'; stat.textContent='\u2714 '+t('m365_fsrc_saved','Source saved');
await _loadFileSources();
log(t('m365_fsrc_saved','Source saved')+': '+label);
} catch(e){ stat.style.color='var(--danger)'; stat.textContent=e.message; }
}
async function fsrcDelete(id, label) {
if (!confirm(t('m365_profile_delete_confirm','Delete')+' "'+label+'"?')) return;
try {
await fetch('/api/file_sources/delete',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({id})});
await _loadFileSources();
log(t('m365_profile_deleted','Deleted')+': '+label);
} catch(e){ const s=document.getElementById('fsrcStatus'); if(s) s.textContent=e.message; }
}
async function fsrcScan(id) {
const source = S._fileSources.find(function(s){ return s.id===id; });
if (!source) return;
closeFileSourcesModal();
log(t('m365_fsrc_scan_start','Starting file scan')+': '+(source.label||source.path));
try {
const r = await fetch('/api/file_scan/start',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(source)});
const d = await r.json();
if (d.error) log('File scan error: '+d.error,'err');
} catch(e){ log('File scan error: '+e.message,'err'); }
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.openSourcesMgmt = openSourcesMgmt;
window.closeSourcesMgmt = closeSourcesMgmt;
window.switchSrcTab = switchSrcTab;
window.smRefreshStatus = smRefreshStatus;
window.smConnect = smConnect;
window.smDisconnect = smDisconnect;
window.smGoogleSetMode = smGoogleSetMode;
window.smGoogleRefreshStatus = smGoogleRefreshStatus;
window.smGoogleConnect = smGoogleConnect;
window.smGoogleDisconnect = smGoogleDisconnect;
window.smGooglePersonalStart = smGooglePersonalStart;
window.smGooglePersonalPoll = smGooglePersonalPoll;
window.smGooglePersonalSignOut = smGooglePersonalSignOut;
window.getGoogleScanOptions = getGoogleScanOptions;
window.srcFileRenderList = srcFileRenderList;
window.srcFileDetectSmb = srcFileDetectSmb;
window.srcFileAutoName = srcFileAutoName;
window.srcFileAdd = srcFileAdd;
window.srcFileEdit = srcFileEdit;
window.srcFileDelete = srcFileDelete;
window.srcFileScan = srcFileScan;
window.openFileSourcesModal = openFileSourcesModal;
window.closeFileSourcesModal = closeFileSourcesModal;
window._loadFileSources = _loadFileSources;
window._renderFileSources = _renderFileSources;
window.fsrcDetectSmb = fsrcDetectSmb;
window.fsrcAutoName = fsrcAutoName;
window.fsrcAddSource = fsrcAddSource;
window.fsrcDelete = fsrcDelete;
window.fsrcScan = fsrcScan;
window._googleKeyDict = _googleKeyDict;
window._googleAuthMode = _googleAuthMode;
+341
View File
@@ -0,0 +1,341 @@
import { S } from './state.js';
// ── Log ──────────────────────────────────────────────────────────────────────
const _LOG_SESSION_KEY = 'gdpr_log_session';
const _LOG_MAX_LINES = 300;
let _logFilter = 'all'; // 'all' | 'err'
// Maps keywords found in phase strings → {label, pillClass}
// Emoji patterns cover phases that have no source keyword in text
// (e.g. "📂 skolehaver: 1 msg(s)" — 📂 is only used for mail folders)
const _PHASE_SOURCE_MAP = [
{ re: /OneDrive/i, label: 'OneDrive', cls: 'progress-src-m365' },
{ re: /SharePoint/i, label: 'SharePoint', cls: 'progress-src-m365' },
{ re: /\bTeams\b/i, label: 'Teams', cls: 'progress-src-m365' },
{ re: /E-?mail|emails?|msg\(s\)|\uD83D\uDCC2/iu, label: 'Outlook', cls: 'progress-src-m365' },
{ re: /Google Workspace/i, label: 'Gmail', cls: 'progress-src-google' },
{ re: /Google Drive/i, label: 'GDrive', cls: 'progress-src-google' },
{ re: /Gmail/i, label: 'Gmail', cls: 'progress-src-google' },
{ re: /\bfil(er|S.es)?\b/i, label: 'Local', cls: 'progress-src-file' },
];
function _escHtml(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
// Resolve an email address to a display name using S._allUsers, and strip
// trailing count suffixes like ": 3 file(s)" or ": 5 msg(s)".
function _resolveDisplayName(text) {
if (!text) return text;
const stripped = text.replace(/:\s*\d+\s*(file\(s\)|files?|filer|msg\(s\)|folders?)[\u2026\.]*\s*$/iu, '').trim();
const check = stripped || text;
if (check.includes('@')) {
const email = check.toLowerCase();
const user = S._allUsers.find(function(u) {
return (u.email || '').toLowerCase() === email ||
(u.googleEmail || '').toLowerCase() === email;
});
if (user) return user.displayName;
}
return stripped || text;
}
// Tracks the most recent user name shown — used for sub-phases (e.g. mail folder counts)
// that don't repeat the username in their phase string.
function _setProgressPhase(phase) {
const who = document.getElementById('progressWho');
if (!who) return;
// Find source from the full phase string first
let srcEntry = null;
for (const s of _PHASE_SOURCE_MAP) {
if (s.re.test(phase)) { srcEntry = s; break; }
}
// Try "Left — Right" split (em-dash / en-dash only — plain hyphens cause false splits)
const dashMatch = phase.match(/^(.+?)\s+[\u2014\u2013]\s+(.+?)[\u2026\.]*\s*$/u);
if (srcEntry && dashMatch) {
const left = dashMatch[1].trim();
const right = dashMatch[2].trim();
// Full name is whichever side doesn't contain the source keyword
const raw = srcEntry.re.test(left) ? right : left;
const displayName = _resolveDisplayName(raw);
S._progressCurrentUser = displayName;
who.innerHTML =
'<span class="progress-src-pill ' + srcEntry.cls + '">' + srcEntry.label + '</span>' +
'<span class="progress-user">' + _escHtml(displayName) + '</span>';
return;
}
if (srcEntry) {
// Source identified but no dash split (e.g. "📂 Indbakke: 3 msg(s)").
// Re-use last known user rather than showing a folder path.
const displayName = S._progressCurrentUser ||
phase.replace(/^[\u{1F000}-\u{1FFFF}\u{2600}-\u{27FF}\s]+/u, '').trim();
who.innerHTML =
'<span class="progress-src-pill ' + srcEntry.cls + '">' + srcEntry.label + '</span>' +
'<span class="progress-user">' + _escHtml(displayName) + '</span>';
return;
}
// Informational phase (Auth mode, Delta mode, Resuming, …) — keep pill cleared
who.innerHTML = '<span class="progress-phase">' + _escHtml(phase) + '</span>';
}
function _clearProgressBar() {
_setProgressPhase('');
document.getElementById('progressStats').textContent = '';
document.getElementById('progressEta').textContent = '';
document.getElementById('progressFile').textContent = '';
}
function _renderProgressSegments() {
const track = document.getElementById('progressTrack');
if (!track) return;
const sources = [
{ key: 'm365', active: S._m365ScanRunning, color: 'var(--accent)', label: 'M365' },
{ key: 'google', active: S._googleScanRunning, color: '#3a7d44', label: 'GWS' },
{ key: 'file', active: S._fileScanRunning, color: '#7a6a9e', label: 'Files' },
].filter(function(s) { return s.active; });
if (!sources.length) { track.innerHTML = ''; return; }
track.innerHTML = sources.map(function(s, i) {
return '<div class="progress-seg"' + (i < sources.length - 1 ? '' : '') + '>' +
'<div class="progress-seg-fill" id="progressFill_' + s.key + '" style="background:' + s.color + ';width:' + (S._srcPct[s.key] || 0) + '%"></div>' +
'</div>';
}).join('');
}
function _logAtBottom(p) {
return p.scrollHeight - p.scrollTop - p.clientHeight < 24;
}
function log(msg, cls='') {
const p = document.getElementById('logPanel');
const live = document.getElementById('logLive');
const atBottom = _logAtBottom(p);
const d = document.createElement('div');
const timestamp = new Date().toLocaleTimeString();
d.className = 'log-line' + (cls ? ' log-' + cls : '');
d.textContent = timestamp + ' ' + msg;
// Insert before live indicator (always last)
if (live) p.insertBefore(d, live); else p.appendChild(d);
// Apply filter
if (_logFilter === 'err' && !cls) d.classList.add('log-err-hidden');
if (atBottom) p.scrollTop = p.scrollHeight;
// Persist to sessionStorage
try {
const lines = JSON.parse(sessionStorage.getItem(_LOG_SESSION_KEY) || '[]');
lines.push({ t: timestamp, msg, cls });
if (lines.length > _LOG_MAX_LINES) lines.splice(0, lines.length - _LOG_MAX_LINES);
sessionStorage.setItem(_LOG_SESSION_KEY, JSON.stringify(lines));
} catch(e) {}
}
function setLogLive(msg) {
const live = document.getElementById('logLive');
if (!live) return;
if (msg) {
live.style.display = 'block';
live.textContent = '▶ ' + msg;
const p = document.getElementById('logPanel');
if (_logAtBottom(p)) p.scrollTop = p.scrollHeight;
} else {
live.style.display = 'none';
live.textContent = '';
}
}
function setLogFilter(filter) {
_logFilter = filter;
document.getElementById('logFilterAll').classList.toggle('active', filter === 'all');
document.getElementById('logFilterErr').classList.toggle('active', filter === 'err');
document.querySelectorAll('#logPanel .log-line:not(#logLive)').forEach(function(d) {
const isErr = d.classList.contains('log-err') || d.classList.contains('log-warn');
d.classList.toggle('log-err-hidden', filter === 'err' && !isErr);
});
}
function copyLog() {
const lines = [];
document.querySelectorAll('#logPanel .log-line:not(#logLive)').forEach(function(d) {
lines.push(d.textContent);
});
navigator.clipboard.writeText(lines.join('\n')).then(function() {
const btn = document.querySelector('.log-copy-btn');
if (btn) { btn.textContent = '✓ Copied'; setTimeout(function(){ btn.textContent = '⎘ Copy'; }, 1500); }
}).catch(function() {});
}
function _restoreLog() {
try {
const lines = JSON.parse(sessionStorage.getItem(_LOG_SESSION_KEY) || '[]');
if (!lines.length) return;
const p = document.getElementById('logPanel');
const live = document.getElementById('logLive');
lines.forEach(function(entry) {
const d = document.createElement('div');
d.className = 'log-line' + (entry.cls ? ' log-' + entry.cls : '');
d.textContent = entry.t + ' ' + entry.msg;
if (live) p.insertBefore(d, live); else p.appendChild(d);
});
p.scrollTop = p.scrollHeight;
} catch(e) {}
}
function _initLogResize() {
const handle = document.getElementById('logResizeHandle');
const wrap = document.getElementById('logWrap');
const panel = document.getElementById('logPanel');
if (!handle || !wrap || !panel) return;
let startY, startH;
handle.addEventListener('pointerdown', function(e) {
startY = e.clientY;
startH = panel.getBoundingClientRect().height;
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', onDrag);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
e.preventDefault();
});
function onDrag(e) {
const ROW = 18; // 16px line-height + 2px margin-bottom
const PAD = 10; // 6px padding-top + 6px padding-bottom - 2px (no margin on last line)
const MIN_ROWS = 2;
const MAX_ROWS = 30;
const delta = startY - e.clientY; // drag up = taller
const rawH = Math.max(60, Math.min(600, startH + delta));
const rows = Math.round((rawH - PAD) / ROW);
const snapped = Math.max(MIN_ROWS, Math.min(MAX_ROWS, rows)) * ROW + PAD;
panel.style.height = snapped + 'px';
}
function onUp(e) {
document.body.style.cursor = '';
document.body.style.userSelect = '';
handle.releasePointerCapture(e.pointerId);
handle.removeEventListener('pointermove', onDrag);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
}
}
function _initPreviewResize() {
const handle = document.getElementById('previewResizeHandle');
const panel = document.getElementById('previewPanel');
if (!handle || !panel) return;
const MIN_W = 280;
const MAX_W = Math.round(window.innerWidth * 0.7);
let startX, startW;
handle.addEventListener('pointerdown', function(e) {
if (panel.classList.contains('hidden')) return;
startX = e.clientX;
startW = panel.getBoundingClientRect().width;
document.body.style.cursor = 'col-resize';
document.body.style.userSelect = 'none';
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', onDrag);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
e.preventDefault();
});
function onDrag(e) {
const delta = startX - e.clientX; // drag left = wider
const w = Math.max(MIN_W, Math.min(MAX_W, startW + delta));
panel.style.width = w + 'px';
}
function onUp(e) {
document.body.style.cursor = '';
document.body.style.userSelect = '';
handle.releasePointerCapture(e.pointerId);
handle.removeEventListener('pointermove', onDrag);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
sessionStorage.setItem('gdpr_preview_width', parseInt(panel.style.width));
}
}
// Called by renderSourcesPanel() after every re-render.
// Pins the panel to its natural scroll height (all sources visible) unless the
// user has previously dragged it smaller, in which case that saved height is
// restored — but only if it's still smaller than the new content height.
function _fitSourcesPanel() {
const panel = document.getElementById('sourcesPanel');
if (!panel) return;
panel.style.height = ''; // clear to measure natural content height
const natural = panel.scrollHeight;
try {
const saved = parseInt(localStorage.getItem('gdpr_sources_h'));
if (saved && saved < natural) {
panel.style.height = saved + 'px'; // honour user's smaller preference
return;
}
} catch(e) {}
panel.style.height = natural + 'px'; // default: show everything
}
function _initSourcesResize() {
const handle = document.getElementById('sourcesResizeHandle');
const panel = document.getElementById('sourcesPanel');
if (!handle || !panel) return;
let startY, startH, maxH;
handle.addEventListener('pointerdown', function(e) {
startY = e.clientY;
startH = panel.getBoundingClientRect().height;
// Max = natural scroll height (enough to show all sources — no more)
panel.style.height = '';
maxH = panel.scrollHeight;
panel.style.height = startH + 'px';
document.body.style.cursor = 'ns-resize';
document.body.style.userSelect = 'none';
handle.setPointerCapture(e.pointerId);
handle.addEventListener('pointermove', onDrag);
handle.addEventListener('pointerup', onUp);
handle.addEventListener('pointercancel', onUp);
e.preventDefault();
});
function onDrag(e) {
const ROW = 22; // ~21px per .source-check row (padding:3px 0 + ~15px content)
const MIN_H = ROW * 2;
const delta = e.clientY - startY; // drag down = taller, drag up = shorter
const rawH = Math.max(MIN_H, Math.min(maxH, startH + delta));
const snapped = Math.round(rawH / ROW) * ROW;
panel.style.height = Math.max(MIN_H, Math.min(maxH, snapped)) + 'px';
}
function onUp(e) {
document.body.style.cursor = '';
document.body.style.userSelect = '';
handle.releasePointerCapture(e.pointerId);
handle.removeEventListener('pointermove', onDrag);
handle.removeEventListener('pointerup', onUp);
handle.removeEventListener('pointercancel', onUp);
const h = parseInt(panel.style.height);
try {
if (h >= maxH) localStorage.removeItem('gdpr_sources_h'); // back to full — forget preference
else localStorage.setItem('gdpr_sources_h', h);
} catch(e) {}
}
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window._escHtml = _escHtml;
window._resolveDisplayName = _resolveDisplayName;
window._setProgressPhase = _setProgressPhase;
window._clearProgressBar = _clearProgressBar;
window._renderProgressSegments = _renderProgressSegments;
window._logAtBottom = _logAtBottom;
window.log = log;
window.setLogLive = setLogLive;
window.setLogFilter = setLogFilter;
window.copyLog = copyLog;
window._restoreLog = _restoreLog;
window._initLogResize = _initLogResize;
window._initPreviewResize = _initPreviewResize;
window._initSourcesResize = _initSourcesResize;
window._fitSourcesPanel = _fitSourcesPanel;
window._LOG_SESSION_KEY = _LOG_SESSION_KEY;
window._LOG_MAX_LINES = _LOG_MAX_LINES;
window._logFilter = _logFilter;
window._PHASE_SOURCE_MAP = _PHASE_SOURCE_MAP;
+709
View File
@@ -0,0 +1,709 @@
import { S } from './state.js';
// ── Profiles (15c) ───────────────────────────────────────────────────────────
async function loadProfiles() {
try {
const r = await fetch('/api/profiles');
if (!r.ok) return;
const d = await r.json();
S._profiles = d.profiles || [];
_renderProfileSelect();
} catch(e) { /* profiles not critical */ }
}
function _renderProfileSelect() {
const sel = document.getElementById('profileSelect');
if (!sel) return;
const prev = sel.value;
// Clear all except the placeholder option (first)
while (sel.options.length > 1) sel.remove(1);
for (const p of S._profiles) {
const opt = document.createElement('option');
opt.value = p.id;
const last = p.last_run ? ' — ' + p.last_run.slice(0, 10) : '';
opt.textContent = p.name + last;
opt.title = p.description || '';
sel.appendChild(opt);
}
// Restore selection if the profile still exists; else fall back to placeholder
if (prev && [...sel.options].some(o => o.value === prev)) {
sel.value = prev;
} else {
sel.value = '';
S._activeProfileId = null;
const clrBtn = document.getElementById('profileClearBtn');
if (clrBtn) clrBtn.style.display = 'none';
}
}
function _setProfileClearBtn(visible) {
const btn = document.getElementById('profileClearBtn');
if (btn) btn.style.display = visible ? 'inline-block' : 'none';
}
function onProfileChange() {
const sel = document.getElementById('profileSelect');
const id = sel.value;
if (!id) return; // placeholder can't be selected (disabled), guard anyway
const profile = S._profiles.find(p => p.id === id);
if (!profile) return;
S._activeProfileId = id;
_setProfileClearBtn(true);
_applyProfile(profile);
}
// Clear the active profile label without touching sidebar settings.
// The sidebar already reflects the loaded (or manually adjusted) state.
function clearActiveProfile() {
S._activeProfileId = null;
const sel = document.getElementById('profileSelect');
if (sel) sel.value = '';
_setProfileClearBtn(false);
}
function _applyProfile(profile) {
// ── Sources ──────────────────────────────────────────────────────────────
// Restore source selections from profile — works for both M365 and file sources.
// File sources may not be rendered yet (they load async), so store their IDs
// in S._pendingProfileSources for renderSourcesPanel() to apply after re-render.
const profileSources = profile.sources || [];
document.querySelectorAll('#sourcesPanel input[data-source-id]').forEach(function(cb) {
cb.checked = profileSources.includes(cb.dataset.sourceId);
});
_updateAccountsVisibility();
// Deferred file sources — store IDs now, apply when _loadFileSources() resolves.
// Don't filter against S._fileSources here — it may be empty at this point.
const _knownSourceIds = new Set(['email', 'onedrive', 'sharepoint', 'teams', 'gmail', 'gdrive']);
S._pendingProfileSources = (profile.file_sources && profile.file_sources.length)
? profile.file_sources.slice()
: profileSources.filter(function(id) { return !_knownSourceIds.has(id); });
// Deferred Google sources — store IDs now, apply when smGoogleRefreshStatus() resolves.
const googleIds = profile.google_sources
|| profileSources.filter(function(id) { return id === 'gmail' || id === 'gdrive'; });
S._pendingGoogleSources = googleIds.slice();
// ── Options ───────────────────────────────────────────────────────────────
const opts = profile.options || {};
if (opts.email_body !== undefined) {
const el = document.getElementById('optEmailBody');
if (el) el.checked = opts.email_body;
}
if (opts.attachments !== undefined) {
const el = document.getElementById('optAttachments');
if (el) {
el.checked = opts.attachments;
// Update the size row opacity directly
const sizeRow = document.getElementById('attachSizeRow');
if (sizeRow) sizeRow.style.opacity = opts.attachments ? '1' : '0.4';
}
}
if (opts.max_attach_mb !== undefined) {
const el = document.getElementById('optMaxAttachMB');
if (el) el.value = opts.max_attach_mb;
}
if (opts.max_emails !== undefined) {
const el = document.getElementById('optMaxEmails');
if (el) el.value = opts.max_emails;
}
if (opts.delta !== undefined) {
const el = document.getElementById('optDelta');
if (el) el.checked = opts.delta;
}
if (opts.scan_photos !== undefined) {
const el = document.getElementById('optScanPhotos');
if (el) el.checked = opts.scan_photos;
}
// ── Date filter ───────────────────────────────────────────────────────────
const days = opts.older_than_days;
if (days !== undefined) {
const hidden = document.getElementById('olderThan');
const dateIn = document.getElementById('olderThanDate');
const presets = document.querySelectorAll('.date-preset');
if (hidden) hidden.value = days;
if (dateIn) {
if (!days) {
dateIn.value = '';
} else {
const d = new Date();
d.setDate(d.getDate() - days);
dateIn.value = d.toISOString().slice(0, 10);
}
}
// Highlight matching preset button
presets.forEach(p => {
const y = parseInt(p.dataset.years || '0');
const presetDays = y === 0 ? 0 : y * 365;
if (y === 0) {
p.classList.toggle('selected', !days);
} else {
p.classList.toggle('selected', days > 0 && presetDays === days);
}
});
}
// ── Retention ─────────────────────────────────────────────────────────────
const retEnabled = !!(opts.retention_enabled || profile.retention_years);
const retEl = document.getElementById('optRetention');
if (retEl) {
retEl.checked = retEnabled;
// Show/hide panel directly
const panel = document.getElementById('retentionPanel');
if (panel) panel.style.display = retEnabled ? 'block' : 'none';
}
if (profile.retention_years) {
const el = document.getElementById('optRetentionYears');
if (el) el.value = profile.retention_years;
}
if (profile.fiscal_year_end) {
const el = document.getElementById('optFiscalYearEnd');
if (el) el.value = profile.fiscal_year_end;
}
updateRetentionCutoffHint && updateRetentionCutoffHint();
// ── User selection ────────────────────────────────────────────────────────
if (profile.user_ids === 'all') {
S._allUsers.forEach(u => { u.selected = true; });
if (S._allUsers.length) renderAccountList();
} else if (Array.isArray(profile.user_ids) && profile.user_ids.length) {
window._pendingProfileUserIds = profile.user_ids.map(u => u.id || u);
_applyPendingProfileUsers();
} else if (Array.isArray(profile.user_ids) && profile.user_ids.length === 0) {
// Explicitly empty list — deselect everyone so previous sidebar state doesn't persist
S._allUsers.forEach(u => { u.selected = false; });
if (S._allUsers.length) renderAccountList();
}
log(t('m365_profile_applied', 'Profile loaded') + ': ' + profile.name);
}
function _applyPendingProfileUsers() {
const ids = window._pendingProfileUserIds;
if (!ids || !ids.length || !S._allUsers.length) return;
// Select only the users listed in the profile
S._allUsers.forEach(u => { u.selected = ids.includes(u.id); });
renderAccountList();
window._pendingProfileUserIds = null;
}
async function saveCurrentAsProfile() {
const name = prompt(t('m365_profile_save_prompt', 'Profile name:'),
S._activeProfileId
? (S._profiles.find(p => p.id === S._activeProfileId) || {}).name || ''
: '');
if (!name) return;
const { sources, fileSources, googleSources, allSources, user_ids, options } = buildScanPayload();
const existing = S._profiles.find(p => p.name.toLowerCase() === name.toLowerCase());
const profile = {
id: existing?.id || '',
name,
description: existing?.description || '',
sources: allSources,
google_sources: googleSources,
user_ids,
options,
retention_years: parseInt(document.getElementById('optRetentionYears')?.value) || null,
fiscal_year_end: document.getElementById('optFiscalYearEnd')?.value || '',
email_to: '',
file_sources: fileSources,
};
try {
const r = await fetch('/api/profiles/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(profile)
});
const d = await r.json();
if (d.error) { alert(d.error); return; }
await loadProfiles();
// Select the newly saved profile
const sel = document.getElementById('profileSelect');
if (sel) { sel.value = d.profile.id; S._activeProfileId = d.profile.id; _setProfileClearBtn(true); }
log(t('m365_profile_saved', 'Profile saved') + ': ' + name);
} catch(e) {
alert('Save failed: ' + e.message);
}
}
// ── Profile management modal (#15d) ──────────────────────────────────────────
function openProfileMgmtModal() {
try { _renderProfileMgmt(); } catch(e) { console.error('[profiles] _renderProfileMgmt threw:', e); }
document.getElementById('pmgmtBackdrop').classList.add('open');
// Auto-open editor for the first profile
if (S._profiles.length > 0) {
try { _pmgmtOpenEditor(S._profiles[0].id); } catch(e) { console.error('[profiles] _pmgmtOpenEditor threw:', e); }
}
}
function closeProfileMgmt() {
document.getElementById('pmgmtBackdrop').classList.remove('open');
}
function _sourceLabel(id) {
const known = {email:'Outlook', onedrive:'OneDrive', sharepoint:'SharePoint', teams:'Teams', gmail:'Gmail', gdrive:'Google Drive'};
if (known[id]) return known[id];
const fs = S._fileSources.find(s => s.id === id);
return fs ? (fs.label || fs.path || id) : id;
}
function _renderProfileMgmt() {
const list = document.getElementById('pmgmtList');
if (!list) return;
const saved = S._profiles.filter(p => p.name !== 'Default' || S._profiles.length === 1);
if (!saved.length) {
list.innerHTML = `<div class="pmgmt-empty">${t('m365_profile_no_profiles','No saved profiles yet. Use 💾 to save the current sidebar settings as a profile.')}</div>`;
return;
}
list.innerHTML = '';
for (const p of S._profiles) {
const sources = (p.sources || []).map(_sourceLabel).join(', ') || '—';
const lastRun = p.last_run ? p.last_run.slice(0,16).replace('T',' ') : t('m365_profile_never','never');
const isActive = p.id === S._activeProfileId;
const row = document.createElement('div');
row.className = 'pmgmt-row';
row.dataset.id = p.id;
row.onclick = function() { _pmgmtOpenEditor(p.id); };
row.innerHTML = `
<div class="pmgmt-row-head">
<span class="pmgmt-name">${_esc(p.name)}${isActive ? ' <span style="color:var(--accent);font-weight:400;font-size:10px">● activ</span>' : ''}</span>
<div class="pmgmt-actions">
<div style="display:flex;border:1px solid var(--border);border-radius:5px;overflow:hidden">
<button class="btn-use" onclick="event.stopPropagation();_pmgmtUse('${p.id}')" style="border-radius:0;border:none;border-right:1px solid var(--border)" data-i18n="m365_profile_use">Brug</button>
<button onclick="event.stopPropagation();_pmgmtDuplicate('${p.id}')" style="border-radius:0;border:none" data-i18n="m365_profile_duplicate">Kopier</button>
</div>
<button class="btn-del" onclick="event.stopPropagation();_pmgmtDelete('${p.id}','${_esc(p.name)}')" data-i18n="m365_profile_delete">Slet</button>
</div>
</div>
<div class="pmgmt-sources">${_esc(sources)}</div>
${p.description ? `<div class="pmgmt-desc">${_esc(p.description)}</div>` : ''}
<div class="pmgmt-meta">${t('m365_profile_last_run','Last run')}: ${lastRun}</div>
`;
list.appendChild(row);
}
}
function _esc(s) {
return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function _pmgmtUse(id) {
const profile = S._profiles.find(p => p.id === id);
if (!profile) return;
S._activeProfileId = id;
_setProfileClearBtn(true);
_applyProfile(profile);
// Sync the topbar dropdown
const sel = document.getElementById('profileSelect');
if (sel) sel.value = id;
closeProfileMgmt();
}
function _pmgmtOpenEditor(id) {
const profile = S._profiles.find(p => p.id === id);
if (!profile) return;
_openEditorForProfile(profile);
}
function _openEditorForProfile(profile) {
const id = profile.id || '';
window._pmgmtEditId = id;
_pmgmtRoleActive = '';
// Highlight active row
document.querySelectorAll('.pmgmt-row').forEach(r => r.classList.toggle('active', id && r.dataset.id === id));
document.getElementById('pmgmtEditorTitle').textContent = profile.name;
const body = document.getElementById('pmgmtEditorBody');
const allSources = profile.sources || [];
const opts = profile.options || {};
const srcCheck = (id) => allSources.includes(id) ? 'checked' : '';
// Build account list from S._allUsers
const savedIds = new Set((profile.user_ids || []).map(u => u.id || u));
// If no saved IDs match current users, treat as all-selected (new profile or users changed)
const anyMatch = savedIds.size > 0 && S._allUsers.some(u => savedIds.has(u.id));
const accountRows = S._allUsers.map(u => {
// Only check if the user was explicitly saved — default to unchecked like the main window
const checked = anyMatch && savedIds.has(u.id) ? 'checked' : '';
const platBadge = u.platform === 'both' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:linear-gradient(90deg,#E6F1FB 50%,#EAF3DE 50%);color:#1a4a1a;font-weight:500;border:0.5px solid #b5d4b5">M365+GWS</span>'
: (u.platform || 'm365') === 'google' ? '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#EAF3DE;color:#3B6D11;font-weight:500">GWS</span>'
: '<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#E6F1FB;color:#185FA5;font-weight:500">M365</span>';
const roleBadge = u.userRole === 'student' ? t('role_student','Elev') : u.userRole === 'staff' ? t('role_staff','Ansat') : t('role_other','Anden');
return `<label class="pmgmt-acct-row" data-uid="${_esc(u.id)}"><input type="checkbox" ${checked} data-uid="${_esc(u.id)}"><span style="flex:1;color:var(--color-text-primary);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(u.displayName)}</span>${platBadge}<span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">${roleBadge}</span></label>`;
}).join('');
body.innerHTML = `
<div>
<div class="pmgmt-editor-section-title">Navn</div>
<input id="pmgmtEditName" type="text" value="${_esc(profile.name)}" style="width:100%;margin-bottom:6px">
<textarea id="pmgmtEditDesc" style="width:100%;font-size:12px;height:44px;resize:none" placeholder="Beskrivelse (valgfri)">${_esc(profile.description || '')}</textarea>
</div>
<div style="display:flex;gap:0;flex:1;min-height:0">
<div style="flex:1;display:flex;flex-direction:column;gap:14px;overflow-y:auto;padding-right:16px">
<div>
<div class="pmgmt-editor-section-title">Kilder</div>
<div id="peSourcesPanel"></div>
</div>
<div>
<div class="pmgmt-editor-section-title">
<span>Konti</span>
<div style="display:flex;gap:4px;align-items:center">
<div style="display:flex;background:var(--bg);border:1px solid var(--border);border-radius:6px;overflow:hidden">
<button type="button" id="peRoleAll" onclick="_pmgmtRoleFilter('')" style="font-size:10px;height:22px;padding:0 7px;border:none;border-right:1px solid var(--border);background:var(--accent);color:#fff;cursor:pointer;box-sizing:border-box">${t('m365_filter_all','Alle')}</button>
<button type="button" id="peRoleStaff" onclick="_pmgmtRoleFilter('staff')" style="font-size:10px;height:22px;padding:0 7px;border:none;border-right:1px solid var(--border);background:none;color:var(--muted);cursor:pointer;box-sizing:border-box">${t('role_staff','Ansat')}</button>
<button type="button" id="peRoleStudent" onclick="_pmgmtRoleFilter('student')" style="font-size:10px;height:22px;padding:0 7px;border:none;background:none;color:var(--muted);cursor:pointer;box-sizing:border-box">${t('role_student','Elev')}</button>
</div>
<div style="display:flex;background:var(--bg);border:1px solid var(--border);border-radius:6px;overflow:hidden">
<button type="button" onclick="_pmgmtSelectAllAccounts(true)" style="font-size:10px;height:22px;padding:0 7px;border:none;border-right:1px solid var(--border);background:none;color:var(--muted);cursor:pointer;box-sizing:border-box">${t('btn_all','Alle')}</button>
<button type="button" onclick="_pmgmtSelectAllAccounts(false)" style="font-size:10px;height:22px;padding:0 7px;border:none;background:none;color:var(--muted);cursor:pointer;box-sizing:border-box">${t('btn_none','Ingen')}</button>
</div>
</div>
</div>
<div style="display:flex;gap:6px;margin-bottom:4px">
<input type="text" id="pmgmtAcctSearch" placeholder="Søg konti…" style="flex:1;font-size:12px" oninput="_pmgmtFilterAccounts(this.value)">
<button type="button" onclick="_pmgmtAddManual()" style="font-size:11px;padding:3px 10px;border-radius:5px;border:1px solid var(--border);background:none;color:var(--muted);cursor:pointer;white-space:nowrap">+ Tilføj konto</button>
</div>
<div class="pmgmt-account-list" id="pmgmtAcctList">${accountRows}</div>
</div>
</div>
<div class="pmgmt-settings-col" style="overflow-y:auto">
<div class="pmgmt-editor-section-title">Indstillinger</div>
<div style="display:flex;flex-direction:column;gap:6px;font-size:12px">
<label style="font-size:11px;color:var(--muted)">${t('m365_opt_date_from','Scan e-mails/filer fra')}</label>
<div class="datepicker-wrap">
<input type="date" id="peOptDate" autocomplete="off" value="${(function(){ if(!opts.older_than_days) return ''; var d=new Date(); d.setDate(d.getDate()-opts.older_than_days); return d.toISOString().slice(0,10); }())}" onchange="_peSetDate(this.value)">
<div class="date-presets">
<button type="button" class="date-preset peYearBtn ${(opts.older_than_days||0)===365 ? 'selected' : ''}" data-years="1" onclick="_peSetYear(1)">${t('m365_preset_1yr','1 år')}</button>
<button type="button" class="date-preset peYearBtn ${(opts.older_than_days||0)===730 ? 'selected' : ''}" data-years="2" onclick="_peSetYear(2)">${t('m365_preset_2yr','2 år')}</button>
<button type="button" class="date-preset peYearBtn ${(opts.older_than_days||0)===1825 ? 'selected' : ''}" data-years="5" onclick="_peSetYear(5)">${t('m365_preset_5yr','5 år')}</button>
<button type="button" class="date-preset peYearBtn ${(opts.older_than_days||0)===3650 ? 'selected' : ''}" data-years="10" onclick="_peSetYear(10)">${t('m365_preset_10yr','10 år')}</button>
<button type="button" class="date-preset peYearBtn ${!(opts.older_than_days) ? 'selected' : ''}" data-years="0" onclick="_peSetYear(0)">${t('m365_preset_any','Alle')}</button>
</div>
</div>
<input type="hidden" id="peOptDays" value="${opts.older_than_days || 0}">
<hr style="border:none;border-top:1px solid var(--pmgmt-divider);margin:2px 0">
<div class="pmgmt-opt-row"><span>${t('m365_opt_email_body','Scan e-mailindhold')}</span><label class="toggle"><input type="checkbox" id="peOptBody" ${opts.email_body !== false ? 'checked' : ''}><span class="toggle-slider"></span></label></div>
<div class="pmgmt-opt-row"><span>${t('m365_opt_attachments','Scan vedhæftede filer')}</span><label class="toggle"><input type="checkbox" id="peOptAtt" ${opts.attachments !== false ? 'checked' : ''}><span class="toggle-slider"></span></label></div>
<div class="pmgmt-opt-row"><span style="color:var(--muted)">${t('m365_opt_max_attach','Maks. vedhæftet filstørrelse (MB)')}</span><input type="number" id="peOptMaxAttach" value="${opts.max_attach_mb || 20}" min="1" max="100" style="width:46px;padding:3px 6px;font-size:11px;text-align:right"></div>
<div class="pmgmt-opt-row"><span>${t('m365_opt_max_emails','Maks. e-mails pr. bruger')}</span><input type="number" id="peOptMaxEmails" value="${opts.max_emails || 2000}" min="10" max="50000" style="width:56px;padding:3px 6px;font-size:11px;text-align:right"></div>
<div class="pmgmt-opt-row"><span>${t('m365_opt_delta','Delta-scanning')}</span><label class="toggle"><input type="checkbox" id="peOptDelta" ${opts.delta ? 'checked' : ''}><span class="toggle-slider"></span></label></div>
<div class="pmgmt-opt-row"><span>${t('m365_opt_scan_photos','Søg efter ansigter i billeder')}</span><label class="toggle"><input type="checkbox" id="peOptPhotos" ${opts.scan_photos ? 'checked' : ''}><span class="toggle-slider"></span></label></div>
<hr style="border:none;border-top:1px solid var(--pmgmt-divider);margin:2px 0">
<div class="pmgmt-opt-row"><span>${t('m365_opt_retention','Opbevaringspolitik')}</span><label class="toggle"><input type="checkbox" id="peOptRetention" ${profile.retention_years ? 'checked' : ''}><span class="toggle-slider"></span></label></div>
<div style="padding:7px 8px;background:var(--bg);border-radius:6px">
<div class="pmgmt-opt-row" style="margin-bottom:5px"><span style="color:var(--muted)">${t('m365_ret_years','Opbevaringsår')}</span><input type="number" id="peOptRetYears" value="${profile.retention_years || 5}" min="1" max="30" style="width:46px;padding:3px 6px;font-size:11px;text-align:right"></div>
<div style="display:flex;flex-direction:column;gap:3px">
<label style="font-size:11px;color:var(--muted)">${t('m365_ret_fy_end','Regnskabsår slut')}</label>
<select id="peOptFiscalYearEnd" style="font-size:11px;padding:3px 6px;width:100%">
<option value="" ${!profile.fiscal_year_end ? 'selected' : ''}>${t('m365_ret_fy_rolling','Rullende (i dag)')}</option>
<option value="12-31" ${profile.fiscal_year_end==='12-31' ? 'selected' : ''}>${t('m365_ret_fy_dec','31 dec (Bogføringsloven)')}</option>
<option value="06-30" ${profile.fiscal_year_end==='06-30' ? 'selected' : ''}>${t('m365_ret_fy_jun','30 jun')}</option>
<option value="03-31" ${profile.fiscal_year_end==='03-31' ? 'selected' : ''}>${t('m365_ret_fy_mar','31 mar')}</option>
</select>
</div>
</div>
</div>
</div>
</div>
`;;
document.getElementById('pmgmtEditorPlaceholder')?.remove();
document.getElementById('pmgmtEditor').classList.add('open');
_renderEditorSources((profile.sources || []).concat(profile.google_sources || []).concat(profile.file_sources || []));
}
function _peSetDate(val) {
if (!val) return;
const ms = new Date() - new Date(val);
const days = Math.round(ms / 86400000);
const hidden = document.getElementById('peOptDays');
if (hidden) hidden.value = days;
// Clear selected year buttons since user picked a custom date
document.querySelectorAll('.peYearBtn').forEach(b => b.classList.remove('selected'));
}
function _peSetYear(years) {
const days = years === 0 ? 0 : years * 365;
const hidden = document.getElementById('peOptDays');
if (hidden) hidden.value = days;
document.querySelectorAll('.peYearBtn').forEach(function(btn) {
const y = parseInt(btn.dataset.years);
const active = (years === 0 && y === 0) || (years > 0 && y === years);
btn.classList.toggle('selected', active);
});
// Sync the date input
var dateInput = document.getElementById('peOptDate');
if (dateInput) {
if (days === 0) { dateInput.value = ''; }
else { var d = new Date(); d.setDate(d.getDate()-days); dateInput.value = d.toISOString().slice(0,10); }
}
}
function _renderEditorSources(checkedIds) {
const panel = document.getElementById('peSourcesPanel');
if (!panel) return;
let html = '';
_M365_SOURCES.forEach(function(s) {
const toggle = s.toggleId ? document.getElementById(s.toggleId) : null;
if (toggle && !toggle.checked) return;
const isChecked = checkedIds.includes(s.id);
html += '<label class="source-check">'
+ '<input type="checkbox" data-source-id="' + s.id + '" data-source-type="m365"' + (isChecked ? ' checked' : '') + '>'
+ '<span class="source-icon">' + s.icon + '</span>'
+ '<span class="source-label">' + t(s.labelKey, s.labelDefault) + '</span>'
+ '</label>';
});
if (window._googleConnected) {
var gmailOn = !document.getElementById('smGoogleSrcGmail') || document.getElementById('smGoogleSrcGmail').checked;
var driveOn = !document.getElementById('smGoogleSrcDrive') || document.getElementById('smGoogleSrcDrive').checked;
if (gmailOn || driveOn) html += '<hr style="border:none;border-top:1px solid var(--border);margin:4px 0">';
if (gmailOn) {
html += '<label class="source-check"><input type="checkbox" data-source-id="gmail" data-source-type="google"' + (checkedIds.includes('gmail') ? ' checked' : '') + '><span class="source-icon">📧</span><span class="source-label">Gmail</span></label>';
}
if (driveOn) {
html += '<label class="source-check"><input type="checkbox" data-source-id="gdrive" data-source-type="google"' + (checkedIds.includes('gdrive') ? ' checked' : '') + '><span class="source-icon">📁</span><span class="source-label">Google Drive</span></label>';
}
}
if (S._fileSources.length > 0) {
html += '<hr style="border:none;border-top:1px solid var(--border);margin:4px 0">';
S._fileSources.forEach(function(s) {
const isSmb = s.path && (s.path.startsWith('//') || s.path.startsWith('\\\\'));
html += '<label class="source-check"><input type="checkbox" data-source-id="' + _esc(s.id) + '" data-source-type="file"' + (checkedIds.includes(s.id) ? ' checked' : '') + '><span class="source-icon">' + (isSmb ? '🌐' : '📁') + '</span><span class="source-label" title="' + _esc(s.path||'') + '">' + _esc(s.label||s.path||s.id) + '</span></label>';
});
}
panel.innerHTML = html;
}
function _pmgmtNewProfile() {
// Create a blank profile shell and open the editor
const blank = {
id: '',
name: '',
description: '',
sources: [],
google_sources: [],
user_ids: [],
options: {},
file_sources: [],
};
// Temporarily add to S._profiles so the editor can find it
window._pmgmtNewDraft = blank;
_openEditorForProfile(blank);
}
function _pmgmtCloseEditor() {
document.getElementById('pmgmtEditor').classList.remove('open');
document.querySelectorAll('.pmgmt-row').forEach(r => r.classList.remove('active'));
window._pmgmtEditId = null;
closeProfileMgmt();
}
function _pmgmtSelectAllAccounts(checked) {
document.querySelectorAll('#pmgmtAcctList label input[type=checkbox]').forEach(function(cb) {
if (cb.closest('label').style.display !== 'none') cb.checked = checked;
});
}
let _pmgmtRoleActive = '';
function _pmgmtRoleFilter(role) {
_pmgmtRoleActive = role;
// Update button styles
['peRoleAll','peRoleStaff','peRoleStudent'].forEach(function(id) {
const btn = document.getElementById(id);
if (!btn) return;
const isActive = (id === 'peRoleAll' && role === '') || (id === 'peRoleStaff' && role === 'staff') || (id === 'peRoleStudent' && role === 'student');
btn.style.background = isActive ? 'var(--accent)' : 'none';
btn.style.color = isActive ? '#fff' : 'var(--muted)';
btn.style.border = isActive ? '1px solid var(--accent)' : '1px solid var(--border)';
});
// Apply filter combined with any active text search
_pmgmtFilterAccounts(document.getElementById('pmgmtAcctSearch')?.value || '');
}
function _pmgmtAddManual() {
const email = prompt('E-mail adresse:');
if (!email || !email.trim()) return;
const list = document.getElementById('pmgmtAcctList');
if (!list) return;
const id = 'manual:' + email.trim().toLowerCase();
if (list.querySelector(`input[data-uid="${id}"]`)) return; // already exists
const lbl = document.createElement('label');
lbl.className = 'pmgmt-acct-row';
lbl.innerHTML = `<input type="checkbox" checked data-uid="${_esc(id)}"><span style="flex:1;color:var(--text);overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${_esc(email.trim())}</span><span style="font-size:9px;padding:1px 5px;border-radius:10px;background:#D3D1C7;color:#444441">Manuel</span>`;
list.appendChild(lbl);
}
function _pmgmtFilterAccounts(q) {
q = (q || '').toLowerCase();
document.querySelectorAll('#pmgmtAcctList label').forEach(function(lbl) {
var name = (lbl.querySelector('span') || {}).textContent || '';
var uid = lbl.querySelector('input')?.dataset?.uid || '';
var user = S._allUsers.find(u => u.id === uid);
var roleOk = !_pmgmtRoleActive || (user && user.userRole === _pmgmtRoleActive);
var nameOk = !q || name.toLowerCase().includes(q);
lbl.style.display = (roleOk && nameOk) ? '' : 'none';
});
}
async function _pmgmtSaveFullEdit() {
const id = window._pmgmtEditId;
const profile = (id ? S._profiles.find(p => p.id === id) : null) || window._pmgmtNewDraft || {};
const name = document.getElementById('pmgmtEditName')?.value?.trim();
if (!name) { alert(t('m365_profile_name_required','Profile name is required.')); return; }
const peSources = Array.from(document.querySelectorAll('#peSourcesPanel input[type=checkbox]:checked'));
const m365Sources = peSources.filter(cb => cb.dataset.sourceType === 'm365').map(cb => cb.dataset.sourceId);
const googleSources = peSources.filter(cb => cb.dataset.sourceType === 'google').map(cb => cb.dataset.sourceId);
const fileSources = peSources.filter(cb => cb.dataset.sourceType === 'file').map(cb => cb.dataset.sourceId);
// Check whether the checkboxes were actually rendered in the editor DOM —
// NOT whether Google is connected or file sources are loaded. Those are async
// and may not have resolved when the editor first opened, leaving the panel
// without checkboxes even though the connection exists. Using the DOM as the
// source of truth avoids a race-condition that silently cleared google/file sources.
const googleRendered = !!document.querySelector('#peSourcesPanel input[data-source-type="google"]');
const fileRendered = !!document.querySelector('#peSourcesPanel input[data-source-type="file"]');
const effectiveGoogleSources = googleRendered ? googleSources : (profile.google_sources || []);
const effectiveFileSources = fileRendered ? fileSources : (profile.file_sources || []);
const allSources = m365Sources.concat(effectiveGoogleSources).concat(effectiveFileSources);
const user_ids = Array.from(document.querySelectorAll('#pmgmtAcctList input[type=checkbox]:checked'))
.map(cb => cb.dataset.uid)
.filter(Boolean);
const updated = {
...profile,
name,
description: document.getElementById('pmgmtEditDesc')?.value?.trim() || '',
sources: allSources,
google_sources: effectiveGoogleSources,
file_sources: effectiveFileSources,
user_ids,
options: {
...(profile.options || {}),
older_than_days: parseInt(document.getElementById('peOptDays')?.value) || 0,
email_body: document.getElementById('peOptBody')?.checked ?? true,
attachments: document.getElementById('peOptAtt')?.checked ?? true,
max_attach_mb: parseInt(document.getElementById('peOptMaxAttach')?.value) || 20,
max_emails: parseInt(document.getElementById('peOptMaxEmails')?.value) || 2000,
delta: document.getElementById('peOptDelta')?.checked ?? false,
scan_photos: document.getElementById('peOptPhotos')?.checked ?? false,
},
retention_years: document.getElementById('peOptRetention')?.checked ? (parseInt(document.getElementById('peOptRetYears')?.value) || 5) : null,
fiscal_year_end: document.getElementById('peOptRetention')?.checked ? (document.getElementById('peOptFiscalYearEnd')?.value || '') : '',
};
try {
const r = await fetch('/api/profiles/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(updated)
});
const d = await r.json();
if (d.error) { alert(d.error); return; }
await loadProfiles();
window._pmgmtNewDraft = null;
log(t('m365_profile_saved','Profile saved') + ': ' + name);
// Show inline saved feedback without closing the modal
const footer = document.querySelector('#pmgmtEditor > div:last-child');
if (footer) {
const fb = document.createElement('span');
fb.textContent = '✓ ' + t('m365_profile_saved', 'Saved');
fb.style.cssText = 'font-size:11px;color:var(--success);margin-right:auto';
footer.prepend(fb);
setTimeout(function() { fb.remove(); }, 2000);
}
// Re-open the editor for the saved profile so it reflects the saved state
const saved = S._profiles.find(function(p) { return p.name === name; });
if (saved) { window._pmgmtEditId = saved.id; }
} catch(e) { alert('Save failed: ' + e.message); }
}
async function _pmgmtSaveEdit(id) {
const name = document.getElementById(`pmgmt-edit-name-${id}`)?.value?.trim();
const desc = document.getElementById(`pmgmt-edit-desc-${id}`)?.value?.trim();
if (!name) { alert(t('m365_profile_name_required','Profile name is required.')); return; }
const profile = S._profiles.find(p => p.id === id);
if (!profile) return;
const updated = { ...profile, name, description: desc || '' };
try {
const r = await fetch('/api/profiles/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(updated)
});
const d = await r.json();
if (d.error) { alert(d.error); return; }
await loadProfiles();
_renderProfileMgmt();
log(t('m365_profile_saved','Profile saved') + ': ' + name);
} catch(e) { alert('Save failed: ' + e.message); }
}
async function _pmgmtDuplicate(id) {
const profile = S._profiles.find(p => p.id === id);
if (!profile) return;
const base = profile.name.replace(/ \(copy( \d+)?\)$/, '');
// Find a unique name
let n = 1, name = base + ' (copy)';
while (S._profiles.some(p => p.name === name)) { n++; name = `${base} (copy ${n})`; }
const copy = { ...profile, id: '', name, last_run: null, last_scan_id: null };
try {
const r = await fetch('/api/profiles/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(copy)
});
const d = await r.json();
if (d.error) { alert(d.error); return; }
await loadProfiles();
_renderProfileMgmt();
log(t('m365_profile_duplicated','Profile duplicated') + ': ' + name);
} catch(e) { alert('Duplicate failed: ' + e.message); }
}
async function _pmgmtDelete(id, name) {
if (!confirm(t('m365_profile_delete_confirm','Delete profile') + ' "' + name + '"?')) return;
try {
const r = await fetch('/api/profiles/delete', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ id })
});
const d = await r.json();
if (d.error) { alert(d.error); return; }
if (S._activeProfileId === id) { S._activeProfileId = null; _setProfileClearBtn(false); }
await loadProfiles();
_renderProfileMgmt();
log(t('m365_profile_deleted','Profile deleted') + ': ' + name);
} catch(e) { alert('Delete failed: ' + e.message); }
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.loadProfiles = loadProfiles;
window._renderProfileSelect = _renderProfileSelect;
window._setProfileClearBtn = _setProfileClearBtn;
window.onProfileChange = onProfileChange;
window.clearActiveProfile = clearActiveProfile;
window._applyProfile = _applyProfile;
window._applyPendingProfileUsers = _applyPendingProfileUsers;
window.saveCurrentAsProfile = saveCurrentAsProfile;
window.openProfileMgmtModal = openProfileMgmtModal;
window.closeProfileMgmt = closeProfileMgmt;
window._sourceLabel = _sourceLabel;
window._renderProfileMgmt = _renderProfileMgmt;
window._esc = _esc;
window._pmgmtUse = _pmgmtUse;
window._pmgmtOpenEditor = _pmgmtOpenEditor;
window._openEditorForProfile = _openEditorForProfile;
window._peSetDate = _peSetDate;
window._peSetYear = _peSetYear;
window._renderEditorSources = _renderEditorSources;
window._pmgmtNewProfile = _pmgmtNewProfile;
window._pmgmtCloseEditor = _pmgmtCloseEditor;
window._pmgmtSelectAllAccounts = _pmgmtSelectAllAccounts;
window._pmgmtRoleFilter = _pmgmtRoleFilter;
window._pmgmtAddManual = _pmgmtAddManual;
window._pmgmtFilterAccounts = _pmgmtFilterAccounts;
window._pmgmtSaveFullEdit = _pmgmtSaveFullEdit;
window._pmgmtSaveEdit = _pmgmtSaveEdit;
window._pmgmtDuplicate = _pmgmtDuplicate;
window._pmgmtDelete = _pmgmtDelete;
window._pmgmtRoleActive = _pmgmtRoleActive;
+886
View File
@@ -0,0 +1,886 @@
import { S } from './state.js';
// ── Cards ─────────────────────────────────────────────────────────────────────
const SOURCE_BADGES = {
email: ['📧', 'badge-email', 'Outlook'],
gmail: ['📧', 'badge-gmail', 'Gmail'],
gdrive: ['📁', 'badge-gdrive', 'GDrive'],
onedrive: ['💾', 'badge-onedrive', 'OneDrive'],
sharepoint: ['🌐', 'badge-sharepoint', 'SharePoint'],
teams: ['💬', 'badge-teams', 'Teams'],
local: ['📁', 'badge-local', 'Local'],
smb: ['🌐', 'badge-smb', 'Network'],
};
function appendCard(f) {
const search = document.getElementById('filterSearch').value.trim().toLowerCase();
const srcVal = document.getElementById('filterSource').value;
if (search && !f.name.toLowerCase().includes(search)) return;
if (srcVal && f.source_type !== srcVal) return;
const grid = document.getElementById('grid');
const [icon, badgeCls, label] = SOURCE_BADGES[f.source_type] || ['📄', '', f.source_type];
const src = f.thumb_b64
? 'data:' + f.thumb_mime + ';base64,' + f.thumb_b64
: '/api/thumb?name=' + encodeURIComponent(f.name) + '&type=' + encodeURIComponent(f.source_type);
const card = document.createElement('div');
card.className = 'card' + (S.isListView ? ' list-view' : '');
card.dataset.id = f.id;
card.onclick = () => openPreview(f);
const delBtn = window.VIEWER_MODE ? '' : `<button class="card-delete-btn" title="${t('m365_delete_confirm','Delete')}" onclick="event.stopPropagation();deleteItem(${JSON.stringify(f).replace(/"/g,'&quot;')},this.closest('.card'))">🗑</button>`;
if (S.isListView) {
card.innerHTML = `
<div style="font-size:24px; flex-shrink:0">${icon}</div>
<div class="card-info list-info">
<div class="card-name" title="${f.name}">${f.name}</div>
<div class="card-meta">${f.size_kb} KB · ${f.modified || ''}${f.folder ? ' · 📂 ' + f.folder : ''}</div>
<div class="card-source"><span class="source-badge ${badgeCls}">${label}</span> ${f.source || ''}${f.account_name ? ' · <span class="account-pill" title="' + f.account_name + '">' + (f.user_role === 'student' ? '<span class="role-badge">' + t('role_student','Elev') + '</span>' : f.user_role === 'staff' ? '<span class="role-badge">' + t('role_staff','Ansat') + '</span>' : '') + f.account_name + '</span>' : ''}${f.transfer_risk === 'external-recipient' ? ' <span class="role-pill" style="background:#7B2D00;color:#FFD0B0">⚠ Ext.</span>' : f.transfer_risk ? ' <span class="role-pill" style="background:#003D7B;color:#B0D4FF">🔗</span>' : ''}</div>
</div>
<span class="cpr-badge">${f.cpr_count} CPR</span>
${f.face_count > 0 ? '<span class="photo-face-badge">' + f.face_count + ' ' + t('m365_badge_faces', f.face_count === 1 ? 'face' : 'faces') + '</span> ' : ''}
${f.exif && f.exif.gps ? '<span class="photo-face-badge" style="background:#0a3a5a;color:#7ec8d0">🌍 GPS</span> ' : ''}
${f.special_category && f.special_category.length ? '<span class="special-cat-badge">⚠ Art.9 — ' + f.special_category.filter(function(s){return s !== 'gps_location' && s !== 'exif_pii';}).join(', ') + '</span> ' : ''}${f.overdue ? '<span class="overdue-badge">🗓 Overdue</span>' : ''}
${delBtn}`;
} else {
card.innerHTML = `
<div class="thumb-wrap"><img src="${src}" alt="${f.name}" loading="lazy"></div>
<div class="card-info">
<div class="card-name" title="${f.name}">${f.name}</div>
<div class="card-meta">${f.size_kb} KB · ${f.modified || ''}</div>
${f.folder ? `<div class="card-meta" style="font-size:10px" title="${f.folder}">📂 ${f.folder}</div>` : ''}
<div class="card-source"><span class="source-badge ${badgeCls}">${label}</span>${f.account_name ? ' <span class="account-pill" title="' + f.account_name + '">' + (f.user_role === "student" ? '<span class="role-badge">' + t("role_student","Elev") + "</span>" : f.user_role === "staff" ? '<span class="role-badge">' + t("role_staff","Ansat") + "</span>" : "") + f.account_name + '</span>' : ''}${f.transfer_risk === "external-recipient" ? ' <span class="role-pill" style="background:#7B2D00;color:#FFD0B0">⚠ Ext.</span>' : f.transfer_risk ? ' <span class="role-pill" style="background:#003D7B;color:#B0D4FF">🔗</span>' : ''}</div>
<span class="cpr-badge">${f.cpr_count} CPR</span>${f.face_count > 0 ? ' <span class="photo-face-badge">' + f.face_count + ' ' + t('m365_badge_faces', f.face_count === 1 ? 'face' : 'faces') + '</span>' : ''}${f.exif && f.exif.gps ? ' <span class="photo-face-badge" style="background:#0a3a5a;color:#7ec8d0">🌍 GPS</span>' : ''}${f.overdue ? ' <span class="overdue-badge">🗓 Overdue</span>' : ''}
</div>
${delBtn}`;
}
grid.appendChild(card);
}
function renderGrid(files) {
const grid = document.getElementById('grid');
grid.innerHTML = '';
files.forEach(f => appendCard(f));
}
// ── Preview panel ─────────────────────────────────────────────────────────────
let _previewItemId = null;
async function openPreview(f) {
// Highlight selected card
document.querySelectorAll('.card.selected').forEach(c => c.classList.remove('selected'));
const cardEl = document.querySelector(`.card[data-id="${CSS.escape(f.id)}"]`);
if (cardEl) cardEl.classList.add('selected');
const panel = document.getElementById('previewPanel');
const frame = document.getElementById('previewFrame');
const loading = document.getElementById('previewLoading');
const title = document.getElementById('previewTitle');
const meta = document.getElementById('previewMeta');
panel.classList.remove('hidden');
const _savedW = sessionStorage.getItem('gdpr_preview_width');
if (_savedW) panel.style.width = _savedW + 'px';
title.textContent = f.name;
frame.style.display = 'none';
loading.style.display = 'flex';
loading.textContent = 'Loading preview…';
meta.innerHTML = [
f.account_name ? `<span style="font-weight:500">👤 ${f.account_name}</span>` : '',
f.source ? `<span>${f.source}</span>` : '',
f.size_kb ? `<span>${f.size_kb} KB</span>` : '',
f.modified ? `<span>${f.modified}</span>` : '',
f.cpr_count ? `<span style="color:var(--danger)">${f.cpr_count} CPR</span>` : '',
f.url ? `<button class="preview-open-btn" onclick="window.open('${f.url}','_blank')">${t("m365_preview_open","Open in M365 ↗")}</button>` : '',
].filter(Boolean).join('');
_previewItemId = f.id;
loadDisposition(f.id); // load disposition for this item (#6)
try {
const r = await fetch('/api/preview/' + encodeURIComponent(f.id)
+ '?source_type=' + encodeURIComponent(f.source_type || '')
+ '&account_id=' + encodeURIComponent(f.account_id || ''));
const d = await r.json();
if (_previewItemId !== f.id) return; // stale — user clicked another card
if (d.error) {
loading.textContent = d.error;
return;
}
if (d.type === 'local') {
loading.style.display = 'none';
frame.style.display = 'block';
frame.srcdoc = `<html><body style="font-family:sans-serif;color:#ccc;background:#1e1e1e;padding:24px;display:flex;flex-direction:column;align-items:center;justify-content:center;height:80vh;gap:12px">
<div style="font-size:40px">📁</div>
<div style="font-size:14px;font-weight:600">${d.name || f.name}</div>
<div style="font-size:11px;color:#888">${t('m365_preview_local_file','Local file — no cloud preview available')}</div>
<div style="font-size:10px;color:#666;word-break:break-all;max-width:400px;text-align:center">${d.path || ''}</div>
</body></html>`;
return;
}
if (d.type === 'html' && d.html) {
loading.style.display = 'none';
frame.style.display = 'block';
const theme = document.body.dataset.theme === 'dark' ? '#1e1e1e' : '#ffffff';
const textColor = document.body.dataset.theme === 'dark' ? '#e0e0e0' : '#111111';
const mutedColor = document.body.dataset.theme === 'dark' ? '#888' : '#666';
frame.srcdoc = `<html><body style="margin:0;background:${theme};color:${textColor};font-family:sans-serif;--muted:${mutedColor};--text:${textColor};--mono:monospace">${d.html}</body></html>`;
return;
}
if (d.type === 'info' && d.html) {
loading.style.display = 'none';
frame.style.display = 'block';
const theme = document.body.dataset.theme === 'dark' ? '#1e1e1e' : '#ffffff';
frame.srcdoc = `<html><body style="margin:0;padding:20px;background:${theme};color:#888;font-family:sans-serif">${d.html}</body></html>`;
return;
}
if (d.type === 'iframe' && d.url) {
frame.src = d.url;
frame.onload = () => {
loading.style.display = 'none';
frame.style.display = 'block';
};
} else if (d.type === 'html') {
const blob = new Blob([d.html], {type: 'text/html'});
frame.src = URL.createObjectURL(blob);
frame.onload = () => {
loading.style.display = 'none';
frame.style.display = 'block';
};
} else {
loading.textContent = t('m365_preview_open','Open in M365') + ' — No preview available.';
}
} catch(e) {
loading.textContent = 'Preview failed: ' + e.message;
}
}
// ── Retention policy (#1) ────────────────────────────────────────────────────
function toggleRetentionPanel() {
const enabled = document.getElementById('optRetention').checked;
document.getElementById('retentionPanel').style.display = enabled ? 'block' : 'none';
if (enabled) updateRetentionCutoffHint();
}
function updateRetentionCutoffHint() {
const years = parseInt(document.getElementById('optRetentionYears')?.value) || 5;
const fyEnd = document.getElementById('optFiscalYearEnd')?.value || '';
const hint = document.getElementById('retentionCutoffHint');
if (!hint) return;
// Compute cutoff client-side for instant feedback
const today = new Date();
let cutoff;
if (fyEnd) {
const [mm, dd] = fyEnd.split('-').map(Number);
let fyEndDate = new Date(today.getFullYear(), mm - 1, dd);
if (fyEndDate >= today) fyEndDate = new Date(today.getFullYear() - 1, mm - 1, dd);
cutoff = new Date(fyEndDate); cutoff.setFullYear(cutoff.getFullYear() - years);
} else {
cutoff = new Date(today); cutoff.setFullYear(cutoff.getFullYear() - years);
}
const iso = cutoff.toISOString().split('T')[0];
const mode = fyEnd ? t('m365_ret_mode_fiscal', 'fiscal year') : t('m365_ret_mode_rolling', 'rolling');
hint.textContent = t('m365_ret_cutoff_hint', 'Items modified before') + ' ' + iso + ' (' + mode + ') ' + t('m365_ret_cutoff_flagged', 'will be flagged');
}
// Mark cards as overdue after scan completes or on load
async function markOverdueCards() {
const retentionEnabled = document.getElementById('optRetention')?.checked;
if (!retentionEnabled) return;
const years = parseInt(document.getElementById('optRetentionYears')?.value) || 5;
const fyEnd = document.getElementById('optFiscalYearEnd')?.value || '';
try {
const params = new URLSearchParams({years});
if (fyEnd) params.set('fiscal_year_end', fyEnd);
const r = await fetch('/api/db/overdue?' + params);
const d = await r.json();
if (!d.items) return;
const overdueIds = new Set(d.items.map(i => i.id));
// Mark S.flaggedData entries
S.flaggedData.forEach(f => { f.overdue = overdueIds.has(f.id); });
// Re-render to show badges
renderGrid(S.filteredData.length ? S.filteredData : S.flaggedData);
if (d.count > 0) {
log('🗓 ' + d.count + ' ' + t('m365_overdue_found', 'overdue item(s) found') + ' (cutoff: ' + d.cutoff_date + ')', 'warn');
}
} catch(e) { /* DB not available -- skip */ }
}
// Pre-filter bulk delete to overdue items
async function preFilterOverdue() {
const years = parseInt(document.getElementById('optRetentionYears')?.value) || 5;
const fyEnd = document.getElementById('optFiscalYearEnd')?.value || '';
try {
const params = new URLSearchParams({years});
if (fyEnd) params.set('fiscal_year_end', fyEnd);
const r = await fetch('/api/db/overdue?' + params);
const d = await r.json();
if (d.cutoff_date) {
document.getElementById('bdOlderThan').value = d.cutoff_date;
updateBdPreview();
}
} catch(e) {
// Fallback: compute client-side
const today = new Date();
const cutoff = new Date(today); cutoff.setFullYear(cutoff.getFullYear() - years);
document.getElementById('bdOlderThan').value = cutoff.toISOString().split('T')[0];
updateBdPreview();
}
}
function clearBdFilters() {
document.getElementById('bdSource').value = '';
document.getElementById('bdMinCpr').value = '1';
document.getElementById('bdOlderThan').value = '';
updateBdPreview();
}
// ── Data subject lookup (#4) ──────────────────────────────────────────────
let _dsubItems = []; // items from last lookup, for bulk delete
function openSubjectModal() {
document.getElementById("dsubBackdrop").classList.add("open");
document.getElementById("dsubInput").value = "";
document.getElementById("dsubStatus").textContent = "";
document.getElementById("dsubResults").innerHTML = "";
document.getElementById("dsubDeleteBtn").style.display = "none";
_dsubItems = [];
setTimeout(() => document.getElementById("dsubInput").focus(), 80);
}
function closeDsubModal() {
document.getElementById("dsubBackdrop").classList.remove("open");
}
async function runSubjectLookup() {
const cpr = document.getElementById("dsubInput").value.trim();
if (!cpr) return;
const statusEl = document.getElementById("dsubStatus");
const resultsEl = document.getElementById("dsubResults");
const deleteBtn = document.getElementById("dsubDeleteBtn");
statusEl.textContent = t("m365_subject_searching", "Searching…");
resultsEl.innerHTML = "";
deleteBtn.style.display = "none";
_dsubItems = [];
try {
const r = await fetch("/api/db/subject", {
method: "POST", headers: {"Content-Type":"application/json"},
body: JSON.stringify({cpr})
});
const d = await r.json();
if (d.error) { statusEl.textContent = d.error; return; }
if (!d.count) {
statusEl.textContent = t("m365_subject_not_found", "No flagged items found for this CPR number.");
return;
}
statusEl.textContent = d.count + " " + t("m365_subject_found", "item(s) found");
_dsubItems = d.items;
resultsEl.innerHTML = d.items.map(item => `
<div class="dsub-result-row">
<div class="dsub-result-name" title="${item.name}">${item.name}</div>
<div class="dsub-result-meta">${item.source_type || ""}</div>
<div class="dsub-result-meta">${item.modified || ""}</div>
<div class="dsub-result-meta" style="color:var(--danger)">${item.cpr_count} CPR</div>
</div>
`).join("");
if (d.count > 0) deleteBtn.style.display = "block";
} catch(e) {
statusEl.textContent = "Error: " + e.message;
}
}
async function deleteSubjectItems() {
if (!_dsubItems.length) return;
const count = _dsubItems.length;
if (!confirm(`${count} ${t("m365_subject_delete_confirm", "item(s) will be permanently deleted. Continue?")}`))
return;
const ids = _dsubItems.map(i => i.id);
const statusEl = document.getElementById("dsubStatus");
statusEl.textContent = t("m365_bulk_deleting", "Deleting…");
try {
const r = await fetch("/api/delete_bulk", {
method: "POST", headers: {"Content-Type":"application/json"},
body: JSON.stringify({ids, reason: "data-subject-request"})
});
const d = await r.json();
statusEl.textContent = `${d.deleted || 0} ${t("m365_bulk_deleted","deleted")}`;
document.getElementById("dsubDeleteBtn").style.display = "none";
document.getElementById("dsubResults").innerHTML = "";
_dsubItems = [];
// Refresh grid
S.flaggedData = S.flaggedData.filter(f => !ids.includes(f.id));
S.filteredData = S.filteredData.filter(f => !ids.includes(f.id));
renderGrid();
updateStats();
} catch(e) {
statusEl.textContent = "Delete failed: " + e.message;
}
}
// ── Disposition tagging (#6) ───────────────────────────────────────────────
let _dispositionItemId = null;
async function loadDisposition(itemId) {
_dispositionItemId = itemId;
const row = document.getElementById("dispositionRow");
const sel = document.getElementById("dispositionSelect");
const saved = document.getElementById("dispositionSaved");
row.style.display = "flex";
saved.textContent = "";
try {
const r = await fetch("/api/db/disposition/" + encodeURIComponent(itemId));
const d = await r.json();
if (d.error) return; // DB not available -- hide row
const status = d.status || "unreviewed";
sel.value = status;
// Cache on S.flaggedData item so the filter bar works without extra API calls
const item = S.flaggedData.find(f => f.id === itemId);
if (item) item.disposition = status;
} catch(e) {
row.style.display = "none";
}
}
async function saveDisposition() {
if (!_dispositionItemId) return;
const status = document.getElementById("dispositionSelect").value;
const savedEl = document.getElementById("dispositionSaved");
savedEl.textContent = "";
try {
await fetch("/api/db/disposition", {
method: "POST", headers: {"Content-Type":"application/json"},
body: JSON.stringify({item_id: _dispositionItemId, status})
});
savedEl.textContent = t("m365_disp_saved", "✓ Saved");
setTimeout(() => { savedEl.textContent = ""; }, 2000);
// Update cached value on the S.flaggedData item
const item = S.flaggedData.find(f => f.id === _dispositionItemId);
if (item) item.disposition = status;
// Refresh card badge if a disposition filter is active
const dispFilter = document.getElementById("filterDisposition")?.value;
if (dispFilter) applyFilters();
} catch(e) {
savedEl.textContent = "Error";
}
}
function closePreview() {
const panel = document.getElementById('previewPanel');
panel.style.width = ''; // clear inline width so CSS .hidden { width:0 } takes effect
panel.classList.add('hidden');
document.getElementById('previewFrame').src = '';
document.querySelectorAll('.card.selected').forEach(c => c.classList.remove('selected'));
_previewItemId = null;
}
document.addEventListener('keydown', e => {
if (e.key === 'Escape') { closeAbout(); closeModeInfo(); closeBulkDelete(); closePreview(); closeDsubModal(); closeSmtpModal(); closeProfileMgmt(); closeImportDBModal(); closeFileSourcesModal(); closeSourcesMgmt(); closeSettings(); closePinPrompt(); }
});
// ── Delete ────────────────────────────────────────────────────────────────────
async function deleteItem(f, cardEl) {
if (!confirm(t('m365_delete_confirm', 'Delete') + ' "' + f.name + '"?\n\n' + t('m365_delete_warning', 'This cannot be undone.'))) return;
try {
const r = await fetch('/api/delete_item', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({id: f.id, source_type: f.source_type, account_id: f.account_id, drive_id: f.drive_id})
});
const d = await r.json();
if (d.ok) {
S.flaggedData = S.flaggedData.filter(x => x.id !== f.id);
S.filteredData = S.filteredData.filter(x => x.id !== f.id);
if (cardEl) cardEl.remove();
updateStats();
log(t('m365_log_deleted', 'Deleted:') + ' ' + f.name, 'ok');
if (_previewItemId === f.id) closePreview();
} else {
log(t('m365_log_delete_failed', 'Delete failed:') + ' ' + (d.error || '?'), 'err');
}
} catch(e) {
log(t('m365_log_delete_failed', 'Delete failed:') + ' ' + e.message, 'err');
}
}
// ── Bulk delete modal ─────────────────────────────────────────────────────────
function openBulkDelete() {
applyI18n();
updateBdPreview();
document.getElementById('bulkDeleteBackdrop').classList.add('open');
}
function closeBulkDelete() {
document.getElementById('bulkDeleteBackdrop').classList.remove('open');
document.getElementById('bdProgress').textContent = '';
}
function _bdFilters() {
return {
source_type: document.getElementById('bdSource').value,
min_cpr: parseInt(document.getElementById('bdMinCpr').value) || 1,
older_than_date: document.getElementById('bdOlderThan').value,
};
}
function _bdMatches() {
const f = _bdFilters();
return S.flaggedData.filter(x => {
if (f.source_type && x.source_type !== f.source_type) return false;
if (x.cpr_count < f.min_cpr) return false;
if (f.older_than_date && x.modified > f.older_than_date) return false;
return true;
});
}
function updateBdPreview() {
const matches = _bdMatches();
const prev = document.getElementById('bdPreview');
if (!prev) return;
if (matches.length === 0) {
prev.textContent = t('m365_bulk_no_match', 'No items match these criteria.');
document.getElementById('bdConfirmBtn').disabled = true;
} else {
prev.innerHTML = `<strong style="color:var(--danger)">${matches.length}</strong> ${t('m365_bulk_match_count', 'item(s) will be deleted')}`;
document.getElementById('bdConfirmBtn').disabled = false;
}
}
// ── Auto-connect SSE on page load (#21) ──────────────────────────────────────
// ── SSE connection management ────────────────────────────────────────────────
// The browser keeps an SSE connection to /api/scan/stream for live scan events.
// Problem: idle SSE connections silently die (Flask/Werkzeug threading, proxies,
// OS TCP keepalive). EventSource auto-reconnects, but during the reconnect
// window a scheduled scan's events are lost.
//
// Solution: a polling watchdog checks /api/scan/status every few seconds.
// When it detects a running scan (manual or scheduled), it ensures the SSE
// connection is alive and the progress UI is visible.
let _sseWatchdogTimer = null;
let _initialStatusChecked = false;
const _SSE_POLL_INTERVAL = 4000; // ms between status polls
function _ensureSSE() {
// Open SSE if not already open or if the existing connection is dead
if (S.es && S.es.readyState !== EventSource.CLOSED) return;
if (S.es) { try { S.es.close(); } catch(_){} }
console.log('[SSE] Opening connection to /api/scan/stream');
S.es = new EventSource('/api/scan/stream');
S.es.onopen = function() { console.log('[SSE] Connection established'); };
S.es.onerror = function(e) {
console.warn('[SSE] Connection error (will auto-reconnect)', e);
};
_attachScanListeners(S.es);
_attachSchedulerListeners(S.es);
}
function _sseWatchdog() {
fetch('/api/scan/status').then(function(r) { return r.json(); }).then(function(status) {
if (status.running) {
// A scan is in progress — make sure SSE is connected and progress UI is visible
_ensureSSE();
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
// /api/scan/status checks the M365 lock — if running=true it's an M365 scan
S._m365ScanRunning = true; _renderProgressSegments();
document.getElementById('progressFile').textContent = t('m365_sse_reconnecting', 'Reconnecting to running scan…');
log(t('m365_sse_reconnecting', 'Reconnecting to running scan…'));
}
}
if (!_initialStatusChecked) {
_initialStatusChecked = true;
if (!status.running) loadLastScanSummary();
}
// When no scan is running, we still keep polling — the SSE connection
// may have died and we need to detect the *next* scheduled scan.
// The SSE itself is only opened/reopened when a scan is detected.
}).catch(function(err) {
// Status endpoint unavailable — server might be restarting
console.warn('[SSE] status poll failed:', err);
});
}
function _autoConnectSSEIfRunning() {
// Open initial SSE connection
_ensureSSE();
// Check if a scan is already running (e.g. scheduled scan started before page load)
_sseWatchdog();
// Start polling watchdog — catches scheduled scans that start later
if (!_sseWatchdogTimer) {
_sseWatchdogTimer = setInterval(_sseWatchdog, _SSE_POLL_INTERVAL);
}
}
// ── Viewer mode result loader ─────────────────────────────────────────────────
async function _loadViewerResults() {
try {
const r = await fetch('/api/db/flagged');
const items = await r.json();
if (!Array.isArray(items) || items.length === 0) {
// Show last-scan summary card (stats only, no items yet)
const panel = document.getElementById('lastScanSummary');
const empty = document.getElementById('emptyState');
const r2 = await fetch('/api/db/stats');
const stats = await r2.json();
if (stats.scan_id && panel && empty) {
const dateStr = stats.finished_at
? new Date(stats.finished_at * 1000).toLocaleDateString('da-DK', {day:'numeric', month:'short', year:'numeric'})
: '—';
const srcLabels = {email:'Outlook',onedrive:'OneDrive',sharepoint:'SharePoint',teams:'Teams',
gmail:'Gmail',gdrive:'Drive',local:'Lokale filer',smb:'SMB'};
const srcStr = Object.keys(stats.by_source || {}).map(s => srcLabels[s] || s).join(' · ') || '—';
panel.innerHTML =
'<div class="last-scan-card">' +
'<h3>' + t('last_scan_title', 'Seneste scanning') + '</h3>' +
'<div class="last-scan-stats">' +
'<div class="last-scan-stat"><span class="val">' + (stats.flagged_count || 0) + '</span><span class="lbl">' + t('last_scan_hits', 'Fund') + '</span></div>' +
'<div class="last-scan-stat"><span class="val">' + (stats.unique_subjects || 0) + '</span><span class="lbl">' + t('last_scan_subjects', 'Unikke CPR') + '</span></div>' +
'<div class="last-scan-stat"><span class="val">' + (stats.total_scanned || 0) + '</span><span class="lbl">' + t('last_scan_scanned', 'Scannet') + '</span></div>' +
'</div>' +
'<div style="margin-top:12px;font-size:11px;color:var(--muted)">' + dateStr + ' &nbsp;·&nbsp; ' + srcStr + '</div>' +
'</div>';
empty.style.display = 'none';
panel.style.display = 'flex';
}
return;
}
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.style.display = 'grid';
renderGrid(items);
try { loadTrend(); } catch(_) {}
} catch(e) {
console.error('[viewer] failed to load results:', e);
}
}
document.addEventListener('DOMContentLoaded', () => {
_restoreLog();
_initLogResize();
_initPreviewResize();
_initSourcesResize();
restoreSectionStates();
if (window.VIEWER_MODE) {
_loadViewerResults();
return;
}
_loadFileSources();
_autoConnectSSEIfRunning(); // populates S._fileSources then calls renderSourcesPanel()
smGoogleRefreshStatus(); // sets _googleConnected and re-renders sources panel
// Restore all source toggle states
fetch('/api/src_toggles').then(function(r){ return r.json(); }).then(function(d) {
_restoreM365SourceToggles(d);
var gm = document.getElementById('smGoogleSrcGmail');
var gd = document.getElementById('smGoogleSrcDrive');
if (gm && d.src_gmail !== undefined) { gm.checked = !!d.src_gmail; }
if (gd && d.src_drive !== undefined) { gd.checked = !!d.src_drive; }
}).catch(function(){});
// ── macOS pywebview: push content below traffic-light buttons ─────────────
// In frameless pywebview windows on macOS the content starts at y=0, behind
// the system close/minimise/maximise buttons (~28px). Apply a padding only
// when running inside pywebview AND on macOS (navigator.platform contains Mac).
if (window.pywebview && navigator.platform.toLowerCase().includes('mac')) {
document.body.style.paddingTop = '30px';
}
['bdSource','bdMinCpr','bdOlderThan'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('input', updateBdPreview);
});
['optRetentionYears','optFiscalYearEnd'].forEach(id => {
const el = document.getElementById(id);
if (el) el.addEventListener('change', updateRetentionCutoffHint);
});
window.addEventListener('resize', () => {
const tp = document.getElementById('trendPanel');
if (tp && tp.style.display !== 'none') loadTrend();
});
const deltaCb = document.getElementById('optDelta');
if (deltaCb) {
deltaCb.addEventListener('change', () => {
if (deltaCb.checked) checkDeltaStatus();
else document.getElementById('deltaStatusRow').style.display = 'none';
});
}
});
async function executeBulkDelete() {
const matches = _bdMatches();
if (!matches.length) return;
const confirmMsg = matches.length + ' ' + t('m365_bulk_confirm_q', 'item(s) will be permanently deleted. Continue?');
if (!confirm(confirmMsg)) return;
const btn = document.getElementById('bdConfirmBtn');
const prog = document.getElementById('bdProgress');
btn.disabled = true;
prog.textContent = t('m365_bulk_deleting', 'Deleting…');
try {
const r = await fetch('/api/delete_bulk', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ ids: matches.map(x => x.id), filters: {} })
});
const d = await r.json();
if (d.ok) {
const deletedSet = new Set(matches.map(x => x.id));
S.flaggedData = S.flaggedData.filter(x => !deletedSet.has(x.id));
S.filteredData = S.filteredData.filter(x => !deletedSet.has(x.id));
renderGrid(S.filteredData.length ? S.filteredData : S.flaggedData);
updateStats();
prog.innerHTML = `<span style="color:var(--ok,#4c4)">✓ ${d.deleted} ${t('m365_bulk_deleted', 'deleted')}</span>` +
(d.failed ? ` · <span style="color:var(--danger)">${d.failed} ${t('m365_bulk_failed', 'failed')}</span>` : '');
if (d.errors && d.errors.length) {
d.errors.forEach(err => log('✗ ' + err.name + ': ' + err.error, 'err'));
}
log(t('m365_log_bulk_done', 'Bulk delete:') + ' ' + d.deleted + ' deleted, ' + d.failed + ' failed', d.failed ? 'err' : 'ok');
if (d.failed === 0) setTimeout(closeBulkDelete, 1800);
} else {
prog.textContent = d.error || 'Error';
}
} catch(e) {
prog.textContent = e.message;
} finally {
btn.disabled = false;
}
}
function applyFilters() {
const search = document.getElementById('filterSearch').value.trim().toLowerCase();
const srcVal = document.getElementById('filterSource').value;
const dispVal = document.getElementById('filterDisposition')?.value || '';
const transferVal = document.getElementById('filterTransfer')?.value || '';
const specialVal = document.getElementById('filterSpecial')?.value || '';
S.filteredData = S.flaggedData.filter(f => {
if (search && !f.name.toLowerCase().includes(search)) return false;
if (srcVal && f.source_type !== srcVal) return false;
if (dispVal && (f.disposition || 'unreviewed') !== dispVal) return false;
if (transferVal && (f.transfer_risk || '') !== transferVal) return false;
if (specialVal === '1' && !(f.special_category && f.special_category.length)) return false;
if (specialVal === 'photo' && !(f.face_count > 0)) return false;
return true;
});
const grid = document.getElementById('grid');
if (S.filteredData.length === 0 && S.flaggedData.length > 0) {
grid.style.display = 'none';
document.getElementById('emptyState').innerHTML =
`<div class="empty-icon">🔍</div><div class="empty-text">${t('m365_no_matches','No matches')}</div>`;
document.getElementById('emptyState').style.display = 'flex';
} else {
document.getElementById('emptyState').style.display = 'none';
grid.style.display = S.isListView ? 'block' : 'grid';
renderGrid(S.filteredData);
}
}
async function exportExcel() {
if (!S.flaggedData || S.flaggedData.length === 0) {
log(t('m365_export_no_data', 'No results to export.'));
return;
}
if (window.pywebview && window.pywebview.api && window.pywebview.api.save_excel) {
try {
const r = await window.pywebview.api.save_excel();
if (r && r.ok) { log('Excel exported: ' + r.path); }
else if (r && r.error && r.error !== 'cancelled') { alert('Export failed: ' + r.error); }
} catch(e) { alert('Export failed: ' + e.message); }
return;
}
const btn = document.getElementById('exportBtn');
if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
try {
// In pywebview (macOS/Windows app), blob URL downloads don't work —
// use the native save dialog exposed via the JS API instead.
if (window.pywebview && window.pywebview.api && window.pywebview.api.save_excel) {
const result = await window.pywebview.api.save_excel();
if (result && result.ok) {
log(t('m365_export_done', 'Excel export ready.'), 'ok');
} else {
if (result && result.error && result.error !== 'cancelled') {
log('Export error: ' + result.error, 'err');
}
}
return;
}
// Browser / localhost fallback: fetch as blob and trigger download
const r = await fetch('/api/export_excel');
if (!r.ok) {
const err = await r.json().catch(() => ({error: 'Export failed'}));
log('Export error: ' + (err.error || r.status), 'err');
return;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const disp = r.headers.get('Content-Disposition') || '';
const match = disp.match(/filename=([^\s;]+)/);
a.href = url;
a.download = match ? match[1] : 'export.xlsx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log(t('m365_export_done', 'Excel export ready.'), 'ok');
} catch(e) {
log('Export error: ' + e.message, 'err');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '⬇ Excel'; }
}
}
async function exportArticle30() {
if (!S.flaggedData || S.flaggedData.length === 0) {
log(t('m365_export_no_data', 'No results to export.'));
return;
}
if (window.pywebview && window.pywebview.api && window.pywebview.api.save_article30) {
try {
const r = await window.pywebview.api.save_article30();
if (r && r.ok) { log('Article 30 exported: ' + r.path); }
else if (r && r.error && r.error !== 'cancelled') { alert('Export failed: ' + r.error); }
} catch(e) { alert('Export failed: ' + e.message); }
return;
}
const btn = document.getElementById('exportA30Btn');
if (btn) { btn.disabled = true; btn.textContent = '⏳'; }
try {
const r = await fetch('/api/export_article30');
if (!r.ok) {
const err = await r.json().catch(() => ({error: 'Export failed'}));
log('Article 30 export error: ' + (err.error || r.status), 'err');
return;
}
const blob = await r.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
const disp = r.headers.get('Content-Disposition') || '';
const match = disp.match(/filename=([^\s;]+)/);
a.href = url;
a.download = match ? match[1] : 'article30.docx';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
log(t('m365_article30_done', 'Article 30 report ready.'), 'ok');
} catch(e) {
log('Article 30 export error: ' + e.message, 'err');
} finally {
if (btn) { btn.disabled = false; btn.innerHTML = '📋 Art.30'; }
}
}
function clearFilters() {
document.getElementById('filterSearch').value = '';
document.getElementById('filterSource').value = '';
const fd = document.getElementById('filterDisposition');
if (fd) fd.value = '';
const ft = document.getElementById('filterTransfer');
if (ft) ft.value = '';
const fs = document.getElementById('filterSpecial');
if (fs) fs.value = '';
applyFilters();
}
function toggleView() {
S.isListView = !S.isListView;
document.getElementById('listViewBtn').textContent = S.isListView ? t('m365_btn_grid_view', '⊞ Grid') : t('m365_btn_list_view', '☰ List');
document.getElementById('grid').className = S.isListView ? '' : 'grid';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
renderGrid(S.filteredData.length ? S.filteredData : S.flaggedData);
}
// ── Hint tooltips ─────────────────────────────────────────────────────────────
function toggleHint(icon) {
const isActive = icon.classList.contains('active');
// Close all open hints first
document.querySelectorAll('.hint-icon.active').forEach(function(el) {
el.classList.remove('active');
const b = el.nextElementSibling;
if (b && b.classList.contains('hint-bubble')) b.style.display = '';
});
if (!isActive) {
icon.classList.add('active');
// Position bubble using fixed coords so it escapes sidebar stacking context
const bubble = icon.nextElementSibling;
if (bubble && bubble.classList.contains('hint-bubble')) {
bubble.style.display = 'block';
const rect = icon.getBoundingClientRect();
bubble.style.top = Math.round(rect.top + rect.height / 2 - bubble.offsetHeight / 2) + 'px';
bubble.style.left = Math.round(rect.right + 8) + 'px';
}
// Close when clicking anywhere else
setTimeout(function() {
document.addEventListener('click', function closeHint(e) {
if (!e.target.classList.contains('hint-icon')) {
document.querySelectorAll('.hint-icon.active').forEach(function(el) {
el.classList.remove('active');
});
document.querySelectorAll('.hint-bubble').forEach(function(el) {
el.style.display = '';
});
document.removeEventListener('click', closeHint);
}
});
}, 0);
}
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.appendCard = appendCard;
window.renderGrid = renderGrid;
window.openPreview = openPreview;
window.toggleRetentionPanel = toggleRetentionPanel;
window.updateRetentionCutoffHint = updateRetentionCutoffHint;
window.markOverdueCards = markOverdueCards;
window.preFilterOverdue = preFilterOverdue;
window.clearBdFilters = clearBdFilters;
window.openSubjectModal = openSubjectModal;
window.closeDsubModal = closeDsubModal;
window.runSubjectLookup = runSubjectLookup;
window.deleteSubjectItems = deleteSubjectItems;
window.loadDisposition = loadDisposition;
window.saveDisposition = saveDisposition;
window.closePreview = closePreview;
window.deleteItem = deleteItem;
window.openBulkDelete = openBulkDelete;
window.closeBulkDelete = closeBulkDelete;
window._bdFilters = _bdFilters;
window._bdMatches = _bdMatches;
window.updateBdPreview = updateBdPreview;
window._ensureSSE = _ensureSSE;
window._sseWatchdog = _sseWatchdog;
window._autoConnectSSEIfRunning = _autoConnectSSEIfRunning;
window._loadViewerResults = _loadViewerResults;
window.executeBulkDelete = executeBulkDelete;
window.applyFilters = applyFilters;
window.exportExcel = exportExcel;
window.exportArticle30 = exportArticle30;
window.clearFilters = clearFilters;
window.toggleView = toggleView;
window.toggleHint = toggleHint;
window.SOURCE_BADGES = SOURCE_BADGES;
window._previewItemId = _previewItemId;
window._dsubItems = _dsubItems;
window._dispositionItemId = _dispositionItemId;
window._sseWatchdogTimer = _sseWatchdogTimer;
window._initialStatusChecked = _initialStatusChecked;
window._SSE_POLL_INTERVAL = _SSE_POLL_INTERVAL;
+730
View File
@@ -0,0 +1,730 @@
import { S } from './state.js';
// ── DB Export / Import (#11) ──────────────────────────────────────────────────
async function exportDB() {
// In pywebview app, use native save dialog; in browser, use blob download
if (window.pywebview && window.pywebview.api && window.pywebview.api.save_db_export) {
try {
const r = await window.pywebview.api.save_db_export();
if (r && r.ok) { log(t('m365_db_exported','Database exported') + ': ' + r.path); }
else if (r && r.error && r.error !== 'cancelled') { alert(t('m365_db_export_error','Export failed') + ': ' + r.error); }
} catch(e) { alert(t('m365_db_export_error','Export failed') + ': ' + e.message); }
return;
}
// Browser fallback
try {
const res = await fetch('/api/db/export');
if (!res.ok) {
const d = await res.json().catch(() => ({}));
alert(t('m365_db_export_error','Export failed') + ': ' + (d.error || res.statusText));
return;
}
const blob = await res.blob();
const cd = res.headers.get('Content-Disposition') || '';
const m = cd.match(/filename="([^"]+)"/);
const name = m ? m[1] : 'gdpr_export.zip';
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = name; a.click();
URL.revokeObjectURL(url);
log(t('m365_db_exported','Database exported') + ': ' + name);
} catch(e) {
alert(t('m365_db_export_error','Export failed') + ': ' + e.message);
}
}
function openImportDBModal() {
const fi = document.getElementById('importDbFile');
if (fi) fi.value = '';
const mode = document.getElementById('importDbMode');
if (mode) mode.value = 'merge';
document.getElementById('importDbReplaceWarn').style.display = 'none';
document.getElementById('importDbStatus').textContent = '';
document.getElementById('importDbBackdrop').classList.add('open');
}
function closeImportDBModal() {
document.getElementById('importDbBackdrop').classList.remove('open');
}
// Show/hide the replace warning when mode changes
document.addEventListener('DOMContentLoaded', () => {
document.getElementById('importDbMode')?.addEventListener('change', function() {
document.getElementById('importDbReplaceWarn').style.display =
this.value === 'replace' ? 'block' : 'none';
});
});
async function doImportDB() {
const fi = document.getElementById('importDbFile');
const mode = document.getElementById('importDbMode')?.value || 'merge';
const stat = document.getElementById('importDbStatus');
const btn = document.getElementById('importDbBtn');
if (!fi?.files?.length) {
stat.textContent = t('m365_db_import_no_file','Please select a ZIP file first.');
stat.style.color = 'var(--danger)';
return;
}
if (mode === 'replace') {
if (!confirm(t('m365_db_import_replace_confirm',
'Replace mode will erase ALL existing scan data and restore from the archive.\n\nMake sure you have a manual backup of ~/.gdpr_scanner.db.\n\nProceed?'))) return;
}
btn.disabled = true;
stat.style.color = 'var(--muted)';
stat.textContent = t('m365_db_importing','Importing…');
const fd = new FormData();
fd.append('file', fi.files[0]);
fd.append('mode', mode);
if (mode === 'replace') fd.append('confirm', 'yes');
try {
const r = await fetch('/api/db/import', { method: 'POST', body: fd });
const d = await r.json();
if (!r.ok || d.error) {
stat.style.color = 'var(--danger)';
stat.textContent = '✖ ' + (d.error || r.statusText);
} else {
const counts = Object.entries(d.imported || {}).map(([k,v]) => `${k}: ${v}`).join(', ');
stat.style.color = 'var(--accent)';
stat.textContent = '✔ ' + t('m365_db_imported','Imported') + (counts ? ' (' + counts + ')' : '');
log(t('m365_db_imported','Imported') + ' [' + mode + '] ' + fi.files[0].name);
}
} catch(e) {
stat.style.color = 'var(--danger)';
stat.textContent = '✖ ' + e.message;
} finally {
btn.disabled = false;
}
}
// ── Scan ─────────────────────────────────────────────────────────────────────
function buildScanPayload() {
// Collect checked M365 sources from dynamic panel
const sources = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="m365"]:checked').forEach(function(cb) {
sources.push(cb.dataset.sourceId);
});
// Collect checked file sources (local/smb) — handled separately in startScan()
// but included here so profiles and checkpoint checks are aware of them
const fileSources = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="file"]:checked').forEach(function(cb) {
fileSources.push(cb.dataset.sourceId);
});
// Collect checked Google sources
const googleSources = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="google"]:checked').forEach(function(cb) {
googleSources.push(cb.dataset.sourceId);
});
const user_ids = getSelectedUsers();
// Merge all source types into a single array for profiles
const allSources = sources.concat(fileSources).concat(googleSources);
const options = {
older_than_days: parseInt(document.getElementById('olderThan').value) || 0,
email_body: document.getElementById('optEmailBody').checked,
attachments: document.getElementById('optAttachments').checked,
max_attach_mb: parseInt(document.getElementById('optMaxAttachMB').value) || 20,
max_emails: parseInt(document.getElementById('optMaxEmails').value) || 200,
delta: document.getElementById('optDelta') ? document.getElementById('optDelta').checked : false,
scan_photos: document.getElementById('optScanPhotos') ? document.getElementById('optScanPhotos').checked : false,
retention_enabled: document.getElementById('optRetention') ? document.getElementById('optRetention').checked : false,
retention_years: parseInt(document.getElementById('optRetentionYears')?.value) || 5,
fiscal_year_end: document.getElementById('optFiscalYearEnd')?.value || '',
};
return { sources, fileSources, allSources, googleSources, user_ids, options };
}
async function checkCheckpoint() {
const payload = buildScanPayload();
if (!payload.sources.length && !payload.fileSources.length) return;
if (payload.sources.length && !payload.user_ids.length) return;
try {
const r = await fetch('/api/scan/checkpoint', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(payload)
});
const d = await r.json();
const banner = document.getElementById('resumeBanner');
if (d.exists) {
const ts = d.started_at ? new Date(d.started_at * 1000).toLocaleString([], {dateStyle:'short', timeStyle:'short'}) : '';
document.getElementById('resumeBannerText').textContent =
t('m365_resume_banner', `Previous scan interrupted (${d.scanned_count} scanned, ${d.flagged_count} found${ts ? ' — ' + ts : ''})`);
banner.style.display = 'flex';
} else {
banner.style.display = 'none';
}
} catch(e) { /* ignore */ }
}
async function clearCheckpointAndScan() {
await fetch('/api/scan/clear_checkpoint', {method:'POST'});
document.getElementById('resumeBanner').style.display = 'none';
startScan(false);
}
async function checkDeltaStatus() {
const cb = document.getElementById('optDelta');
if (!cb) return;
try {
const r = await fetch('/api/delta/status');
const d = await r.json();
const row = document.getElementById('deltaStatusRow');
const txt = document.getElementById('deltaStatusText');
if (d.exists) {
const src = d.count === 1 ? '1 source' : `${d.count} sources`;
txt.textContent = t('m365_delta_tokens_saved', `Tokens saved for ${src}`);
row.style.display = 'flex';
row.style.alignItems = 'center';
} else {
row.style.display = 'none';
}
} catch(e) { /* ignore */ }
}
async function clearDeltaTokens() {
await fetch('/api/delta/clear', {method:'POST'});
document.getElementById('deltaStatusRow').style.display = 'none';
log(t('m365_delta_cleared', 'Delta tokens cleared — next scan will be a full scan.'));
}
// ── SMTP / Email report modal ─────────────────────────────────────────────────
function openSmtpModal(focusSend) {
document.getElementById('smtpBackdrop').classList.add('open');
document.getElementById('smtpStatus').textContent = '';
loadSmtpConfig();
if (focusSend) {
setTimeout(() => document.getElementById('smtpRecipients').focus(), 120);
}
}
function closeSmtpModal() {
document.getElementById('smtpBackdrop').classList.remove('open');
}
async function loadSmtpConfig() {
try {
const r = await fetch('/api/smtp/config');
const d = await r.json();
if (d.host) document.getElementById('smtpHost').value = d.host;
if (d.port) document.getElementById('smtpPort').value = d.port;
if (d.username) document.getElementById('smtpUser').value = d.username;
if (d.from_addr) document.getElementById('smtpFrom').value = d.from_addr;
if (d.recipients) document.getElementById('smtpRecipients').value = Array.isArray(d.recipients) ? d.recipients.join(', ') : d.recipients;
if (d.password_saved) document.getElementById('smtpPass').placeholder = '(password saved)';
if (d.use_tls !== undefined) document.getElementById('smtpTLS').checked = d.use_tls;
if (d.use_ssl !== undefined) document.getElementById('smtpSSL').checked = d.use_ssl;
} catch(e) { /* ignore */ }
}
async function saveSmtpConfig() {
const cfg = _smtpFields();
const r = await fetch('/api/smtp/config', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(cfg)
});
const d = await r.json();
const el = document.getElementById('smtpStatus');
if (d.status === 'saved') {
el.style.color = 'var(--success)';
el.textContent = t('m365_smtp_saved', 'Settings saved.');
if (cfg.password) document.getElementById('smtpPass').placeholder = '(password saved)';
} else {
el.style.color = 'var(--danger)';
el.textContent = d.error || 'Error saving';
}
}
async function sendReport() {
const cfg = _smtpFields();
const recipStr = document.getElementById('smtpRecipients').value.trim();
if (!recipStr) {
document.getElementById('smtpStatus').style.color = 'var(--danger)';
document.getElementById('smtpStatus').textContent = t('m365_smtp_no_recipients', 'Enter at least one recipient.');
document.getElementById('smtpRecipients').focus();
return;
}
const recipients = recipStr.split(/[,;]/).map(s => s.trim()).filter(Boolean);
const statusEl = document.getElementById('smtpStatus');
statusEl.style.color = 'var(--muted)';
statusEl.textContent = t('m365_smtp_sending', 'Sending…');
const r = await fetch('/api/send_report', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({recipients, smtp: cfg})
});
const d = await r.json();
if (d.status === 'sent') {
statusEl.style.color = 'var(--success)';
statusEl.textContent = t('m365_smtp_sent', 'Sent to ' + recipients.join(', '));
log('Report emailed to ' + recipients.join(', '), 'ok');
} else {
statusEl.style.color = 'var(--danger)';
statusEl.textContent = d.error || 'Send failed';
log('Email send failed: ' + (d.error || ''), 'err');
}
}
function _smtpFields() {
return {
host: document.getElementById('smtpHost').value.trim(),
port: parseInt(document.getElementById('smtpPort').value) || 587,
username: document.getElementById('smtpUser').value.trim(),
password: document.getElementById('smtpPass').value,
from_addr: document.getElementById('smtpFrom').value.trim(),
use_tls: document.getElementById('smtpTLS').checked,
use_ssl: document.getElementById('smtpSSL').checked,
recipients: document.getElementById('smtpRecipients').value,
};
}
// ── Shared SSE event listeners (#21) ─────────────────────────────────────────
// Extracted so both startScan() and _autoConnectSSEIfRunning() share identical
// handlers — fixes the bug where replayed events from a scheduled scan were
// silently ignored because the page-load SSE only had scheduler_* listeners.
function _attachScanListeners(source) {
source.addEventListener('scan_phase', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scan_phase:', d.phase);
// Ensure a progress segment exists before rendering phase text.
// scan_phase can arrive before scan_progress (or before scan_start on replay
// if scan_start has been pushed out of the 500-event SSE buffer).
if (!S._m365ScanRunning && !S._googleScanRunning && !S._fileScanRunning) {
var ph = (d.phase || '').toLowerCase();
var phaseSrc = /google|gmail|gdrive/.test(ph) ? 'google'
: /^files\s*[—\-–]/.test(ph) ? 'file'
: 'm365';
if (phaseSrc === 'google') { S._googleScanRunning = true; }
else if (phaseSrc === 'file') { S._fileScanRunning = true; }
else { S._m365ScanRunning = true; }
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
_renderProgressSegments();
}
_setProgressPhase(d.phase);
log(d.phase);
});
source.addEventListener('scan_progress', function(e) {
var d = JSON.parse(e.data);
var src = d.source || 'm365';
var pct = d.pct !== undefined ? d.pct
: (d.total > 0 ? Math.round((d.index || d.completed || 0) / d.total * 100) : 0);
S._srcPct[src] = pct;
// If reconnecting mid-scan the running flag may not be set yet — ensure segment exists
if (src === 'm365' && !S._m365ScanRunning) { S._m365ScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
if (src === 'google' && !S._googleScanRunning) { S._googleScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
if (src === 'file' && !S._fileScanRunning) { S._fileScanRunning = true; document.getElementById('scanBtn').disabled = true; document.getElementById('stopBtn').style.display = 'inline-block'; _renderProgressSegments(); }
var fill = document.getElementById('progressFill_' + src);
if (fill) fill.style.width = pct + '%';
document.getElementById('progressFile').textContent = d.file || '';
// Only update stats/ETA from M365 (has meaningful totals and ETA)
if (src === 'm365') {
var statsEl = document.getElementById('progressStats');
if (statsEl && d.total) {
statsEl.textContent = (d.index || 0) + ' / ' + d.total;
}
var etaEl = document.getElementById('progressEta');
if (etaEl && d.eta !== undefined) {
etaEl.textContent = d.eta ? ('ETA ' + d.eta) : '';
}
}
});
source.addEventListener('scan_file', function(e) {
var d = JSON.parse(e.data);
setLogLive(d.file || '');
});
source.addEventListener('scan_file_flagged', function(e) {
var card = JSON.parse(e.data);
console.log('[SSE] scan_file_flagged:', card.name || card.id);
if (!S.flaggedData.find(function(x){ return x.id === card.id; })) {
S.flaggedData.push(card);
S.totalCPR += (card.cpr_count || 0);
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
}
});
source.addEventListener('scan_error', function(e) {
var d = JSON.parse(e.data);
log((d.file ? d.file + ': ' : '') + d.error, 'err');
});
source.addEventListener('scan_cancelled', function() {
if (S._userStartedScan) {
S._userStartedScan = false;
if (S.es) { S.es.close(); S.es = null; }
}
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
setLogLive('');
log('Scan stopped.', 'warn');
});
source.addEventListener('scan_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scan_done:', d);
// Only close SSE if the user started this scan via the Scan button.
// For scheduled scans, keep the SSE connection alive so future
// scheduler events are still received.
if (S._userStartedScan) {
S._userStartedScan = false;
if (S.es) { S.es.close(); S.es = null; }
}
S._srcPct.m365 = 100;
S._m365ScanRunning = false;
_renderProgressSegments();
var _anyRunning = S._googleScanRunning || S._fileScanRunning;
if (!_anyRunning) setLogLive('');
document.getElementById('scanBtn').disabled = _anyRunning;
document.getElementById('stopBtn').style.display = _anyRunning ? 'inline-block' : 'none';
if (!_anyRunning) _clearProgressBar();
document.getElementById('statsSection').style.display = 'block';
document.getElementById('statScanned').textContent = d.total_scanned;
document.getElementById('statFlagged').textContent = d.flagged_count;
document.getElementById('statCPR').textContent = S.totalCPR;
document.getElementById('statsPill').style.display = 'block';
updateStats();
if (S.flaggedData.length) {
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
} else {
document.getElementById('emptyState').style.display = 'flex';
document.getElementById('emptyState').innerHTML = '<div class="empty-icon">\u2705</div><div class="empty-text">' + t('m365_no_cpr_found','No CPR numbers found.') + '</div>';
}
var deltaNote = d.delta ? ' (\u0394 delta \u2014 ' + (d.delta_sources||0) + ' source(s) indexed)' : '';
log('Scan complete \u2014 ' + d.flagged_count + ' flagged of ' + d.total_scanned + deltaNote, 'ok');
if (d.delta) checkDeltaStatus();
markOverdueCards();
loadTrend();
});
source.addEventListener('google_scan_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] google_scan_done:', d);
S._srcPct.google = 100;
S._googleScanRunning = false;
_renderProgressSegments();
if (!S._m365ScanRunning && !S._fileScanRunning) {
setLogLive('');
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
document.getElementById('statsSection').style.display = 'block';
document.getElementById('statsPill').style.display = 'block';
updateStats();
if (S.flaggedData.length) {
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
}
}
log('Google scan complete \u2014 ' + d.flagged_count + ' flagged of ' + d.total_scanned, 'ok');
markOverdueCards();
loadTrend();
});
source.addEventListener('file_scan_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] file_scan_done:', d);
S._srcPct.file = 100;
S._fileScanRunning = false;
_renderProgressSegments();
if (!S._m365ScanRunning && !S._googleScanRunning) {
setLogLive('');
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
document.getElementById('statsSection').style.display = 'block';
document.getElementById('statsPill').style.display = 'block';
updateStats();
if (S.flaggedData.length) {
document.getElementById('filterBar').style.display = 'flex';
document.getElementById('grid').style.display = S.isListView ? 'block' : 'grid';
applyFilters();
}
}
log('Bestandsscan fuldført \u2014 ' + d.flagged_count + ' flagget af ' + d.total_scanned, 'ok');
markOverdueCards();
loadTrend();
});
// 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
source.addEventListener('sse_replay_done', function() {
log(t('m365_sse_replay_note', 'Live log resumed \u2014 earlier entries replayed from running scan.'));
});
}
function _attachSchedulerListeners(source) {
source.addEventListener('scheduler_started', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scheduler_started received:', d);
log('\uD83D\uDD50 ' + t('m365_sched_title','Scheduled scan') + ': ' + (d.job_name||'') + '\u2026');
// Show progress UI so scan_phase / scan_progress events are visible
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
S._srcPct = { m365: 0, google: 0, file: 0 }; S._m365ScanRunning = true; _renderProgressSegments();
_setProgressPhase((d.job_name||'') + '\u2026');
document.getElementById('progressFile').textContent = '';
});
source.addEventListener('scan_start', function(e) {
// Scheduled scans also emit scan_start — show progress UI in case
// scheduler_started was missed (e.g. browser reconnected mid-scan)
console.log('[SSE] scan_start received');
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
// Ensure at least the M365 segment is rendered (scan_start is M365-only)
if (!S._m365ScanRunning) { S._m365ScanRunning = true; _renderProgressSegments(); }
});
source.addEventListener('scheduler_done', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scheduler_done received:', d);
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
log('\u2713 ' + t('m365_sched_title','Scheduled scan') + ' ' + (d.job_name||'') + ' \u2014 ' + (d.flagged||0) + ' flagged', 'ok');
markOverdueCards();
loadTrend();
});
source.addEventListener('scheduler_error', function(e) {
var d = JSON.parse(e.data);
console.log('[SSE] scheduler_error received:', d);
document.getElementById('scanBtn').disabled = false;
document.getElementById('stopBtn').style.display = 'none';
_clearProgressBar();
log('\u26A0 ' + t('m365_sched_title','Scheduled scan') + ' failed: ' + (d.error||''), 'err');
});
}
function startScan(resume) {
const { sources, fileSources, googleSources, user_ids, options } = buildScanPayload();
if (!sources.length && !fileSources.length && !googleSources.length) { alert(t('m365_no_sources','No sources selected — nothing to scan.')); return; }
if (sources.length && !user_ids.length && !googleSources.length) { alert('Select at least one account to scan.'); return; }
// When resuming, keep existing cards; otherwise clear everything
if (!resume) {
S.flaggedData = []; S.filteredData = []; S.totalCPR = 0;
document.getElementById('grid').innerHTML = '';
document.getElementById('grid').style.display = 'none';
document.getElementById('emptyState').style.display = 'none';
const _lss = document.getElementById('lastScanSummary'); if (_lss) _lss.style.display = 'none';
document.getElementById('statsSection').style.display = 'none';
document.getElementById('statsPill').style.display = 'none';
}
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) {}
S._m365ScanRunning = sources.length > 0;
S._googleScanRunning = googleSources.length > 0;
S._fileScanRunning = fileSources.length > 0;
S._srcPct = { m365: 0, google: 0, file: 0 };
S._progressCurrentUser = '';
_renderProgressSegments();
document.getElementById('scanBtn').disabled = true;
document.getElementById('stopBtn').style.display = 'inline-block';
// progress segments rendered by _renderProgressSegments() called above
document.getElementById('progressFile').textContent = '';
_setProgressPhase(t('scan_preparing', 'Preparing…'));
const dateLabel = options.older_than_days > 0 ? ', ' + t('m365_log_older_than', 'older than') + ' ' + document.getElementById('olderThanDate').value : '';
const modeLabel = resume ? t('m365_log_resuming', 'Resuming scan:') : t('m365_log_starting_scan', 'Starting scan:');
var googleCount = googleSources.length > 0 ? S._allUsers.filter(function(u) {
return u.selected !== false && (u.platform === 'google' || u.platform === 'both');
}).length : 0;
var totalAccounts = (sources.length > 0 ? user_ids.length : 0) + (googleSources.length > 0 && sources.length === 0 ? googleCount : 0);
var allSourceLabels = sources.concat(googleSources);
log(modeLabel + ' ' + allSourceLabels.join(', ') + ' — ' + (totalAccounts || googleCount) + ' ' + t('m365_log_accounts', 'account(s)') + dateLabel + '…');
// Always close and reopen SSE — ensures a fresh queue is registered
// before the scan fires events (prevents missed events on the server side)
if (S.es) { S.es.close(); S.es = null; }
S._userStartedScan = true;
_ensureSSE();
setTimeout(() => {
// Fire M365 scan if any M365 sources are selected
if (sources.length > 0) {
fetch('/api/scan/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({sources, user_ids, options, resume: !!resume,
profile_id: S._activeProfileId || null})
}).then(r => {
if (r.status === 409) { log('Scan already running', 'err'); }
}).catch(e => { log('Scan start failed: ' + e, 'err'); });
}
// Fire file scans for each checked file source (local/smb)
const checkedFileIds = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="file"]:checked').forEach(function(cb) {
checkedFileIds.push(cb.dataset.sourceId);
});
checkedFileIds.forEach(function(id) {
const source = S._fileSources.find(function(s) { return s.id === id; });
if (!source) return;
fetch('/api/file_scan/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(Object.assign({}, source, {scan_photos: options.scan_photos || false}))
}).catch(e => { log('File scan error: ' + e, 'err'); });
});
// Fire Google Workspace scan if any Google sources are selected
const checkedGoogleIds = [];
document.querySelectorAll('#sourcesPanel input[data-source-type="google"]:checked').forEach(function(cb) {
checkedGoogleIds.push(cb.dataset.sourceId);
});
if (checkedGoogleIds.length > 0) {
// Collect selected Google user emails from the account list
var selectedGoogleEmails = S._allUsers
.filter(function(u) { return u.selected !== false && (u.platform === 'google' || u.platform === 'both'); })
.map(function(u) { return u.platform === 'both' ? u.googleEmail : u.email; })
.filter(Boolean);
fetch('/api/google/scan/start', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
sources: checkedGoogleIds,
user_emails: selectedGoogleEmails,
options: options
})
}).then(r => {
if (r.status === 409) { log('Google scan already running', 'err'); }
}).catch(e => { log('Google scan error: ' + e, 'err'); });
}
// All scan types fired above — no fallback error needed
}, 300);
}
function stopScan() {
fetch('/api/scan/stop', {method:'POST'});
}
// ── Trend sparkline (#7) ──────────────────────────────────────────────────────
function drawSparkline(data) {
const canvas = document.getElementById('sparkCanvas');
if (!canvas) return;
const dpr = window.devicePixelRatio || 1;
const W = canvas.offsetWidth || 220;
const H = 60;
canvas.width = W * dpr;
canvas.height = H * dpr;
const ctx = canvas.getContext('2d');
ctx.scale(dpr, dpr);
const flagged = data.map(d => d.flagged_count);
const overdue = data.map(d => d.overdue_count);
const maxVal = Math.max(...flagged, 1) * 1.2;
const n = data.length;
const xPos = i => (i / (n - 1)) * (W - 8) + 4;
const yPos = v => H - 4 - (v / maxVal) * (H - 10);
const isDark = document.body.getAttribute('data-theme') !== 'light';
const cBlue = '#378ADD';
const cAmber = '#BA7517';
const cFill = isDark ? 'rgba(55,138,221,0.12)' : 'rgba(55,138,221,0.08)';
// Fill under flagged line
ctx.beginPath();
ctx.moveTo(xPos(0), yPos(flagged[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(flagged[i]));
ctx.lineTo(xPos(n - 1), H);
ctx.lineTo(xPos(0), H);
ctx.closePath();
ctx.fillStyle = cFill;
ctx.fill();
// Flagged line
ctx.beginPath();
ctx.moveTo(xPos(0), yPos(flagged[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(flagged[i]));
ctx.strokeStyle = cBlue; ctx.lineWidth = 1.5; ctx.lineJoin = 'round';
ctx.stroke();
// Overdue dashed line
ctx.beginPath();
ctx.moveTo(xPos(0), yPos(overdue[0]));
for (let i = 1; i < n; i++) ctx.lineTo(xPos(i), yPos(overdue[i]));
ctx.strokeStyle = cAmber; ctx.lineWidth = 1;
ctx.setLineDash([3, 3]); ctx.stroke(); ctx.setLineDash([]);
// Dot on latest point
ctx.beginPath();
ctx.arc(xPos(n - 1), yPos(flagged[n - 1]), 3, 0, Math.PI * 2);
ctx.fillStyle = cBlue; ctx.fill();
// Labels: first, middle, last date (MM-DD only)
const lblEl = document.getElementById('sparkLabels');
if (lblEl) {
const fmt = d => d.scan_date.slice(5);
lblEl.innerHTML = `<span>${fmt(data[0])}</span><span>${fmt(data[Math.floor(n/2)])}</span><span>${fmt(data[n-1])}</span>`;
}
// Trend change label
const last = flagged[n - 1], prev = flagged[n - 2] || last;
const diff = last - prev;
const pct = prev ? Math.round(Math.abs(diff / prev) * 100) : 0;
const arrow = diff < 0 ? '↓' : diff > 0 ? '↑' : '→';
const color = diff < 0 ? 'var(--success)' : diff > 0 ? 'var(--danger)' : 'var(--muted)';
const chEl = document.getElementById('trendChange');
if (chEl) chEl.innerHTML = `<span style="color:${color}">${arrow} ${pct}%</span>`;
// Hover tooltip
canvas.onmousemove = e => {
const rect = canvas.getBoundingClientRect();
const mx = e.clientX - rect.left;
const idx = Math.round(((mx - 4) / (W - 8)) * (n - 1));
if (idx < 0 || idx >= n) return;
const d = data[idx];
const tip = document.getElementById('sparkTip');
if (!tip) return;
tip.style.display = 'block';
tip.textContent = `${d.scan_date} ${d.flagged_count} / ${d.overdue_count} overdue`;
tip.style.left = Math.min(mx, W - tip.offsetWidth - 4) + 'px';
};
canvas.onmouseleave = () => {
const tip = document.getElementById('sparkTip');
if (tip) tip.style.display = 'none';
};
}
async function loadTrend() {
try {
const r = await fetch('/api/db/trend?n=10');
if (!r.ok) return;
const data = await r.json();
if (!Array.isArray(data) || data.length < 2) return;
document.getElementById('trendPanel').style.display = 'block';
// Defer draw until canvas has layout width
setTimeout(() => drawSparkline(data), 60);
} catch(e) { /* DB not available */ }
}
function updateStats() {
document.getElementById('pillFlagged').textContent = S.flaggedData.length;
document.getElementById('pillScanned').textContent =
parseInt(document.getElementById('progressStats').textContent.split('/')[1] || '0') || 0;
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.exportDB = exportDB;
window.openImportDBModal = openImportDBModal;
window.closeImportDBModal = closeImportDBModal;
window.doImportDB = doImportDB;
window.buildScanPayload = buildScanPayload;
window.checkCheckpoint = checkCheckpoint;
window.clearCheckpointAndScan = clearCheckpointAndScan;
window.checkDeltaStatus = checkDeltaStatus;
window.clearDeltaTokens = clearDeltaTokens;
window.openSmtpModal = openSmtpModal;
window.closeSmtpModal = closeSmtpModal;
window.loadSmtpConfig = loadSmtpConfig;
window.saveSmtpConfig = saveSmtpConfig;
window.sendReport = sendReport;
window._smtpFields = _smtpFields;
window._attachScanListeners = _attachScanListeners;
window._attachSchedulerListeners = _attachSchedulerListeners;
window.startScan = startScan;
window.stopScan = stopScan;
window.drawSparkline = drawSparkline;
window.loadTrend = loadTrend;
window.updateStats = updateStats;
+439
View File
@@ -0,0 +1,439 @@
// ── Scheduler — multi-job (#19) ─────────────────────────────────────────────
var _schedJobs = [];
function schedLoad() {
fetch('/api/scheduler/jobs').then(function(r){ return r.json(); }).then(function(d) {
_schedJobs = d.jobs || [];
schedRenderJobs();
schedLoadHistory();
// Fetch status AFTER rendering so run buttons exist in the DOM
return fetch('/api/scheduler/status').then(function(r){ return r.json(); });
}).then(function(d) {
if (!d) return;
var noAps = document.getElementById('schedNoAps');
if (noAps) noAps.style.display = d.available ? 'none' : 'block';
schedUpdateSidebarIndicator(d);
(d.jobs || []).forEach(function(js) {
var descEl = document.getElementById('schedDesc_' + js.id);
if (!descEl) return;
var j2 = _schedJobs.find(function(x){ return x.id === js.id; });
var freqLabel = !j2 ? '' : (j2.frequency === 'weekly' ? 'Weekly' : j2.frequency === 'monthly' ? 'Monthly' : 'Daily');
var timeStr = !j2 ? '' : String(j2.hour||0).padStart(2,'0') + ':' + String(j2.minute||0).padStart(2,'0');
var base = freqLabel + ' ' + timeStr;
var runBtn = document.getElementById('schedRunBtn_' + js.id);
if (js.is_running) {
descEl.textContent = base + ' \u00b7 Running...';
if (runBtn) { runBtn.style.borderColor='#22c55e'; runBtn.style.color='#22c55e'; }
} else if (js.next_run) {
var dt = new Date(js.next_run);
descEl.textContent = base + ' \u00b7 Next: ' + dt.toLocaleString(undefined,{month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
if (runBtn) { runBtn.style.borderColor='var(--border)'; runBtn.style.color='var(--muted)'; }
} else {
descEl.textContent = base + (js.enabled ? '' : ' \u00b7 Disabled');
if (runBtn) { runBtn.style.borderColor='var(--border)'; runBtn.style.color='var(--muted)'; }
}
});
}).catch(function(e){ console.warn('schedLoad:', e); });
}
function schedRenderJobs() {
var list = document.getElementById('schedJobList');
if (!list) return;
if (!_schedJobs.length) {
list.innerHTML = '<div style="font-size:11px;color:var(--muted);padding:4px 0">No scheduled scans yet.</div>';
return;
}
list.innerHTML = _schedJobs.map(function(j) {
var sid = _esc(j.id);
var sname = _esc(j.name || 'Unnamed');
var freqLabel = j.frequency === 'weekly' ? 'Weekly' : j.frequency === 'monthly' ? 'Monthly' : 'Daily';
var timeStr = String(j.hour||0).padStart(2,'0') + ':' + String(j.minute||0).padStart(2,'0');
var desc = freqLabel + ' ' + timeStr;
var chk = j.enabled ? ' checked' : '';
return '<div style="display:flex;align-items:center;gap:6px;padding:5px 6px;border:1px solid var(--border);border-radius:6px;background:var(--surface)">'
+ '<label class="toggle" style="flex:unset;margin:0"><input type="checkbox"'+chk+' onchange="schedToggleEnabled(\''+sid+'\',this.checked)"><span class="toggle-slider"></span></label>'
+ '<div style="flex:1;min-width:0">'
+ '<div style="font-size:12px;font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">'+sname+'</div>'
+ '<div id="schedDesc_'+sid+'" style="font-size:10px;color:var(--muted)">'+desc+'</div>'
+ '</div>'
+ '<button onclick="schedRunJob(\''+sid+'\')" id="schedRunBtn_'+sid+'" style="background:none;border:1px solid var(--border);color:var(--muted);padding:2px 7px;border-radius:4px;font-size:10px;cursor:pointer" title="Run now">&#9654;</button>'
+ '<button onclick="schedEditJob(\''+sid+'\')" style="background:none;border:1px solid var(--border);color:var(--muted);padding:2px 7px;border-radius:4px;font-size:10px;cursor:pointer" title="Edit">&#9998;</button>'
+ '<button onclick="schedDeleteJob(\''+sid+'\')" style="background:none;border:1px solid var(--danger);color:var(--danger);padding:2px 7px;border-radius:4px;font-size:10px;cursor:pointer" title="Delete">&#10005;</button>'
+ '</div>';
}).join('');
}
function schedToggleEnabled(id, enabled) {
var j = _schedJobs.find(function(x){ return x.id === id; });
if (!j) return;
var updated = Object.assign({}, j, {enabled: enabled});
fetch('/api/scheduler/jobs/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(updated)
}).then(function(r){ return r.json(); }).then(function(d) {
if (d.error) { alert('Error: ' + d.error); return; }
j.enabled = enabled;
schedLoad();
}).catch(function(e){ alert('Error: ' + e); });
}
function schedAddJob() {
document.getElementById('schedEditId').value = '';
document.getElementById('schedName').value = '';
document.getElementById('schedEnabled').checked = true;
document.getElementById('schedFrequency').value = 'daily';
document.getElementById('schedDow').value = 'mon';
document.getElementById('schedDom').value = 1;
document.getElementById('schedHour').value = 2;
document.getElementById('schedMinute').value = 0;
document.getElementById('schedAutoEmail').checked = false;
document.getElementById('schedAutoRetention').checked = false;
var titleEl = document.getElementById('schedEditorTitle');
if (titleEl) titleEl.textContent = t('m365_sched_editor_new', 'New scheduled scan');
schedPopulateProfiles('');
schedToggleFreqRows();
document.getElementById('schedJobEditor').style.display = 'block';
document.getElementById('schedSaveStatus').textContent = '';
document.getElementById('schedName').focus();
}
function schedEditJob(id) {
var j = _schedJobs.find(function(x){ return x.id === id; });
if (!j) return;
document.getElementById('schedEditId').value = j.id;
document.getElementById('schedName').value = j.name || '';
document.getElementById('schedEnabled').checked = !!j.enabled;
document.getElementById('schedFrequency').value = j.frequency || 'daily';
document.getElementById('schedDow').value = j.day_of_week || 'mon';
document.getElementById('schedDom').value = j.day_of_month || 1;
document.getElementById('schedHour').value = j.hour != null ? j.hour : 2;
document.getElementById('schedMinute').value = j.minute != null ? j.minute : 0;
document.getElementById('schedAutoEmail').checked = !!j.auto_email;
document.getElementById('schedAutoRetention').checked = !!j.auto_retention;
var titleEl = document.getElementById('schedEditorTitle');
if (titleEl) titleEl.textContent = t('m365_sched_editor_edit', 'Edit scheduled scan');
schedPopulateProfiles(j.profile_id || '');
schedToggleFreqRows();
document.getElementById('schedJobEditor').style.display = 'block';
document.getElementById('schedSaveStatus').textContent = '';
}
function schedCancelEdit() {
document.getElementById('schedJobEditor').style.display = 'none';
}
function schedSaveJob() {
var name = document.getElementById('schedName').value.trim();
if (!name) {
var st = document.getElementById('schedSaveStatus');
st.textContent = t('m365_sched_name_required', 'Name is required');
st.style.color = 'var(--danger)';
document.getElementById('schedName').focus();
return;
}
var job = {
id: document.getElementById('schedEditId').value || '',
name: name,
enabled: document.getElementById('schedEnabled').checked,
frequency: document.getElementById('schedFrequency').value,
day_of_week: document.getElementById('schedDow').value,
day_of_month: parseInt(document.getElementById('schedDom').value) || 1,
hour: parseInt(document.getElementById('schedHour').value) || 0,
minute: parseInt(document.getElementById('schedMinute').value) || 0,
profile_id: document.getElementById('schedProfile').value,
auto_email: document.getElementById('schedAutoEmail').checked,
auto_retention: document.getElementById('schedAutoRetention').checked,
};
var st = document.getElementById('schedSaveStatus');
st.style.color = 'var(--muted)'; st.textContent = 'Saving...';
fetch('/api/scheduler/jobs/save', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(job)
}).then(function(r){ return r.json(); }).then(function(d) {
if (d.error) { st.style.color='var(--danger)'; st.textContent=d.error; return; }
st.style.color = 'var(--accent)'; st.textContent = '\u2713 Saved';
setTimeout(function(){ st.textContent=''; }, 1500);
document.getElementById('schedJobEditor').style.display = 'none';
schedLoad();
}).catch(function(e){ st.style.color='var(--danger)'; st.textContent=e.message; });
}
function schedDeleteJob(id) {
var j = _schedJobs.find(function(x){ return x.id === id; });
var name = j ? j.name : id;
if (!confirm('Delete "' + name + '"?')) return;
fetch('/api/scheduler/jobs/delete', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({id: id})
}).then(function(r){ return r.json(); }).then(function(d) {
if (d.error) { alert('Delete failed: ' + d.error); return; }
schedLoad();
}).catch(function(e){ alert('Delete error: ' + e); });
}
function schedRunJob(id) {
var j = _schedJobs.find(function(x){ return x.id === id; });
var name = j ? j.name : 'this scan';
if (!confirm('Run "' + name + '" now?')) return;
fetch('/api/scheduler/jobs/run_now', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({id: id})
}).then(function(r){ return r.json(); }).then(function(d) {
if (d.error) alert(d.error);
else schedLoad();
});
}
function schedToggleFreqRows() {
var freq = document.getElementById('schedFrequency');
if (!freq) return;
var val = freq.value;
var dowRow = document.getElementById('schedDowRow');
var domRow = document.getElementById('schedDomRow');
if (dowRow) dowRow.style.display = val === 'weekly' ? 'flex' : 'none';
if (domRow) domRow.style.display = val === 'monthly' ? 'flex' : 'none';
}
function schedPopulateProfiles(selectedId) {
fetch('/api/profiles').then(function(r){ return r.json(); }).then(function(d) {
var sel = document.getElementById('schedProfile');
if (!sel) return;
var firstOpt = sel.options[0];
sel.innerHTML = '';
sel.appendChild(firstOpt);
(d.profiles || []).forEach(function(p) {
var o = document.createElement('option');
o.value = p.id || p.name;
o.textContent = p.name;
if ((p.id || p.name) === selectedId) o.selected = true;
sel.appendChild(o);
});
});
}
function schedLoadHistory() {
var el = document.getElementById('schedHistory');
if (!el) return;
fetch('/api/scheduler/history?limit=10').then(function(r){ return r.json(); }).then(function(d) {
var runs = d.runs || [];
if (!runs.length) { el.innerHTML = '<em>No scheduled runs yet</em>'; return; }
var html = '';
runs.forEach(function(r) {
var ts = r.started_at ? new Date(r.started_at * 1000).toLocaleString() : '-';
var icon = r.status === 'completed' ? '\u2713' : r.status === 'failed' ? '\u2716' : '\u23f3';
var jname = r.job_name ? '<strong>' + _esc(r.job_name) + '</strong> - ' : '';
html += icon + ' ' + jname + ts + ' - ' + (r.flagged||0) + ' flagged';
if (r.emailed) html += ' \u2709';
if (r.error) html += ' <span style="color:var(--danger)">' + _esc(r.error.substring(0,60)) + '</span>';
html += '<br>';
});
el.innerHTML = html;
});
}
function schedUpdateSidebarIndicator(d) {
var wrap = document.getElementById('schedNextIndicator');
var txt = document.getElementById('schedNextText');
if (!wrap || !txt) return;
if (d && d.enabled && d.next_run) {
try {
var dt = new Date(d.next_run);
txt.textContent = t('m365_sched_next', 'Next') + ': ' + dt.toLocaleString(undefined, {month:'short',day:'numeric',hour:'2-digit',minute:'2-digit'});
wrap.style.display = 'inline-flex';
} catch(e) { wrap.style.display = 'none'; }
} else {
wrap.style.display = 'none';
}
}
// Poll scheduler status every 60s
setInterval(function() {
fetch('/api/scheduler/status').then(function(r){ return r.json(); }).then(function(d) {
schedUpdateSidebarIndicator(d);
}).catch(function(){});
}, 60000);
document.addEventListener('DOMContentLoaded', function() {
fetch('/api/scheduler/status').then(function(r){ return r.json(); }).then(function(d) {
schedUpdateSidebarIndicator(d);
}).catch(function(){});
});
// ── General tab ───────────────────────────────────────────────────────────────
function stPopulateGeneral() {
stLoadPinStatus();
// Populate language selector (mirrors the hidden langSelect)
const src = document.getElementById('langSelect');
const dst = document.getElementById('langSelectSettings');
if (src && dst && dst.options.length === 0) {
Array.from(src.options).forEach(function(opt) {
const o = document.createElement('option');
o.value = opt.value; o.textContent = opt.textContent;
if (opt.selected) o.selected = true;
dst.appendChild(o);
});
} else if (src && dst) {
dst.value = src.value;
}
// Populate About rows
fetch('/api/about').then(function(r){ return r.json(); }).then(function(d) {
const set = function(id, val) { const el=document.getElementById(id); if(el) el.textContent=val||'\u2014'; };
set('st-about-python', d.python);
set('st-about-msal', d.msal);
set('st-about-requests',d.requests);
set('st-about-openpyxl',d.openpyxl);
}).catch(function(){});
}
// ── Email tab ─────────────────────────────────────────────────────────────────
function stLoadSmtp() {
fetch('/api/smtp/config').then(function(r){ return r.json(); }).then(function(d) {
const set = function(id, val) { const el=document.getElementById(id); if(el) el.value=val||''; };
set('st-smtpHost', d.host);
set('st-smtpPort', d.port || 587);
set('st-smtpUser', d.user);
set('st-smtpFrom', d.from_addr);
set('st-smtpTo', Array.isArray(d.recipients) ? d.recipients.join(', ') : (d.recipients||''));
const tls = document.getElementById('st-smtpTls');
if (tls) tls.checked = d.starttls !== false;
const pw = document.getElementById('st-smtpPw');
if (pw) pw.value = d.has_password ? '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' : '';
}).catch(function(){});
}
async function stSmtpSave() {
const st = document.getElementById('st-smtpStatus');
const rawPw = document.getElementById('st-smtpPw').value;
const pw = rawPw === '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' ? null : rawPw;
const body = {
host: document.getElementById('st-smtpHost').value.trim(),
port: parseInt(document.getElementById('st-smtpPort').value) || 587,
user: document.getElementById('st-smtpUser').value.trim(),
from_addr: document.getElementById('st-smtpFrom').value.trim(),
recipients: document.getElementById('st-smtpTo').value.split(/[,;]/).map(function(s){return s.trim();}).filter(Boolean),
starttls: document.getElementById('st-smtpTls').checked,
};
if (pw !== null) body.password = pw;
st.style.color = 'var(--muted)'; st.textContent = t('m365_smtp_saving','Saving...');
try {
const r = await fetch('/api/smtp/config', {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)});
const d = await r.json();
if (d.error) { st.style.color='var(--danger)'; st.textContent=d.error; return; }
st.style.color='var(--accent)'; st.textContent='\u2714 '+t('m365_smtp_saved','Saved');
} catch(e){ st.style.color='var(--danger)'; st.textContent=e.message; }
}
async function stSmtpTest() {
const st = document.getElementById('st-smtpStatus');
await stSmtpSave();
if (st) { st.style.color='var(--muted)'; st.textContent=t('m365_smtp_testing','Testing connection\u2026'); }
try {
const r = await fetch('/api/smtp/test', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({})});
const d = await r.json();
if (d.ok) {
if (st) { st.style.color='var(--accent)'; st.textContent='\u2714 ' + (d.message || t('m365_smtp_test_ok','Connection successful')); }
} else {
if (st) { st.style.color='var(--danger)'; st.textContent='\u2717 ' + (d.error || t('m365_smtp_test_fail','Connection failed')); }
}
} catch(e) {
if (st) { st.style.color='var(--danger)'; st.textContent='\u2717 ' + e.message; }
}
}
async function stSmtpSend() {
const st = document.getElementById('st-smtpStatus');
// First save current field values
await stSmtpSave();
// Check we have recipients
const recipStr = document.getElementById('st-smtpTo').value.trim();
if (!recipStr) {
if (st) { st.style.color='var(--danger)'; st.textContent=t('m365_smtp_no_recipients','Enter at least one recipient.'); }
return;
}
const recipients = recipStr.split(/[,;]/).map(function(s){return s.trim();}).filter(Boolean);
const rawPw = document.getElementById('st-smtpPw').value;
const cfg = {
host: document.getElementById('st-smtpHost').value.trim(),
port: parseInt(document.getElementById('st-smtpPort').value) || 587,
username: document.getElementById('st-smtpUser').value.trim(),
password: rawPw === '\u2022\u2022\u2022\u2022\u2022\u2022\u2022\u2022' ? null : rawPw,
from_addr: document.getElementById('st-smtpFrom').value.trim(),
use_tls: document.getElementById('st-smtpTls').checked,
use_ssl: false,
};
if (st) { st.style.color='var(--muted)'; st.textContent=t('m365_smtp_sending','Sending\u2026'); }
try {
const r = await fetch('/api/send_report', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({recipients, smtp:cfg})});
const d = await r.json();
if (d.status === 'sent') {
if (st) { st.style.color='var(--accent)'; st.textContent=t('m365_smtp_sent','\u2714 Sent'); }
log(t('m365_smtp_sent','Report sent to') + ' ' + recipients.join(', '), 'ok');
} else {
if (st) { st.style.color='var(--danger)'; st.textContent=d.error||'Send failed'; }
log('Email send failed: '+(d.error||''),'err');
}
} catch(e){
if (st) { st.style.color='var(--danger)'; st.textContent=e.message; }
}
}
// ── Database tab ──────────────────────────────────────────────────────────────
function stLoadDbStats() {
fetch('/api/db/stats').then(function(r){ return r.json(); }).then(function(d) {
const el = document.getElementById('st-dbStats');
if (!el) return;
if (d.error) { el.textContent = d.error; return; }
el.innerHTML =
'<span>' + t('m365_stat_scanned','Scanned items') + '</span>: <strong>' + (d.total_items||0) + '</strong><br>' +
'<span>' + t('m365_stat_flagged','Flagged items') + '</span>: <strong>' + (d.flagged_items||0) + '</strong><br>' +
'<span>' + t('m365_db_scans','Scans') + '</span>: <strong>' + (d.total_scans||0) + '</strong>';
}).catch(function(){ });
}
function stResetDB() {
if (!confirm(t('m365_db_reset_confirm','Reset database? All scan results will be deleted.'))) return;
requirePin(t('m365_settings_enter_pin_reset','Enter admin PIN to reset the database.'), function(pin) {
fetch('/api/db/reset', {method:'POST', headers:{'Content-Type':'application/json'},
body:JSON.stringify({confirm:'yes', pin:pin})
}).then(function(r){ return r.json(); }).then(function(d) {
if (d.error === 'incorrect_pin') { log(t('m365_settings_pin_wrong','Incorrect PIN \u2014 reset cancelled.'), 'err'); return; }
if (d.error) { log('Reset failed: '+d.error, 'err'); return; }
stLoadDbStats();
log(t('m365_db_reset_done','Database reset'));
}).catch(function(e){ log('Reset failed: '+e,'err'); });
});
}
// Redirect old openSmtpModal to Settings email tab
function openSmtpModal(send) {
openSettings('email');
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.schedLoad = schedLoad;
window.schedRenderJobs = schedRenderJobs;
window.schedToggleEnabled = schedToggleEnabled;
window.schedAddJob = schedAddJob;
window.schedEditJob = schedEditJob;
window.schedCancelEdit = schedCancelEdit;
window.schedSaveJob = schedSaveJob;
window.schedDeleteJob = schedDeleteJob;
window.schedRunJob = schedRunJob;
window.schedToggleFreqRows = schedToggleFreqRows;
window.schedPopulateProfiles = schedPopulateProfiles;
window.schedLoadHistory = schedLoadHistory;
window.schedUpdateSidebarIndicator = schedUpdateSidebarIndicator;
window.stPopulateGeneral = stPopulateGeneral;
window.stLoadSmtp = stLoadSmtp;
window.stSmtpSave = stSmtpSave;
window.stSmtpTest = stSmtpTest;
window.stSmtpSend = stSmtpSend;
window.stLoadDbStats = stLoadDbStats;
window.stResetDB = stResetDB;
window.openSmtpModal = openSmtpModal;
window._schedJobs = _schedJobs;
+269
View File
@@ -0,0 +1,269 @@
import { S } from './state.js';
// ── Dynamic sources panel ─────────────────────────────────────────────────────
// Fixed M365 sources — always present when authenticated
const _M365_SOURCES = [
{ id: 'email', icon: '\uD83D\uDCE7', labelKey: 'm365_src_email', labelDefault: 'Exchange / Outlook', toggleId: 'smSrcEmail' },
{ id: 'onedrive', icon: '\uD83D\uDCBE', labelKey: 'm365_src_onedrive', labelDefault: 'OneDrive', toggleId: 'smSrcOneDrive' },
{ id: 'sharepoint', icon: '\uD83C\uDF10', labelKey: 'm365_src_sharepoint', labelDefault: 'SharePoint', toggleId: 'smSrcSharePoint' },
{ id: 'teams', icon: '\uD83D\uDCAC', labelKey: 'm365_src_teams', labelDefault: 'Teams', toggleId: 'smSrcTeams' },
];
// Future connector stubs — uncomment when implemented
// const _GMAIL_SOURCE = { id: 'gmail', icon: '\uD83D\uDCE7', labelKey: 'm365_src_gmail', labelDefault: 'Gmail', type: 'm365' };
// const _GDRIVE_SOURCE = { id: 'googledrive', icon: '\uD83D\uDCC1', labelKey: 'm365_src_googledrive', labelDefault: 'Google Drive', type: 'm365' };
function renderSourcesPanel() {
const panel = document.getElementById('sourcesPanel');
if (!panel) return;
// Remember currently checked state before re-render
const checked = {};
panel.querySelectorAll('input[data-source-id]').forEach(function(cb) {
checked[cb.dataset.sourceId] = cb.checked;
});
let html = '';
// M365 fixed sources — only show if their toggle in Source Management is on
_M365_SOURCES.forEach(function(s) {
const toggle = s.toggleId ? document.getElementById(s.toggleId) : null;
if (toggle && !toggle.checked) return; // hidden by user in Source Management
const isChecked = (s.id in checked) ? checked[s.id] : true;
html += '<label class="source-check">'
+ '<input type="checkbox" data-source-id="' + s.id + '" data-source-type="m365"' + (isChecked ? ' checked' : '') + ' onchange="_onSourceChange()">'
+ '<span class="source-icon">' + s.icon + '</span>'
+ '<span class="source-label" data-i18n="' + s.labelKey + '">' + t(s.labelKey, s.labelDefault) + '</span>'
+ '</label>';
});
// Google Workspace sources — only show if connected
if (window._googleConnected) {
var gmailToggle = document.getElementById('smGoogleSrcGmail');
var driveToggle = document.getElementById('smGoogleSrcDrive');
var showGmail = !gmailToggle || gmailToggle.checked;
var showDrive = !driveToggle || driveToggle.checked;
if (showGmail || showDrive) {
html += '<div style="margin:6px 0 2px"><hr style="border:none;border-top:1px solid var(--border);margin:1px 0 2px"></div>';
}
if (showGmail) {
var isCheckedG = ('gmail' in checked) ? checked['gmail']
: S._pendingGoogleSources !== null ? S._pendingGoogleSources.includes('gmail')
: true;
html += '<label class="source-check"><input type="checkbox" data-source-id="gmail" data-source-type="google"' + (isCheckedG ? ' checked' : '') + ' onchange="_onSourceChange()"><span class="source-icon">📧</span><span class="source-label">Gmail</span></label>';
}
if (showDrive) {
var isCheckedD = ('gdrive' in checked) ? checked['gdrive']
: S._pendingGoogleSources !== null ? S._pendingGoogleSources.includes('gdrive')
: true;
html += '<label class="source-check"><input type="checkbox" data-source-id="gdrive" data-source-type="google"' + (isCheckedD ? ' checked' : '') + ' onchange="_onSourceChange()"><span class="source-icon">📁</span><span class="source-label">Google Drive</span></label>';
}
// Pending has been applied — clear it
S._pendingGoogleSources = null;
}
// File sources (local / SMB) — one entry per saved source
if (S._fileSources.length > 0) {
html += '<div style="margin:6px 0 2px;font-size:10px;color:var(--muted);text-transform:uppercase;letter-spacing:.04em">'
+ '<hr style="border:none;border-top:1px solid var(--border);margin:1px 0 2px">';
S._fileSources.forEach(function(s) {
const isSmb = s.path && (s.path.startsWith('//') || s.path.startsWith('\\\\'));
const icon = isSmb ? '\uD83C\uDF10' : '\uD83D\uDCC1';
const label = s.label || s.path || s.id;
const isChecked = (s.id in checked) ? checked[s.id] : true;
html += '<label class="source-check">'
+ '<input type="checkbox" data-source-id="' + _esc(s.id) + '" data-source-type="file"' + (isChecked ? ' checked' : '') + '>'
+ '<span class="source-icon">' + icon + '</span>'
+ '<span class="source-label" title="' + _esc(s.path || '') + '">' + _esc(label) + '</span>'
+ '</label>';
});
}
panel.innerHTML = html;
// Resize panel to fit all rendered sources (respects user's saved smaller preference)
if (typeof _fitSourcesPanel === 'function') _fitSourcesPanel();
// Grey out the accounts section when no M365 sources are selected
_updateAccountsVisibility();
}
function _onSourceChange() {
_updateAccountsVisibility();
renderAccountList();
}
function _onGoogleSourceToggle() {
// Re-render sources panel (hides/shows Gmail+Drive checkboxes in KILDER)
renderSourcesPanel();
// Re-render accounts — 'both' users show as M365-only when Google sources disabled
renderAccountList();
// Persist toggle state
var gm = document.getElementById('smGoogleSrcGmail');
var gd = document.getElementById('smGoogleSrcDrive');
fetch('/api/src_toggles', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({
src_gmail: gm ? gm.checked : true,
src_drive: gd ? gd.checked : true
})
}).catch(function(){});
}
function _saveM365SourceToggles() {
var state = {};
_M365_SOURCES.forEach(function(s) {
var el = s.toggleId ? document.getElementById(s.toggleId) : null;
if (el) state['src_toggle_' + s.id] = el.checked;
});
fetch('/api/src_toggles', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(state)
}).catch(function(){});
}
function _restoreM365SourceToggles(settings) {
_M365_SOURCES.forEach(function(s) {
var el = s.toggleId ? document.getElementById(s.toggleId) : null;
var key = 'src_toggle_' + s.id;
if (el && settings[key] !== undefined) el.checked = !!settings[key];
});
renderSourcesPanel();
}
function _googleSourcesEnabled() {
return !!(document.getElementById('smGoogleSrcGmail') && document.getElementById('smGoogleSrcGmail').checked)
|| !!(document.getElementById('smGoogleSrcDrive') && document.getElementById('smGoogleSrcDrive').checked);
}
function _updateAccountsVisibility() {
const panel = document.getElementById('sourcesPanel');
const anyActive = panel
? Array.from(panel.querySelectorAll('input[data-source-type]')).some(cb => cb.checked)
: false;
const sec = document.getElementById('accountsSection');
if (!sec) return;
sec.style.opacity = anyActive ? '1' : '0.35';
sec.style.pointerEvents = anyActive ? '' : 'none';
sec.title = anyActive ? '' : t('m365_accounts_disabled_tip', 'Select a source to enable account selection');
}
// ── Admin PIN ─────────────────────────────────────────────────────────────────
let _pinCallback = null;
async function stLoadPinStatus() {
const r = await fetch('/api/admin/pin');
const d = await r.json();
const statusEl = document.getElementById('stPinStatus');
const currentRow = document.getElementById('stCurrentPinRow');
if (d.pin_set) {
if (statusEl) statusEl.textContent = '\u2714 ' + t('m365_settings_pin_set', 'Admin PIN is set');
if (currentRow) currentRow.style.display = '';
} else {
if (statusEl) statusEl.textContent = t('m365_settings_pin_not_set', 'No PIN set \u2014 Reset DB is unprotected');
if (currentRow) currentRow.style.display = 'none';
}
}
async function stSavePin() {
const newPin = document.getElementById('stNewPin').value;
const confirmPin = document.getElementById('stConfirmPin').value;
const currentPin = document.getElementById('stCurrentPin')?.value || '';
const st = document.getElementById('stPinSaveStatus');
if (!newPin) { st.style.color='var(--danger)'; st.textContent=t('m365_settings_pin_required','New PIN is required.'); return; }
if (newPin !== confirmPin) { st.style.color='var(--danger)'; st.textContent=t('m365_settings_pin_mismatch','PINs do not match.'); return; }
st.style.color='var(--muted)'; st.textContent=t('m365_fsrc_saving','Saving...');
try {
const r = await fetch('/api/admin/pin', {method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({current_pin: currentPin, new_pin: newPin})});
const d = await r.json();
if (d.error === 'incorrect_pin') { st.style.color='var(--danger)'; st.textContent=t('m365_settings_pin_wrong','Current PIN is incorrect.'); return; }
if (d.error) { st.style.color='var(--danger)'; st.textContent=d.error; return; }
st.style.color='var(--accent)'; st.textContent='\u2714 '+t('m365_settings_pin_saved','PIN saved');
['stNewPin','stConfirmPin','stCurrentPin'].forEach(function(id){const el=document.getElementById(id);if(el)el.value='';});
stLoadPinStatus();
} catch(e){ st.style.color='var(--danger)'; st.textContent=e.message; }
}
// PIN prompt — used for destructive actions
function requirePin(message, callback) {
fetch('/api/admin/pin').then(function(r){return r.json();}).then(function(d) {
if (!d.pin_set) {
// No PIN set — proceed directly
callback('');
return;
}
_pinCallback = callback;
const msg = document.getElementById('pinPromptMsg');
const inp = document.getElementById('pinPromptInput');
const err = document.getElementById('pinPromptError');
if (msg) msg.textContent = message || t('m365_settings_enter_pin','Enter admin PIN to continue.');
if (inp) inp.value = '';
if (err) err.textContent = '';
document.getElementById('pinPromptBackdrop').classList.add('open');
setTimeout(function(){ if(inp) inp.focus(); }, 100);
});
}
function closePinPrompt() {
document.getElementById('pinPromptBackdrop').classList.remove('open');
_pinCallback = null;
}
function confirmPinPrompt() {
const pin = document.getElementById('pinPromptInput').value;
const err = document.getElementById('pinPromptError');
if (!pin) { if(err) err.textContent = t('m365_settings_pin_required','PIN is required.'); return; }
const cb = _pinCallback; // save before closePinPrompt nulls it
closePinPrompt();
if (cb) cb(pin);
}
// ── Settings modal ────────────────────────────────────────────────────────────
function openSettings(tab) {
document.getElementById('settingsBackdrop').classList.add('open');
switchSettingsTab(tab || 'general');
stPopulateGeneral();
if (tab === 'email') stLoadSmtp();
if (tab === 'database') stLoadDbStats();
if (tab === 'scheduler') schedLoad();
}
function closeSettings() {
document.getElementById('settingsBackdrop').classList.remove('open');
}
function switchSettingsTab(tab) {
['general','security','scheduler','email','database'].forEach(function(t) {
var cap = t.charAt(0).toUpperCase() + t.slice(1);
var pane = document.getElementById('stPane' + cap);
var btn = document.getElementById('stTab' + cap);
if (pane) pane.classList.toggle('active', t === tab);
if (btn) btn.classList.toggle('active', t === tab);
});
if (tab === 'security') { stLoadPinStatus(); if (typeof stLoadViewerPinStatus === 'function') stLoadViewerPinStatus(); }
if (tab === 'email') stLoadSmtp();
if (tab === 'database') stLoadDbStats();
if (tab === 'scheduler') schedLoad();
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.renderSourcesPanel = renderSourcesPanel;
window._onSourceChange = _onSourceChange;
window._onGoogleSourceToggle = _onGoogleSourceToggle;
window._saveM365SourceToggles = _saveM365SourceToggles;
window._restoreM365SourceToggles = _restoreM365SourceToggles;
window._googleSourcesEnabled = _googleSourcesEnabled;
window._updateAccountsVisibility = _updateAccountsVisibility;
window.stLoadPinStatus = stLoadPinStatus;
window.stSavePin = stSavePin;
window.requirePin = requirePin;
window.closePinPrompt = closePinPrompt;
window.confirmPinPrompt = confirmPinPrompt;
window.openSettings = openSettings;
window.closeSettings = closeSettings;
window.switchSettingsTab = switchSettingsTab;
window._M365_SOURCES = _M365_SOURCES;
window._pinCallback = _pinCallback;
+31
View File
@@ -0,0 +1,31 @@
// state.js — shared mutable state for GDPRScanner
// Imported by every module that needs cross-module state.
// Use S.varName everywhere instead of bare varName.
export const S = {
// Scan results
flaggedData: [],
filteredData: [],
totalCPR: 0,
isListView: false,
// SSE connection
es: null,
_userStartedScan: false,
// Scan running flags + progress
_m365ScanRunning: false,
_googleScanRunning: false,
_fileScanRunning: false,
_srcPct: { m365: 0, google: 0, file: 0 },
_progressCurrentUser: '',
// Users
_allUsers: [],
// Auth
_currentAppMode: null,
// Profiles
_profiles: [],
_activeProfileId: null,
_pendingProfileSources: [],
_pendingGoogleSources: null,
// Sources
_fileSources: [],
};
+120
View File
@@ -0,0 +1,120 @@
import { S } from './state.js';
// Global error trap — logs JS errors to console without blocking the page
window.onerror = function(msg, src, line, col, err) {
console.error('JS Error [' + (src||'').split('/').pop() + ':' + line + '] ' + msg, err);
return false;
};
window.addEventListener('unhandledrejection', function(e) {
console.error('Unhandled promise rejection:', e.reason);
});
// ── Theme ────────────────────────────────────────────────────────────────────
function openAbout() {
document.getElementById('aboutBackdrop').classList.add('open');
fetch('/api/about').then(r => r.json()).then(d => {
document.getElementById('about-python').textContent = d.python || '—';
document.getElementById('about-msal').textContent = d.msal || '—';
document.getElementById('about-requests').textContent = d.requests || '—';
document.getElementById('about-openpyxl').textContent = d.openpyxl || '—';
}).catch(() => {});
}
function closeAbout() {
document.getElementById('aboutBackdrop').classList.remove('open');
}
// ── Mode info modal ───────────────────────────────────────────────────────────
function openModeInfo() {
const isApp = S._currentAppMode === true;
const title = document.getElementById('modeInfoTitle');
const sub = document.getElementById('modeInfoSubtitle');
const rows = document.getElementById('modeInfoRows');
if (isApp) {
title.textContent = t('m365_mode_app', '🔑 App mode — org-wide');
sub.textContent = t('m365_auth_mode_app_short', 'Application permissions · client credentials');
rows.innerHTML = `
<div class="about-row"><span>${t('m365_info_permissions','Permissions')}</span><span>Application</span></div>
<div class="about-row"><span>${t('m365_info_signin','Sign-in required')}</span><span>${t('m365_info_no','No')}</span></div>
<div class="about-row"><span>${t('m365_info_scope','Scope')}</span><span>${t('m365_info_scope_org','All users in tenant')}</span></div>
<div class="about-row"><span>${t('m365_info_consent','Admin consent')}</span><span>${t('m365_info_required','Required')}</span></div>
<div style="margin-top:12px;font-size:11px;color:var(--muted);line-height:1.6">
${t('m365_info_app_desc','The app authenticates with a Client Secret and accesses all users\' data directly via Microsoft Graph — no interactive sign-in needed. Ideal for automated or scheduled scans.')}
</div>`;
} else {
title.textContent = t('m365_mode_delegated', '👤 Delegated');
sub.textContent = t('m365_auth_mode_delegated_short', 'Delegated permissions · device code flow');
rows.innerHTML = `
<div class="about-row"><span>${t('m365_info_permissions','Permissions')}</span><span>Delegated</span></div>
<div class="about-row"><span>${t('m365_info_signin','Sign-in required')}</span><span>${t('m365_info_yes','Yes')}</span></div>
<div class="about-row"><span>${t('m365_info_scope','Scope')}</span><span>${t('m365_info_scope_user','Signed-in user only')}</span></div>
<div class="about-row"><span>${t('m365_info_admin','Global Admin')}</span><span>${t('m365_info_expands_scope','Expands scope to all users')}</span></div>
<div style="margin-top:12px;font-size:11px;color:var(--muted);line-height:1.6">
${t('m365_info_delegated_desc','The app acts on behalf of the signed-in user via the device code flow. By default only that user\'s data is accessible. A Global Admin can grant broader consent to scan all users.')}
</div>`;
}
document.getElementById('modeInfoBackdrop').classList.add('open');
}
function closeModeInfo() {
document.getElementById('modeInfoBackdrop').classList.remove('open');
}
function toggleTheme() {
const t = document.body.dataset.theme === 'dark' ? 'light' : 'dark';
document.body.dataset.theme = t;
document.getElementById('themeBtn').textContent = t === 'dark' ? '🌙' : '☀️';
try { localStorage.setItem('m365_theme', t); } catch(e) {}
}
(function() {
try {
const t = localStorage.getItem('m365_theme');
if (t) {
document.body.dataset.theme = t;
const btn = document.getElementById('themeBtn');
if (btn) btn.textContent = t === 'dark' ? '🌙' : '☀️';
}
} catch(e) {}
})();
// ── Language selector ─────────────────────────────────────────────────────────
fetch('/api/langs').then(r => r.json()).then(d => {
const sel = document.getElementById('langSelect');
if (!sel || !d.langs || d.langs.length < 2) {
if (sel) sel.style.display = 'none';
return;
}
d.langs.forEach(l => {
const opt = document.createElement('option');
opt.value = l.code;
opt.textContent = l.name;
if (l.code === d.current) opt.selected = true;
sel.appendChild(opt);
});
}).catch(() => {
const sel = document.getElementById('langSelect');
if (sel) sel.style.display = 'none';
});
async function setLang(code) {
const r = await fetch('/api/set_lang', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({lang: code})
});
const d = await r.json();
if (d.translations) {
// Update the in-memory LANG dict and re-apply all translations in place.
// This keeps all scan results, cards, and state intact.
Object.assign(LANG, d.translations);
applyI18n();
// Re-render the grid so card text (source badges etc.) picks up new strings
if (S.flaggedData.length) renderGrid(S.filteredData.length ? S.filteredData : S.flaggedData);
}
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.openAbout = openAbout;
window.closeAbout = closeAbout;
window.openModeInfo = openModeInfo;
window.closeModeInfo = closeModeInfo;
window.toggleTheme = toggleTheme;
window.setLang = setLang;
+475
View File
@@ -0,0 +1,475 @@
import { S } from './state.js';
// ── Accounts ──────────────────────────────────────────────────────────────────
async function loadUsers() {
const list = document.getElementById('accountsList');
const loading = document.getElementById('accountsLoading');
if (!list) return;
if (loading) loading.textContent = t('lbl_loading', 'Loading…');
// Ensure source panel checkboxes exist before we render the account list
if (!document.querySelector('#sourcesPanel input') && typeof renderSourcesPanel === 'function') {
renderSourcesPanel();
}
try {
const r = await fetch('/api/users');
if (!r.ok) { if (loading) loading.textContent = 'Could not load users'; return; }
const d = await r.json();
if (d.error) { if (loading) loading.textContent = d.error; return; }
// Merge with any manually-added users, preserving them
const fetched = d.users || [];
fetched.forEach(u => { u.platform = 'm365'; });
const existingManual = S._allUsers.filter(u => u.manual);
const fetchedIds = new Set(fetched.map(u => u.id));
const toAdd = existingManual.filter(u => !fetchedIds.has(u.id));
// Preserve existing selected state for users already in S._allUsers;
// new users default to selected=true
const prevSelected = new Map(S._allUsers.map(u => [u.id, u.selected]));
fetched.forEach(u => {
u.selected = prevSelected.has(u.id) ? prevSelected.get(u.id) : false;
});
S._allUsers = [...fetched, ...toAdd];
renderAccountList(fetched.length <= 1);
// Merge Google users separately so they're not blocked by M365 auth timing
_mergeGoogleUsers();
checkCheckpoint();
checkDeltaStatus();
_applyPendingProfileUsers();
// Show warning banner when no users could be classified
const warn = document.getElementById('skuWarnBanner');
if (warn) {
const allOther = fetched.length > 0 && fetched.every(u => u.userRole === 'other');
warn.style.display = allOther ? 'block' : 'none';
}
} catch(e) {
if (loading) loading.textContent = 'Could not load users';
}
}
async function _mergeGoogleUsers() {
if (!window._googleConnected) return;
try {
var gr = await fetch('/api/google/scan/users');
if (!gr.ok) return;
var gd = await gr.json();
if (gd.error) return;
var prevSelected = new Map(S._allUsers.map(function(u){ return [u.id, u.selected]; }));
// Build displayName → Google user map for cross-platform matching
// Both M365 and GWS are maintained from AD — full name is identical
var googleByName = {};
(gd.users || []).forEach(function(gu) {
var name = (gu.displayName || '').trim().toLowerCase();
if (name) googleByName[name] = gu;
});
// Merge onto M365 users where display name matches
var matchedNames = new Set();
S._allUsers.forEach(function(u) {
if ((u.platform || 'm365') !== 'm365') return;
var name = (u.displayName || '').trim().toLowerCase();
var gu = googleByName[name];
if (gu) {
u.platform = 'both';
u.googleEmail = gu.email;
// Keep M365 displayName (from AD, authoritative)
matchedNames.add(name);
} else {
// Clear previous merge if Google disconnected
delete u.googleEmail;
u.platform = 'm365';
}
});
// Add unmatched Google users as standalone entries
var googleUsers = [];
(gd.users || []).forEach(function(gu) {
var name = (gu.displayName || '').trim().toLowerCase();
if (matchedNames.has(name)) return; // already merged
var uid = 'google:' + gu.email;
googleUsers.push({
id: uid,
displayName: gu.displayName || gu.email,
email: gu.email,
userRole: gu.userRole || 'other',
platform: 'google',
selected: prevSelected.has(uid) ? prevSelected.get(uid) : false,
});
});
// Remove stale standalone Google users, add fresh unmatched ones
S._allUsers = S._allUsers.filter(function(u){ return (u.platform||'m365') !== 'google'; });
S._allUsers = S._allUsers.concat(googleUsers);
renderAccountList();
} catch(e) { /* Google users unavailable */ }
}
let _activeRoleFilter = ''; // '' = all, 'staff', 'student'
// ── Sidebar section collapse ──────────────────────────────────────────────────
const _COLLAPSE_SECTIONS = ['sourcesPanelSection', 'optionsSection', 'accountsSection', 'logSection'];
function toggleSection(id) {
const body = document.getElementById(id + 'Body');
if (!body) return;
const collapsing = body.style.display !== 'none';
body.style.display = collapsing ? 'none' : '';
const btn = document.getElementById(id + '-btn');
if (btn) btn.textContent = collapsing ? '▸' : '▾';
if (id === 'accountsSection') {
const sec = document.getElementById('accountsSection');
if (sec) sec.style.flex = collapsing ? '0 0 auto' : '1';
}
try { localStorage.setItem('sc_' + id, collapsing ? '1' : '0'); } catch(e) {}
}
function restoreSectionStates() {
_COLLAPSE_SECTIONS.forEach(function(id) {
try {
if (localStorage.getItem('sc_' + id) === '1') {
const body = document.getElementById(id + 'Body');
if (body) body.style.display = 'none';
const btn = document.getElementById(id + '-btn');
if (btn) btn.textContent = '▸';
if (id === 'accountsSection') {
const sec = document.getElementById('accountsSection');
if (sec) sec.style.flex = '0 0 auto';
}
}
} catch(e) {}
});
}
// ── Role filter with counts ───────────────────────────────────────────────────
function updateRoleFilterCounts() {
const total = S._allUsers.filter(function(u){ return !u.manual; }).length;
const staff = S._allUsers.filter(function(u){ return !u.manual && u.userRole === 'staff'; }).length;
const student = S._allUsers.filter(function(u){ return !u.manual && u.userRole === 'student'; }).length;
const btnAll = document.getElementById('rfAll');
const btnStaff = document.getElementById('rfStaff');
const btnStudent = document.getElementById('rfStudent');
if (btnAll) btnAll.textContent = t('m365_role_all','All') + (total ? ' (' + total + ')' : '');
if (btnStaff) btnStaff.textContent = t('role_staff','Ansat') + (staff ? ' (' + staff + ')' : '');
if (btnStudent) btnStudent.textContent = t('role_student','Elev') + (student ? ' (' + student + ')' : '');
}
function setRoleFilter(role) {
_activeRoleFilter = role;
[['rfAll',''],['rfStaff','staff'],['rfStudent','student']].forEach(function(pair) {
const btn = document.getElementById(pair[0]);
if (!btn) return;
const active = role === pair[1];
btn.style.background = active ? 'var(--accent)' : 'none';
btn.style.color = active ? '#fff' : 'var(--muted)';
});
updateRoleFilterCounts();
filterUsers();
}
// ── Last scan summary (empty state) ──────────────────────────────────────────
async function loadLastScanSummary() {
try {
const r = await fetch('/api/db/stats');
const d = await r.json();
if (!d.scan_id || S.flaggedData.length > 0) return;
const panel = document.getElementById('lastScanSummary');
const empty = document.getElementById('emptyState');
if (!panel || !empty) return;
const dateStr = d.finished_at
? new Date(d.finished_at * 1000).toLocaleDateString('da-DK', {day:'numeric', month:'short', year:'numeric'})
: '—';
const sources = Object.keys(d.by_source || {});
const srcLabels = {'email':'Outlook','onedrive':'OneDrive','sharepoint':'SharePoint','teams':'Teams',
'gmail':'Gmail','gdrive':'Drive','local':'Lokale filer','smb':'SMB'};
const srcStr = sources.map(function(s){ return srcLabels[s] || s; }).join(' · ') || '—';
panel.innerHTML =
'<div class="last-scan-card">' +
'<h3>' + t('last_scan_title', 'Seneste scanning') + '</h3>' +
'<div class="last-scan-stats">' +
'<div class="last-scan-stat"><span class="val">' + (d.flagged_count || 0) + '</span><span class="lbl">' + t('last_scan_hits', 'Fund') + '</span></div>' +
'<div class="last-scan-stat"><span class="val">' + (d.unique_subjects || 0) + '</span><span class="lbl">' + t('last_scan_subjects', 'Unikke CPR') + '</span></div>' +
'<div class="last-scan-stat"><span class="val">' + (d.total_scanned || 0) + '</span><span class="lbl">' + t('last_scan_scanned', 'Scannet') + '</span></div>' +
'</div>' +
'<div style="margin-top:12px;font-size:11px;color:var(--muted)">' + dateStr + ' &nbsp;·&nbsp; ' + srcStr + '</div>' +
'</div>' +
'<div class="empty-text" style="font-size:12px">' + t('m365_empty_hint', 'Vælg kilder og klik på <strong>Scan</strong><br>for at starte en ny scanning') + '</div>';
empty.style.display = 'none';
panel.style.display = 'flex';
} catch(e) {}
}
function renderAccountList(showAdminNote = false) {
updateRoleFilterCounts();
const list = document.getElementById('accountsList');
if (!list) return;
const q = (document.getElementById('userSearch')?.value || '').toLowerCase().trim();
let visible = S._allUsers;
// Filter by platform: only show accounts relevant to checked sources
// If the sources panel hasn't been rendered yet (no checkboxes at all), treat M365 as active
var panelHasAny = !!document.querySelector('#sourcesPanel input[data-source-type]');
var hasM365Src = panelHasAny
? !!document.querySelector('#sourcesPanel input[data-source-type="m365"]:checked')
: S._allUsers.some(function(u){ return !u.platform || u.platform === 'm365' || u.platform === 'both'; });
var hasGoogleSrc = !!document.querySelector('#sourcesPanel input[data-source-type="google"]:checked');
// Always filter — if neither is active, show nothing
// Check if Google is enabled in Source Management (not just selected in KILDER)
var googleEnabled = !!(document.getElementById('smGoogleSrcGmail') && document.getElementById('smGoogleSrcGmail').checked)
|| !!(document.getElementById('smGoogleSrcDrive') && document.getElementById('smGoogleSrcDrive').checked);
var effectiveGws = hasGoogleSrc && googleEnabled;
visible = visible.filter(function(u) {
var plat = u.platform || 'm365';
if (plat === 'both') return hasM365Src || effectiveGws;
return (plat === 'm365' && hasM365Src) || (plat === 'google' && effectiveGws);
});
// Apply role filter first
if (_activeRoleFilter) {
visible = visible.filter(u => (u.userRole || 'other') === _activeRoleFilter);
}
// Then apply text search
if (q) {
visible = visible.filter(u =>
(u.displayName || '').toLowerCase().includes(q) ||
(u.email || '').toLowerCase().includes(q));
}
_updateUserCountBadge(visible.length, S._allUsers.length);
const note = (!q && !_activeRoleFilter && showAdminNote)
? `<div style="font-size:10px;color:var(--muted);padding:4px 0 6px;line-height:1.4">${t('m365_admin_note','Only showing your account. To list all users, an admin must grant <strong>User.Read.All</strong> consent.')}</div>`
: '';
const noMatch = (q || _activeRoleFilter) && !visible.length
? `<div style="padding:4px 0;color:var(--muted);font-size:11px">${t('m365_no_users_match','No users match')} "${q || _activeRoleFilter}"</div>`
: '';
list.innerHTML = note + noMatch + visible.map(u => `
<label style="display:flex;align-items:center;gap:7px;padding:2px 0;cursor:pointer">
<input type="checkbox" class="account-check" data-id="${u.id}" data-name="${u.displayName}" data-role="${u.userRole || 'other'}"
${u.selected !== false ? 'checked' : ''}
onchange="onAccountCheckChange('${u.id}', this.checked)">
<span style="flex:1;overflow:hidden">
<span style="display:block;font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${u.displayName}${u.isMe ? ' <span style=\'color:var(--accent);font-size:10px\'>(you)</span>' : ''}${u.manual ? ' <span style=\'color:var(--muted);font-size:10px\'>(manual)</span>' : ''}</span>
<span style="color:var(--muted);font-size:10px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;display:block">${u.email}</span>
</span>
<span style="font-size:9px;padding:1px 5px;border-radius:10px;flex-shrink:0;"
class="${u.platform==='both' ? (hasM365Src && effectiveGws ? 'plat-badge-both' : effectiveGws ? 'plat-badge-google' : 'plat-badge-m365') : (u.platform||'m365')==='google' ? 'plat-badge-google' : 'plat-badge-m365'}">
${u.platform==='both' ? (hasM365Src && effectiveGws ? 'M365 + GWS' : effectiveGws ? 'GWS' : 'M365') : (u.platform||'m365')==='google' ? 'GWS' : 'M365'}
</span>
<button type="button" onclick="cycleUserRole(this.getAttribute('data-uid'))"
data-uid="${u.id.replace(/&/g,'&amp;').replace(/'/g,'&#39;').replace(/"/g,'&quot;')}"
title="${t('m365_role_cycle_tip','Click to change role')}"
class="role-badge" style="font-size:9px;padding:1px 5px;cursor:pointer;flex-shrink:0;white-space:nowrap;border:none;${u.roleOverride ? 'color:var(--color-text-info);outline:1px solid var(--color-border-info)' : ''}">
${u.userRole === 'student' ? t('role_student','Elev') : u.userRole === 'staff' ? t('role_staff','Ansat') : t('role_other','Anden')}${u.roleOverride ? ' ✎' : ''}
</button>
${u.manual ? `<button onclick="removeUser(this.getAttribute('data-uid'))" data-uid="${u.id.replace(/&/g,'&amp;').replace(/'/g,'&#39;').replace(/"/g,'&quot;')}" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:13px;padding:0;flex-shrink:0" title="Remove">×</button>` : ''}
</label>`).join('');
}
function _updateUserCountBadge(visible, total) {
const badge = document.getElementById('userCountBadge');
if (!badge) return;
if (total === 0) { badge.textContent = ''; return; }
badge.textContent = visible < total ? `(${visible} / ${total})` : `(${total})`;
}
// ── SKU debug — surface unknown tenant SKU IDs so they can be added to m365_skus.json ──
async function showSkuDebug() {
let modal = document.getElementById('skuDebugModal');
if (!modal) {
modal = document.createElement('div');
modal.id = 'skuDebugModal';
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,.55);z-index:1000;display:flex;align-items:center;justify-content:center';
modal.onclick = e => { if (e.target === modal) modal.remove(); };
document.body.appendChild(modal);
}
modal.innerHTML = `<div style="background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:22px 26px;width:min(520px,95vw);max-height:80vh;display:flex;flex-direction:column;gap:12px;font-size:12px">
<div style="display:flex;align-items:center;justify-content:space-between">
<strong style="font-size:13px">${t('m365_sku_debug_title','🔍 Tenant SKU IDs')}</strong>
<button onclick="document.getElementById('skuDebugModal').remove()" style="background:none;border:none;color:var(--muted);cursor:pointer;font-size:16px">×</button>
</div>
<div style="color:var(--muted);font-size:11px;line-height:1.5">${t('m365_sku_debug_desc','These are the raw SKU IDs assigned to your users. Any marked <b>❓ unknown</b> are not in <code>classification/m365_skus.json</code> — copy them in under <code>student_ids</code> or <code>staff_ids</code> and restart.')}</div>
<div id="skuDebugList" style="overflow-y:auto;flex:1;font-family:var(--mono);font-size:11px">Loading…</div>
<div style="display:flex;justify-content:flex-end;gap:8px;padding-top:4px;border-top:1px solid var(--border)">
<button onclick="document.getElementById('skuDebugModal').remove()" style="background:none;border:1px solid var(--border);color:var(--muted);padding:4px 14px;border-radius:6px;cursor:pointer">${t('btn_close','Close')}</button>
</div>
</div>`;
const listEl = document.getElementById('skuDebugList');
try {
const r = await fetch('/api/users/license_debug');
const d = await r.json();
if (d.error) { listEl.textContent = 'Error: ' + d.error; return; }
// Collect unique SKUs across all users
const skuSeen = {}; // skuId → {name, role, count, known}
for (const u of (d.users || [])) {
for (let i = 0; i < (u.skuIds || []).length; i++) {
const id = u.skuIds[i];
const nm = (u.skuNames || [])[i] || '';
if (!skuSeen[id]) skuSeen[id] = { name: nm, role: u.role, count: 0 };
skuSeen[id].count++;
}
}
const rows = Object.entries(skuSeen).sort((a,b) => b[1].count - a[1].count);
if (!rows.length) { listEl.textContent = t('m365_sku_debug_none','No license data returned — check that the app has User.Read.All permission.'); return; }
const knownStudent = new Set((d.student_ids || []));
const knownStaff = new Set((d.staff_ids || []));
listEl.innerHTML = rows.map(([id, info]) => {
const known = knownStudent.has(id) ? '🎓 student'
: knownStaff.has(id) ? '👔 staff'
: '❓ unknown';
const color = known.startsWith('❓') ? 'var(--danger)' : 'var(--accent)';
return `<div style="display:flex;align-items:baseline;gap:8px;padding:3px 0;border-bottom:1px solid var(--border)">
<code style="flex:1;color:var(--text);user-select:all">${id}</code>
<span style="color:var(--muted);font-size:10px;white-space:nowrap">${info.name || '—'}</span>
<span style="color:${color};font-size:10px;white-space:nowrap;flex-shrink:0">${known} (${info.count})</span>
</div>`;
}).join('');
} catch(e) {
listEl.textContent = 'Error: ' + e.message;
}
}
function filterUsers() {
const showAdminNote = S._allUsers.filter(u => !u.manual).length <= 1;
renderAccountList(showAdminNote);
}
async function cycleUserRole(id) {
// Cycle: student → staff → other → (clear override, back to auto)
if (!id) { console.warn('cycleUserRole: no id'); return; }
const u = S._allUsers.find(u => u.id === id);
if (!u) { console.warn('cycleUserRole: user not found for id', id); return; }
const cycle = ['student', 'staff', 'other'];
let next;
if (!u.roleOverride) {
// First click: remember auto role, pin to next in cycle
u._autoRole = u.userRole;
u._cycleSteps = 0;
const cur = cycle.indexOf(u.userRole);
next = cycle[(cur + 1) % cycle.length];
} else {
u._cycleSteps = (u._cycleSteps || 0) + 1;
if (u._cycleSteps >= cycle.length) {
next = ''; // full cycle completed — clear override
} else {
const cur = cycle.indexOf(u.userRole);
next = cycle[(cur + 1) % cycle.length];
}
}
try {
const r = await fetch('/api/users/role_override', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({user_id: id, role: next})
});
const d = await r.json();
if (d.error) { log('Role override failed: ' + d.error, 'err'); return; }
// Update local state
if (next) {
if (!u.roleOverride) u._autoRole = u.userRole; // remember original for clear
u.userRole = next;
u.roleOverride = true;
} else {
u.userRole = u._autoRole || u.userRole;
u.roleOverride = false;
u._autoRole = undefined;
}
// Update the role filter count badges and re-render
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
log((next ? t('m365_role_set', 'Role set') + ': ' + next : t('m365_role_cleared', 'Role override cleared')) + ' — ' + (u.displayName || id));
} catch(e) {
log('Role override error: ' + e.message, 'err');
}
}
function removeUser(id) {
S._allUsers = S._allUsers.filter(u => u.id !== id);
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
}
async function addUserManually() {
const input = document.getElementById('addUserInput');
const upn = input.value.trim();
if (!upn) return;
// Look up the user via server
const btn = input.nextElementSibling;
btn.disabled = true; btn.textContent = '…';
try {
const r = await fetch('/api/users/lookup?upn=' + encodeURIComponent(upn));
const d = await r.json();
if (d.error) { alert('User not found: ' + d.error); return; }
if (S._allUsers.find(u => u.id === d.id)) { alert('User already in list.'); return; }
S._allUsers.push({...d, manual: true});
input.value = '';
renderAccountList(S._allUsers.filter(u => !u.manual).length <= 1);
} catch(e) {
alert('Lookup failed: ' + e.message);
} finally {
btn.disabled = false; btn.textContent = '+';
}
}
function onAccountCheckChange(id, checked) {
const user = S._allUsers.find(u => u.id === id);
if (user) user.selected = checked;
}
function selectAllAccounts(checked) {
// Toggle all visible users (respects search + role filter)
const visible = new Set(
Array.from(document.querySelectorAll('#accountsList .account-check')).map(cb => cb.dataset.id)
);
S._allUsers.forEach(u => { if (visible.has(u.id)) u.selected = checked; });
document.querySelectorAll('#accountsList .account-check').forEach(cb => cb.checked = checked);
}
function getSelectedUsers() {
// Only return M365 users — Google users are handled separately via selectedGoogleEmails
let selected = S._allUsers.filter(u => u.selected !== false && (u.platform === 'm365' || u.platform === 'both'));
// Respect the active role filter — hidden users must not sneak into the scan
// even if they were checked before the filter was applied.
if (_activeRoleFilter) {
selected = selected.filter(u => (u.userRole || 'other') === _activeRoleFilter);
}
if (selected.length) {
return selected.map(u => ({
id: u.id, displayName: u.displayName, userRole: u.userRole || 'other'
}));
}
// Fallback to DOM if S._allUsers not yet populated
return Array.from(document.querySelectorAll('.account-check:checked')).map(cb => ({
id: cb.dataset.id, displayName: cb.dataset.name, userRole: cb.dataset.role || 'other'
}));
}
// ── Window exports (HTML handlers + cross-module calls) ─────────────────────
window.loadUsers = loadUsers;
window._mergeGoogleUsers = _mergeGoogleUsers;
window.toggleSection = toggleSection;
window.restoreSectionStates = restoreSectionStates;
window.updateRoleFilterCounts = updateRoleFilterCounts;
window.setRoleFilter = setRoleFilter;
window.loadLastScanSummary = loadLastScanSummary;
window.renderAccountList = renderAccountList;
window._updateUserCountBadge = _updateUserCountBadge;
window.showSkuDebug = showSkuDebug;
window.filterUsers = filterUsers;
window.cycleUserRole = cycleUserRole;
window.removeUser = removeUser;
window.addUserManually = addUserManually;
window.onAccountCheckChange = onAccountCheckChange;
window.selectAllAccounts = selectAllAccounts;
window.getSelectedUsers = getSelectedUsers;
window._activeRoleFilter = _activeRoleFilter;
window._COLLAPSE_SECTIONS = _COLLAPSE_SECTIONS;
+225
View File
@@ -0,0 +1,225 @@
// ── Viewer token management (#33) ─────────────────────────────────────────────
// Share button → modal to create, copy, and revoke read-only viewer links.
function openShareModal() {
document.getElementById('shareBackdrop').classList.add('open');
document.getElementById('shareNewLinkRow').style.display = 'none';
document.getElementById('shareLabel').value = '';
document.getElementById('shareExpiry').value = '30';
_renderTokenList();
fetch('/api/viewer/pin').then(function(r){ return r.json(); }).then(function(d) {
const el = document.getElementById('sharePinStatus');
if (el) el.textContent = d.pin_set ? t('share_pin_set', 'Set') : t('share_pin_not_set', 'Not set');
}).catch(function(){});
}
function closeShareModal() {
document.getElementById('shareBackdrop').classList.remove('open');
}
async function _renderTokenList() {
const list = document.getElementById('shareTokenList');
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('lbl_loading', 'Loading…') + '</div>';
try {
const r = await fetch('/api/viewer/tokens');
const tokens = await r.json();
if (!tokens.length) {
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('share_no_links', 'No active links.') + '</div>';
return;
}
list.innerHTML = '';
tokens.forEach(tok => {
const expires = tok.expires_at
? new Date(tok.expires_at * 1000).toLocaleDateString(undefined, {day:'numeric', month:'short', year:'numeric'})
: t('share_expires_never', 'Never');
const lastUsed = tok.last_used_at
? new Date(tok.last_used_at * 1000).toLocaleDateString(undefined, {day:'numeric', month:'short'})
: '—';
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';
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>') +
'</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 +
'</div>' +
'</div>' +
'<button title="' + t('share_copy_link_prompt', 'Copy link:') + '" onclick="copyTokenLink(\'' + tok.token + '\',this)" ' +
'style="height:24px;padding:0 8px;background:none;border:1px solid var(--border);color:var(--muted);border-radius:4px;font-size:11px;cursor:pointer;flex-shrink:0">' + t('log_copy', 'Copy') + '</button>' +
'<button title="' + t('share_revoke', 'Revoke') + '" onclick="revokeToken(\'' + tok.token + '\',this.closest(\'div[style]\'))" ' +
'style="height:24px;padding:0 8px;background:none;border:1px solid var(--danger);color:var(--danger);border-radius:4px;font-size:11px;cursor:pointer;flex-shrink:0">' + t('share_revoke', 'Revoke') + '</button>';
list.appendChild(row);
});
} catch(e) {
list.innerHTML = '<div style="font-size:12px;color:var(--danger);padding:4px 0">' + t('share_load_error', 'Failed to load links.') + '</div>';
}
}
async function createShareLink() {
const label = document.getElementById('shareLabel').value.trim();
const expiry = document.getElementById('shareExpiry').value;
const body = {label};
if (expiry) body.expires_days = parseInt(expiry);
try {
const r = await fetch('/api/viewer/tokens', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify(body),
});
if (!r.ok) throw new Error('Server error ' + r.status);
const entry = await r.json();
const url = window.location.origin + '/view?token=' + encodeURIComponent(entry.token);
const urlInput = document.getElementById('shareNewLinkUrl');
urlInput.value = url;
document.getElementById('shareNewLinkRow').style.display = 'block';
document.getElementById('shareCopyBtn').textContent = t('log_copy', 'Copy');
document.getElementById('shareLabel').value = '';
_renderTokenList();
} catch(e) {
alert(t('share_create_error', 'Failed to create link:') + ' ' + e.message);
}
}
function copyShareLink() {
const url = document.getElementById('shareNewLinkUrl').value;
_copyText(url, document.getElementById('shareCopyBtn'));
}
function copyTokenLink(token, btn) {
const url = window.location.origin + '/view?token=' + encodeURIComponent(token);
_copyText(url, btn);
}
function _copyText(text, btn) {
navigator.clipboard.writeText(text).then(() => {
const orig = btn.textContent;
btn.textContent = t('share_copied', 'Copied!');
setTimeout(() => { btn.textContent = orig; }, 1800);
}).catch(() => {
// Fallback for HTTP contexts
try {
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
const orig = btn.textContent;
btn.textContent = t('share_copied', 'Copied!');
setTimeout(() => { btn.textContent = orig; }, 1800);
} catch(_) {}
});
}
async function revokeToken(token, rowEl) {
if (!confirm(t('share_revoke_confirm', 'Revoke this link? Anyone using it will immediately lose access.'))) return;
try {
const r = await fetch('/api/viewer/tokens/' + encodeURIComponent(token), {method: 'DELETE'});
if (!r.ok) throw new Error('Server error ' + r.status);
rowEl.remove();
const list = document.getElementById('shareTokenList');
if (!list.children.length) {
list.innerHTML = '<div style="font-size:12px;color:var(--muted);padding:4px 0">' + t('share_no_links', 'No active links.') + '</div>';
}
// Hide the copy row if the just-revoked token was the last created
const newRow = document.getElementById('shareNewLinkRow');
if (newRow) {
const shownUrl = document.getElementById('shareNewLinkUrl')?.value || '';
if (shownUrl.includes(token)) newRow.style.display = 'none';
}
} catch(e) {
alert(t('share_revoke_error', 'Failed to revoke:') + ' ' + e.message);
}
}
// ── Viewer PIN — Settings UI ──────────────────────────────────────────────────
async function stLoadViewerPinStatus() {
try {
const r = await fetch('/api/viewer/pin');
const d = await r.json();
const statusEl = document.getElementById('stViewerPinStatus');
const currentRow = document.getElementById('stViewerCurrentPinRow');
const clearBtn = document.getElementById('stViewerPinClearBtn');
if (d.pin_set) {
if (statusEl) statusEl.textContent = '\u2714 ' + t('viewer_pin_is_set', 'Viewer PIN is set');
if (currentRow) currentRow.style.display = '';
if (clearBtn) clearBtn.style.display = '';
} else {
if (statusEl) statusEl.textContent = t('viewer_pin_not_set_msg', 'No PIN set \u2014 /view requires a token link');
if (currentRow) currentRow.style.display = 'none';
if (clearBtn) clearBtn.style.display = 'none';
}
} catch(e) {}
}
async function stSaveViewerPin() {
const newPin = (document.getElementById('stViewerNewPin')?.value || '').trim();
const currentPin = (document.getElementById('stViewerCurrentPin')?.value || '').trim();
const st = document.getElementById('stViewerPinSaveStatus');
if (!newPin) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_settings_pin_required', 'PIN is required.'); }
return;
}
if (!/^\d{4,8}$/.test(newPin)) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('viewer_pin_format', 'PIN must be 4\u20138 digits.'); }
return;
}
if (st) { st.style.color = 'var(--muted)'; st.textContent = t('viewer_pin_saving', 'Saving\u2026'); }
try {
const r = await fetch('/api/viewer/pin', {
method: 'POST', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({pin: newPin, current_pin: currentPin}),
});
const d = await r.json();
if (!r.ok) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = d.error || 'Error.'; }
return;
}
if (st) { st.style.color = 'var(--accent)'; st.textContent = '\u2714 ' + t('viewer_pin_saved', 'PIN saved'); }
if (document.getElementById('stViewerNewPin')) document.getElementById('stViewerNewPin').value = '';
if (document.getElementById('stViewerCurrentPin')) document.getElementById('stViewerCurrentPin').value = '';
stLoadViewerPinStatus();
} catch(e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
}
}
async function stClearViewerPin() {
const currentPin = (document.getElementById('stViewerCurrentPin')?.value || '').trim();
const st = document.getElementById('stViewerPinSaveStatus');
if (!currentPin) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = t('m365_settings_pin_required', 'PIN is required.'); }
document.getElementById('stViewerCurrentPin')?.focus();
return;
}
if (!confirm(t('viewer_pin_clear_confirm', 'Remove the viewer PIN? /view will require a token link again.'))) return;
try {
const r = await fetch('/api/viewer/pin', {
method: 'DELETE', headers: {'Content-Type': 'application/json'},
body: JSON.stringify({current_pin: currentPin}),
});
const d = await r.json();
if (!r.ok) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = d.error || 'Error.'; }
return;
}
if (st) { st.style.color = 'var(--muted)'; st.textContent = t('viewer_pin_cleared', 'PIN cleared'); }
stLoadViewerPinStatus();
} catch(e) {
if (st) { st.style.color = 'var(--danger)'; st.textContent = e.message; }
}
}
// ── Window exports ────────────────────────────────────────────────────────────
window.openShareModal = openShareModal;
window.closeShareModal = closeShareModal;
window.createShareLink = createShareLink;
window.copyShareLink = copyShareLink;
window.copyTokenLink = copyTokenLink;
window.revokeToken = revokeToken;
window.stLoadViewerPinStatus = stLoadViewerPinStatus;
window.stSaveViewerPin = stSaveViewerPin;
window.stClearViewerPin = stClearViewerPin;