Initial commit
This commit is contained in:
commit
9c7df76fbd
91 files changed
+36572
No files matched your search
@@ -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`.
|
||||
@@ -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;
|
||||
@@ -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+'\')">▶ '+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 + '\')">▶ ' + 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;
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>');
|
||||
}
|
||||
|
||||
// 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;
|
||||
@@ -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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -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,'"')},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 + ' · ' + 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;
|
||||
@@ -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;
|
||||
@@ -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">▶</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">✎</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">✕</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;
|
||||
@@ -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;
|
||||
@@ -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
@@ -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;
|
||||
@@ -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 + ' · ' + 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,'&').replace(/'/g,''').replace(/"/g,'"')}"
|
||||
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,'&').replace(/'/g,''').replace(/"/g,'"')}" 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;
|
||||
@@ -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 + ' · ' + 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;
|
||||
@@ -0,0 +1,616 @@
|
||||
:root {
|
||||
--pmgmt-divider: #484850;
|
||||
--bg: #0f0f11;
|
||||
--surface: #18181c;
|
||||
--border: #2a2a30;
|
||||
--accent: #0078d4;
|
||||
--accent2: #f5a623;
|
||||
--text: #e8e6e1;
|
||||
--muted: #6b6970;
|
||||
--success: #2ecc71;
|
||||
--danger: #e74c3c;
|
||||
--mono: 'IBM Plex Mono', monospace;
|
||||
--sans: 'IBM Plex Sans', sans-serif;
|
||||
}
|
||||
[data-theme="light"] {
|
||||
--pmgmt-divider: #b0b0bc;
|
||||
--bg: #f5f5f7;
|
||||
--surface: #ffffff;
|
||||
--border: #d8d8df;
|
||||
--accent: #0060b0;
|
||||
--text: #1a1a1f;
|
||||
--muted: #888891;
|
||||
--success: #1a9952;
|
||||
--danger: #c0392b;
|
||||
}
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; transition: background-color .15s, border-color .15s, color .1s; }
|
||||
html, body { height: 100%; margin: 0; background: var(--bg); color: var(--text); font-family: var(--sans); box-sizing: border-box; }
|
||||
input, select, textarea { background: var(--surface); color: var(--text); border: 1px solid var(--border); border-radius: 6px; padding: 7px 10px; font-family: var(--sans); font-size: 13px; }
|
||||
input:focus, select:focus { outline: none; border-color: var(--accent); }
|
||||
button { cursor: pointer; font-family: var(--sans); font-size: 13px; }
|
||||
|
||||
/* Layout */
|
||||
.layout { display: flex; height: 100%; overflow: hidden; }
|
||||
#sourcesPanel::-webkit-scrollbar,
|
||||
#accountsList::-webkit-scrollbar { width: 4px; }
|
||||
#sourcesPanel::-webkit-scrollbar-track,
|
||||
#accountsList::-webkit-scrollbar-track { background: transparent; }
|
||||
#sourcesPanel::-webkit-scrollbar-thumb,
|
||||
#accountsList::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
#sourcesPanel { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
#accountsList { scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
.sidebar { width: 260px; min-width: 260px; display: flex; flex-direction: column;
|
||||
background: var(--surface); border-right: 1px solid var(--border); overflow: hidden; }
|
||||
.sidebar-header { padding: 10px 12px 8px; border-bottom: 1px solid var(--border); }
|
||||
.sidebar-title { font-size: 15px; font-weight: 600; }
|
||||
.sidebar-section { padding: 7px 12px; border-bottom: 1px solid var(--border); }
|
||||
.section-label { font-size: 10px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin-bottom: 4px; }
|
||||
.section-collapse-btn { background: none; border: none; color: var(--muted); cursor: pointer; font-size: 10px; padding: 0; line-height: 1; transition: transform .15s; }
|
||||
.section-collapsed > .section-collapse-btn { transform: rotate(-90deg); }
|
||||
.last-scan-summary { display: flex; flex-direction: column; align-items: center; gap: 14px; padding: 24px 16px; }
|
||||
.last-scan-card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 16px 24px; min-width: 280px; text-align: center; }
|
||||
.last-scan-card h3 { font-size: 12px; font-weight: 600; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; margin: 0 0 12px; }
|
||||
.last-scan-stats { display: flex; gap: 24px; justify-content: center; }
|
||||
.last-scan-stat { display: flex; flex-direction: column; align-items: center; gap: 2px; }
|
||||
.last-scan-stat .val { font-size: 22px; font-weight: 700; color: var(--text); }
|
||||
.last-scan-stat .lbl { font-size: 10px; color: var(--muted); }
|
||||
input[type="text"], input[type="number"], input[type="date"] {
|
||||
width: 100%; background: var(--bg); border: 1px solid var(--border); border-radius: 6px;
|
||||
color: var(--text); font-family: var(--mono); font-size: 12px; padding: 0 10px;
|
||||
height: 26px; box-sizing: border-box; outline: none;
|
||||
}
|
||||
[data-theme="dark"] input[type="date"] { color-scheme: dark; }
|
||||
[data-theme="light"] input[type="date"] { color-scheme: light; }
|
||||
input[type="text"]:focus, input[type="number"]:focus, input[type="date"]:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.datepicker-wrap { margin-bottom: 4px; }
|
||||
.date-presets { display: flex; margin-top: 6px; background: var(--bg); border: 1px solid var(--border); border-radius: 6px; overflow: hidden; }
|
||||
.date-preset {
|
||||
flex: 1; min-width: 0; background: none; border: none; border-right: 1px solid var(--border);
|
||||
color: var(--muted); font-family: var(--mono); font-size: 10px; padding: 0 2px; height: 26px;
|
||||
cursor: pointer; transition: background .15s, color .15s; letter-spacing: 0.05em;
|
||||
}
|
||||
.date-preset:last-child { border-right: none; }
|
||||
.date-preset:hover { background: var(--surface); color: var(--text); }
|
||||
.date-preset.selected { background: var(--accent); color: #fff; }
|
||||
.role-filter-btn { flex: 1; font-size: 10px; height: 26px; padding: 0 4px; cursor: pointer; border: none; background: none; color: var(--muted); }
|
||||
.role-filter-btn:hover { background: var(--surface); color: var(--text); }
|
||||
.role-filter-btn.rf-sep { border-right: 1px solid var(--border); }
|
||||
.toggle-row { display: flex; align-items: center; justify-content: space-between; margin-bottom: 5px; }
|
||||
.toggle-label { font-size: 12px; color: var(--text); }
|
||||
/* Hint icon + speech bubble tooltip */
|
||||
.hint-wrap { position:relative; display:inline-flex; align-items:center; margin-left:5px; }
|
||||
.hint-icon {
|
||||
display:inline-flex; align-items:center; justify-content:center;
|
||||
width:14px; height:14px; border-radius:50%;
|
||||
border:1px solid var(--muted); color:var(--muted);
|
||||
font-size:9px; font-weight:700; cursor:pointer;
|
||||
flex-shrink:0; user-select:none; line-height:1;
|
||||
transition:border-color .15s, color .15s;
|
||||
}
|
||||
.hint-icon:hover, .hint-icon.active { border-color:var(--accent); color:var(--accent); }
|
||||
.hint-bubble {
|
||||
display:none; position:fixed;
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:8px; padding:7px 10px;
|
||||
font-size:10px; color:var(--muted); line-height:1.5;
|
||||
width:200px; z-index:9999; box-shadow:0 4px 16px rgba(0,0,0,.35);
|
||||
pointer-events:none;
|
||||
}
|
||||
.hint-bubble::before {
|
||||
content:''; position:absolute; right:100%; top:50%; transform:translateY(-50%);
|
||||
border:5px solid transparent; border-right-color:var(--border);
|
||||
}
|
||||
.hint-bubble::after {
|
||||
content:''; position:absolute; right:calc(100% - 1px); top:50%; transform:translateY(-50%);
|
||||
border:5px solid transparent; border-right-color:var(--surface);
|
||||
}
|
||||
/* bubble display controlled by toggleHint() JS */
|
||||
.toggle { position: relative; width: 32px; height: 18px; flex-shrink: 0; }
|
||||
.toggle input { opacity: 0; width: 0; height: 0; }
|
||||
.toggle-slider {
|
||||
position: absolute; inset: 0; background: var(--border); border-radius: 18px;
|
||||
cursor: pointer; transition: 0.2s;
|
||||
}
|
||||
.toggle-slider::before {
|
||||
content: ''; position: absolute; width: 14px; height: 14px; left: 2px; top: 2px;
|
||||
background: var(--muted); border-radius: 50%; transition: 0.2s;
|
||||
}
|
||||
.toggle input:checked + .toggle-slider { background: var(--accent); }
|
||||
.toggle input:checked + .toggle-slider::before { transform: translateX(14px); background: #fff; }
|
||||
.main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
|
||||
/* Auth panel */
|
||||
.auth-panel { flex: 1; display: flex; align-items: center; justify-content: center; padding: 40px; }
|
||||
.auth-card { background: var(--surface); border: 1px solid var(--border); border-radius: 12px; padding: 32px; width: 100%; max-width: 480px; }
|
||||
.auth-title { font-size: 20px; font-weight: 600; margin-bottom: 6px; }
|
||||
.auth-sub { font-size: 13px; color: var(--muted); margin-bottom: 24px; line-height: 1.5; }
|
||||
.form-row { margin-bottom: 14px; }
|
||||
.form-label { font-size: 12px; color: var(--muted); margin-bottom: 4px; display: block; }
|
||||
.form-row input { width: 100%; }
|
||||
.btn-primary { background: var(--accent); color: #fff; border: none; padding: 9px 20px; border-radius: 7px; font-weight: 500; }
|
||||
.btn-primary:hover { filter: brightness(1.1); }
|
||||
|
||||
/* Device code flow */
|
||||
.device-code-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 20px; margin: 16px 0; text-align: center; }
|
||||
.device-code { font-family: var(--mono); font-size: 28px; font-weight: 600; letter-spacing: .15em; color: var(--accent); margin: 10px 0; }
|
||||
.device-url { font-size: 13px; color: var(--muted); }
|
||||
.device-url a { color: var(--accent); }
|
||||
.auth-status { font-size: 13px; margin-top: 12px; padding: 8px 12px; border-radius: 6px; }
|
||||
.auth-status.waiting { background: rgba(0,120,212,.1); color: var(--accent); }
|
||||
.auth-status.success { background: rgba(46,204,113,.1); color: var(--success); }
|
||||
.auth-status.error { background: rgba(231,76,60,.1); color: var(--danger); }
|
||||
|
||||
/* Source selector */
|
||||
.source-check { display: flex; align-items: center; gap: 6px; padding: 3px 0; cursor: pointer; }
|
||||
.source-check input[type=checkbox] { width: 15px; height: 15px; accent-color: var(--accent); cursor: pointer; }
|
||||
.account-check { width: 14px; height: 14px; accent-color: var(--accent); cursor: pointer; flex-shrink: 0; margin: 0; }
|
||||
.source-icon { font-size: 13px; }
|
||||
.source-label { font-size: 12px; }
|
||||
|
||||
/* Topbar */
|
||||
.topbar { display: flex; align-items: center; gap: 10px; padding: 10px 16px;
|
||||
position: sticky; top: 0; z-index: 20;
|
||||
border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; }
|
||||
.scan-btn { background: var(--accent); color: #fff; border: none; height: 26px; padding: 0 16px; border-radius: 7px; font-weight: 500; font-size: 13px; cursor: pointer; }
|
||||
.scan-btn:hover:not(:disabled) { filter: brightness(1.1); }
|
||||
.scan-btn:disabled { opacity: .5; cursor: default; }
|
||||
.stop-btn { background: transparent; color: var(--danger); border: 1px solid var(--danger); height: 26px; padding: 0 12px; border-radius: 7px; font-size: 13px; cursor: pointer; }
|
||||
.stats-pill { background: var(--bg); border: 1px solid var(--border); border-radius: 20px; height: 26px; padding: 0 12px; font-size: 12px; color: var(--muted); display: flex; align-items: center; }
|
||||
.stats-pill span { color: var(--text); font-weight: 600; }
|
||||
.spacer { flex: 1; }
|
||||
.theme-btn { background: none; border: 1px solid var(--border); color: var(--muted); height: 26px; padding: 0 10px; border-radius: 7px; font-size: 14px; cursor: pointer; }
|
||||
.theme-btn:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.topbar-sep { width: 1px; height: 20px; background: var(--border); flex-shrink: 0; margin: 0 2px; }
|
||||
.config-group { display: flex; align-items: center; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; overflow: hidden; }
|
||||
.config-group button { background: none; border: none; border-right: 1px solid var(--border); color: var(--muted); padding: 0 11px; height: 26px; font-size: 11px; cursor: pointer; white-space: nowrap; }
|
||||
.config-group button:last-child { border-right: none; }
|
||||
.config-group button:hover { background: var(--surface); color: var(--text); }
|
||||
|
||||
/* Progress bar */
|
||||
.progress-bar { display: flex; align-items: center; gap: 10px; padding: 0 16px;
|
||||
height: 32px; min-height: 32px; max-height: 32px;
|
||||
background: var(--bg); border-top: 1px solid var(--border); font-size: 12px; color: var(--muted); flex-shrink: 0;
|
||||
line-height: 1; overflow: hidden; }
|
||||
.progress-who { display:flex; align-items:center; gap:5px; flex-shrink:0; min-width:0; max-width:45%; overflow:hidden; }
|
||||
.progress-src-pill { font-size:9px; font-weight:500; padding:1px 5px; border-radius:10px; flex-shrink:0; white-space:nowrap; }
|
||||
.progress-src-m365 { background:#E6F1FB; color:#185FA5; }
|
||||
.progress-src-google { background:#EAF3DE; color:#3B6D11; }
|
||||
.progress-src-file { background:#EDE8F5; color:#5a4080; }
|
||||
.progress-user { font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
|
||||
.progress-file { flex:1; font-size:11px; color:var(--muted); white-space:nowrap; overflow:hidden; text-overflow:ellipsis; opacity:0.7; text-align:right; }
|
||||
.progress-track { width: 180px; height: 6px; background: var(--border); border-radius: 3px; flex-shrink: 0; display: flex; overflow: hidden; }
|
||||
.progress-seg { height: 100%; flex: 1; background: var(--border); position: relative; }
|
||||
.progress-seg + .progress-seg { border-left: 1px solid var(--bg); }
|
||||
.progress-seg-fill { height: 100%; width: 0; transition: width .3s ease; }
|
||||
.progress-phase { font-size: 11px; color: var(--accent); flex-shrink:0; }
|
||||
|
||||
/* Filter bar */
|
||||
.filter-bar { display: flex; align-items: center; gap: 8px; padding: 6px 16px;
|
||||
border-bottom: 1px solid var(--border); background: var(--surface); flex-shrink: 0; }
|
||||
.filter-bar input, .filter-bar select { height: 26px; box-sizing: border-box; padding: 0 8px; font-size: 12px; }
|
||||
.filter-bar input { width: 180px; }
|
||||
.filter-bar select { width: 130px; }
|
||||
.filter-bar button { height: 26px; padding: 0 10px; border-radius: 5px; font-size: 12px; cursor: pointer; box-sizing: border-box; }
|
||||
.filter-clear { background: none; border: 1px solid var(--border); color: var(--muted); font-size: 12px; height: 26px; padding: 0 10px; border-radius: 5px; cursor: pointer; box-sizing: border-box; }
|
||||
.filter-clear:hover { border-color: var(--danger); color: var(--danger); }
|
||||
|
||||
/* Grid */
|
||||
.grid-area { flex: 1; overflow-y: auto; padding: 24px; min-width: 0; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
.grid-area::-webkit-scrollbar { width: 4px; }
|
||||
.grid-area::-webkit-scrollbar-track { background: transparent; }
|
||||
.grid-area::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px,1fr)); gap: 14px; }
|
||||
/* Preview panel */
|
||||
.content-area { flex: 1; display: flex; overflow: hidden; min-height: 0; }
|
||||
.preview-panel {
|
||||
width: 420px; flex-shrink: 0;
|
||||
display: flex; flex-direction: row;
|
||||
background: var(--surface);
|
||||
overflow: hidden;
|
||||
transition: none;
|
||||
}
|
||||
.preview-panel.hidden { width: 0; }
|
||||
.preview-resize-handle {
|
||||
width: 8px; flex-shrink: 0; cursor: col-resize; position: relative;
|
||||
background: transparent; border-left: 1px solid var(--border);
|
||||
}
|
||||
.preview-resize-handle::after {
|
||||
content: ''; position: absolute; inset: 0 -4px; /* extend hit area 4px each side */
|
||||
}
|
||||
.preview-resize-handle:hover { background: var(--accent); opacity: 0.35; }
|
||||
.preview-inner { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
|
||||
.preview-header {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 10px 14px; border-bottom: 1px solid var(--border); flex-shrink: 0;
|
||||
}
|
||||
.preview-title { font-size: 12px; font-weight: 600; color: var(--text); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; margin-right: 8px; }
|
||||
.preview-close { background: none; border: none; color: var(--muted); font-size: 18px; cursor: pointer; padding: 0; line-height: 1; }
|
||||
.preview-close:hover { color: var(--text); }
|
||||
.preview-body { flex: 1; overflow: hidden; position: relative; }
|
||||
.preview-body iframe { width: 100%; height: 100%; border: none; display: block; overflow-x: hidden; }
|
||||
.preview-loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: var(--muted); font-size: 12px; }
|
||||
.preview-meta { padding: 10px 14px; border-top: 1px solid var(--border); font-size: 11px; color: var(--muted); display: flex; gap: 10px; flex-wrap: wrap; flex-shrink: 0; }
|
||||
.preview-open-btn { margin-left: auto; background: var(--accent); color: #fff; border: none; border-radius: 5px; padding: 4px 10px; font-size: 11px; cursor: pointer; white-space: nowrap; }
|
||||
.card.selected { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.card { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; cursor: pointer; transition: border-color .15s, box-shadow .15s; }
|
||||
.card:hover { border-color: var(--accent); box-shadow: 0 0 0 1px var(--accent); }
|
||||
.card.list-view { display: flex; align-items: center; gap: 12px; padding: 10px 14px; border-radius: 8px; }
|
||||
.thumb-wrap { aspect-ratio: 7/9; overflow: hidden; background: var(--bg); }
|
||||
.thumb-wrap img { width: 100%; height: 100%; object-fit: cover; }
|
||||
.card-info { padding: 10px 12px; }
|
||||
.card-info.list-info { flex: 1; padding: 0; }
|
||||
.card-name { font-size: 12px; font-weight: 500; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-bottom: 3px; }
|
||||
.card-meta { font-size: 11px; color: var(--muted); }
|
||||
.plat-badge-m365 { background:#E6F1FB; color:#185FA5; font-weight:500; }
|
||||
.plat-badge-google { background:#EAF3DE; color:#3B6D11; font-weight:500; }
|
||||
.plat-badge-both { background:linear-gradient(90deg,#E6F1FB 50%,#EAF3DE 50%); color:#1a4a1a; font-weight:500; border:0.5px solid #b5d4b5; }
|
||||
.role-badge { font-size:9px; padding:1px 6px; border-radius:10px; background:#D3D1C7; color:#444441; font-weight:500; margin-right:3px; }
|
||||
.card-source { font-size: 10px; color: var(--muted); margin-top: 2px; display: flex; align-items: center; gap: 4px; flex-wrap: wrap; }
|
||||
.source-badge { font-size: 9px; padding: 1px 5px; border-radius: 10px; font-weight: 500; white-space: nowrap; }
|
||||
.cpr-badge { background: var(--danger); color: #fff; font-size: 9px; font-weight: 600; padding: 1px 5px; border-radius: 10px; display: inline-block; }
|
||||
.card-delete-btn { position:absolute; top:6px; right:6px; background:rgba(0,0,0,0.45); color:#fff; border:none; border-radius:50%; width:22px; height:22px; font-size:13px; line-height:22px; text-align:center; cursor:pointer; opacity:0.35; transition:opacity .15s; padding:0; z-index:1; }
|
||||
.card:hover .card-delete-btn { opacity:1; }
|
||||
.card.list-view .card-delete-btn { position:static; opacity:1; background:transparent; color:var(--muted); flex-shrink:0; }
|
||||
.bulk-delete-modal { max-width:460px; }
|
||||
.bulk-criteria-row { display:flex; align-items:center; gap:8px; margin-bottom:8px; font-size:12px; }
|
||||
.bulk-criteria-row label { flex:0 0 130px; color:var(--muted); }
|
||||
.bulk-criteria-row select, .bulk-criteria-row input { flex:1; font-size:12px; padding:4px 7px; background:var(--bg2); border:1px solid var(--border); border-radius:5px; color:var(--text); }
|
||||
.delete-progress { font-size:12px; color:var(--muted); margin-top:10px; min-height:18px; }
|
||||
.btn-danger { background:var(--danger); color:#fff; border:none; padding:7px 16px; border-radius:6px; font-size:12px; cursor:pointer; font-weight:600; }
|
||||
.btn-danger:disabled { opacity:.5; cursor:not-allowed; }
|
||||
|
||||
/* Profile management modal (#15d) */
|
||||
.pmgmt-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.pmgmt-backdrop.open { display: flex; }
|
||||
.pmgmt-modal {
|
||||
display: flex; gap: 0; width: min(820px,96vw); max-height: 80vh; overflow: hidden;
|
||||
background: var(--surface); border-radius: 10px;
|
||||
border: 1px solid var(--border); box-shadow: 0 8px 32px rgba(0,0,0,.4); font-size: 12px; color: var(--text);
|
||||
|
||||
}
|
||||
.pmgmt-panel-list { width: 260px; min-width: 260px; display: flex; flex-direction: column; border-right: 1px solid var(--pmgmt-divider); }
|
||||
.pmgmt-panel-editor { flex: 1; display: none; flex-direction: column; overflow: hidden; }
|
||||
.pmgmt-panel-editor.open { display: flex; }
|
||||
.pmgmt-list { overflow-y: auto; flex: 1; display: flex; flex-direction: column; }
|
||||
.pmgmt-row { cursor: pointer; padding: 8px 12px; border-bottom: 1px solid var(--pmgmt-divider); }
|
||||
.pmgmt-row:hover { background: var(--bg); }
|
||||
.pmgmt-row.active { background: rgba(99,126,210,.15); }
|
||||
.pmgmt-row.active .pmgmt-name { color: var(--accent); }
|
||||
.pmgmt-row.active .pmgmt-sources, .pmgmt-row.active .pmgmt-meta { opacity: .7; }
|
||||
.pmgmt-row-head { display: flex; align-items: center; gap: 8px; }
|
||||
.pmgmt-name { font-weight: 500; flex: 1; font-size: 13px; color: var(--text); }
|
||||
.pmgmt-meta { font-size: 10px; color: var(--muted); margin-top: 1px; }
|
||||
.pmgmt-desc { font-size: 11px; color: var(--muted); margin-top: 2px; font-style: italic; }
|
||||
.pmgmt-sources { font-size: 11px; color: var(--muted); margin-top: 1px; }
|
||||
.pmgmt-actions { display: flex; gap: 5px; flex-shrink: 0; }
|
||||
.pmgmt-actions button { border: 1px solid var(--border); background: none; color: var(--muted); border-radius: 5px; height: 26px; padding: 0 8px; font-size: 11px; cursor: pointer; box-sizing: border-box; }
|
||||
.pmgmt-actions button:hover { color: var(--text); border-color: var(--text); }
|
||||
.pmgmt-actions button.btn-use { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.pmgmt-actions button.btn-del { color: var(--danger); border-color: var(--danger); }
|
||||
.pmgmt-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 24px 12px; }
|
||||
.pmgmt-editor-body { flex: 1; overflow: hidden; padding: 14px 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.pmgmt-editor-section-title { font-size: 10px; font-weight: 600; color: var(--text); text-transform: uppercase; letter-spacing: .07em; margin-bottom: 8px; padding-bottom: 5px; border-bottom: 1px solid var(--pmgmt-divider); display: flex; align-items: center; justify-content: space-between; opacity: .9; }
|
||||
.pmgmt-account-list { display: flex; flex-direction: column; gap: 3px; max-height: 160px; overflow-y: auto; margin-top: 4px; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
.pmgmt-account-list::-webkit-scrollbar { width: 4px; }
|
||||
.pmgmt-account-list::-webkit-scrollbar-track { background: transparent; }
|
||||
.pmgmt-account-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
#peSourcesPanel { max-height: 130px; overflow-y: auto; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
#peSourcesPanel::-webkit-scrollbar { width: 4px; }
|
||||
#peSourcesPanel::-webkit-scrollbar-track { background: transparent; }
|
||||
#peSourcesPanel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
.pmgmt-acct-row { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; padding: 2px 0; }
|
||||
.pmgmt-opt-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--text); }
|
||||
.pmgmt-settings-col { flex: 1; padding-left: 16px; border-left: 1px solid var(--pmgmt-divider); }
|
||||
.pmgmt-opt-row input[type=number] { width: 60px; font-size: 12px; height: 26px; box-sizing: border-box; }
|
||||
.pmgmt-acct-row span:first-of-type { flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.pmgmt-panel-list { width: 260px; min-width: 260px; display: flex; flex-direction: column; border-right: 0.5px solid var(--color-border-tertiary); }
|
||||
.pmgmt-panel-editor { flex: 1; display: none; flex-direction: column; }
|
||||
.pmgmt-panel-editor.open { display: flex; }
|
||||
.pmgmt-row { cursor: pointer; padding: 8px 12px; border-radius: 0; border-bottom: 0.5px solid var(--color-border-tertiary); }
|
||||
.pmgmt-row:hover { background: var(--color-background-secondary); }
|
||||
.pmgmt-row.active { background: var(--color-background-info); }
|
||||
.pmgmt-row.active .pmgmt-name { color: var(--color-text-info); }
|
||||
.pmgmt-row.active .pmgmt-sources, .pmgmt-row.active .pmgmt-meta { color: var(--color-text-info); opacity: .7; }
|
||||
.pmgmt-editor-body { flex: 1; overflow: hidden; padding: 14px 16px; display: flex; flex-direction: column; gap: 14px; }
|
||||
.pmgmt-editor-section-title { font-size: 10px; font-weight: 500; color: var(--color-text-secondary); text-transform: uppercase; letter-spacing: .06em; margin-bottom: 8px; }
|
||||
.pmgmt-account-list { display: flex; flex-direction: column; gap: 3px; max-height: 160px; overflow-y: auto; margin-top: 6px; }
|
||||
.pmgmt-account-list label { display: flex; align-items: center; gap: 6px; font-size: 12px; cursor: pointer; padding: 2px 0; }
|
||||
.pmgmt-opt-row { display: flex; align-items: center; justify-content: space-between; font-size: 12px; color: var(--color-text-primary); }
|
||||
.pmgmt-opt-row input[type=number] { width: 60px; font-size: 12px; }
|
||||
|
||||
|
||||
|
||||
|
||||
/* Settings modal */
|
||||
.settings-backdrop {
|
||||
display:none; position:fixed; inset:0;
|
||||
background:rgba(0,0,0,.55); z-index:1200;
|
||||
align-items:center; justify-content:center;
|
||||
}
|
||||
.settings-backdrop.open { display:flex; }
|
||||
.settings-modal {
|
||||
background:var(--surface); border:1px solid var(--border);
|
||||
border-radius:10px; width:min(540px,96vw);
|
||||
display:flex; flex-direction:column; overflow:hidden;
|
||||
font-size:12px; color:var(--text);
|
||||
}
|
||||
.settings-header { padding:16px 20px 0; display:flex; align-items:center; justify-content:space-between; }
|
||||
.settings-header h2 { font-size:14px; font-weight:700; margin:0; }
|
||||
.settings-tabs { display:flex; border-bottom:1px solid var(--border); padding:0 20px; margin-top:12px; }
|
||||
.settings-tab {
|
||||
height:36px; padding:0 14px; font-size:12px; cursor:pointer; border:none;
|
||||
background:none; color:var(--muted); border-bottom:2px solid transparent;
|
||||
margin-bottom:-1px; font-weight:500;
|
||||
}
|
||||
.settings-tab.active { color:var(--accent); border-bottom-color:var(--accent); font-weight:600; }
|
||||
.settings-body { padding:16px 20px; overflow-y:auto; max-height:65vh; display:flex; flex-direction:column; gap:14px; }
|
||||
.settings-pane { display:none; flex-direction:column; gap:12px; }
|
||||
.settings-pane.active { display:flex; }
|
||||
.settings-group { display:flex; flex-direction:column; gap:6px; }
|
||||
.settings-group-title { font-size:10px; font-weight:700; color:var(--muted); text-transform:uppercase; letter-spacing:.05em; }
|
||||
.settings-row { display:flex; align-items:center; gap:10px; }
|
||||
.settings-row label { flex:0 0 110px; font-size:11px; color:var(--muted); }
|
||||
.settings-row input, .settings-row select { flex:1; font-size:12px; height:26px; padding:0 8px; background:var(--bg); border:1px solid var(--border); border-radius:5px; color:var(--text); box-sizing:border-box; }
|
||||
.settings-footer { padding:10px 20px; border-top:1px solid var(--border); display:flex; justify-content:flex-end; gap:8px; }
|
||||
.settings-about-row { display:flex; justify-content:space-between; font-size:11px; padding:3px 0; border-bottom:1px solid var(--border); }
|
||||
.settings-about-row:last-child { border-bottom:none; }
|
||||
|
||||
/* Unified Source Management modal (#17) */
|
||||
.srcmgmt-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55); z-index: 1100;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.srcmgmt-backdrop.open { display: flex; }
|
||||
.srcmgmt-modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; width: min(620px, 96vw);
|
||||
display: flex; flex-direction: column;
|
||||
font-size: 12px; color: var(--text); overflow: hidden;
|
||||
}
|
||||
.srcmgmt-header { padding: 18px 22px 0; display: flex; align-items: center; justify-content: space-between; }
|
||||
.srcmgmt-header h2 { font-size: 14px; font-weight: 700; margin: 0; }
|
||||
.srcmgmt-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); padding: 0 22px; margin-top: 14px; }
|
||||
.srcmgmt-tab {
|
||||
height: 36px; padding: 0 16px; font-size: 12px; cursor: pointer; border: none;
|
||||
background: none; color: var(--muted); border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px; font-weight: 500; transition: color .15s;
|
||||
}
|
||||
.srcmgmt-tab:hover { color: var(--text); }
|
||||
.srcmgmt-tab.active { color: var(--accent); border-bottom-color: var(--accent); font-weight: 600; }
|
||||
.srcmgmt-tab.stub { opacity: .45; cursor: default; }
|
||||
.srcmgmt-body { padding: 18px 22px; overflow-y: auto; max-height: 65vh; display: flex; flex-direction: column; gap: 14px; }
|
||||
.srcmgmt-pane { display: none; flex-direction: column; gap: 14px; }
|
||||
.srcmgmt-pane.active { display: flex; }
|
||||
.srcmgmt-group { display: flex; flex-direction: column; gap: 6px; }
|
||||
.srcmgmt-group-title { font-size: 10px; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: .05em; }
|
||||
.srcmgmt-row { display: flex; align-items: center; gap: 10px; padding: 7px 10px; border-radius: 7px; background: var(--bg2); border: 1px solid var(--border); }
|
||||
.srcmgmt-row-icon { font-size: 16px; flex-shrink: 0; width: 22px; text-align: center; }
|
||||
.srcmgmt-row-label { flex: 1; font-size: 12px; font-weight: 500; }
|
||||
.srcmgmt-row-sub { font-size: 10px; color: var(--muted); }
|
||||
.srcmgmt-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
|
||||
.srcmgmt-status.green { background: #3fb950; }
|
||||
.srcmgmt-status.amber { background: #d29922; }
|
||||
.srcmgmt-status.grey { background: var(--border); }
|
||||
.srcmgmt-cred-form { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg); }
|
||||
.srcmgmt-cred-row { display: flex; align-items: center; gap: 8px; }
|
||||
.srcmgmt-cred-row label { flex: 0 0 110px; font-size: 11px; color: var(--muted); }
|
||||
.srcmgmt-cred-row input { flex: 1; font-size: 12px; height: 26px; padding: 0 8px; background: var(--surface); border: 1px solid var(--border); border-radius: 5px; color: var(--text); box-sizing: border-box; }
|
||||
.srcmgmt-footer { padding: 12px 22px; border-top: 1px solid var(--border); display: flex; justify-content: flex-end; gap: 8px; }
|
||||
|
||||
/* File Sources modal (#8) */
|
||||
.fsrc-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.fsrc-backdrop.open { display: flex; }
|
||||
.fsrc-modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 22px 26px;
|
||||
width: min(560px, 95vw);
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
font-size: 12px; color: var(--text);
|
||||
}
|
||||
.fsrc-modal h2 { font-size: 14px; font-weight: 700; margin: 0; }
|
||||
.fsrc-list { overflow-y: auto; flex-shrink: 0; display: flex; flex-direction: column; gap: 8px; height: calc(5 * 58px); min-height: 58px; border: 1px solid var(--border); border-radius: 7px; padding: 6px; background: var(--bg); }
|
||||
.fsrc-row { border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; background: var(--bg2); display: flex; flex-direction: column; gap: 4px; }
|
||||
.fsrc-row-head { display: flex; align-items: center; gap: 8px; }
|
||||
.fsrc-row-label { font-weight: 600; flex: 1; font-size: 12px; }
|
||||
.fsrc-row-path { font-size: 10px; color: var(--muted); font-family: var(--mono); }
|
||||
.fsrc-actions { display: flex; gap: 5px; flex-shrink: 0; }
|
||||
.fsrc-actions button { border: 1px solid var(--border); background: none; color: var(--muted); border-radius: 5px; padding: 2px 8px; font-size: 11px; cursor: pointer; }
|
||||
.fsrc-actions button:hover { color: var(--text); border-color: var(--text); }
|
||||
.fsrc-actions button.btn-scan { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.fsrc-actions button.btn-del { color: var(--danger); border-color: var(--danger); }
|
||||
.fsrc-form { display: flex; flex-direction: column; gap: 8px; padding: 10px; border: 1px dashed var(--border); border-radius: 8px; }
|
||||
.fsrc-form-row { display: flex; align-items: center; gap: 8px; }
|
||||
.fsrc-form-row label { flex: 0 0 120px; font-size: 11px; color: var(--muted); }
|
||||
.fsrc-form-row input { flex: 1; font-size: 12px; padding: 4px 8px; background: var(--bg); border: 1px solid var(--border); border-radius: 5px; color: var(--text); }
|
||||
.fsrc-smb-fields { display: none; }
|
||||
.fsrc-empty { color: var(--muted); font-size: 12px; text-align: center; padding: 20px 0; }
|
||||
.fsrc-footer { display: flex; justify-content: flex-end; gap: 8px; padding-top: 4px; border-top: 1px solid var(--border); }
|
||||
|
||||
/* Import DB modal (#11) */
|
||||
.import-db-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.55); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.import-db-backdrop.open { display: flex; }
|
||||
.import-db-modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 24px 28px;
|
||||
width: min(420px, 95vw);
|
||||
display: flex; flex-direction: column; gap: 14px;
|
||||
font-size: 12px; color: var(--text);
|
||||
}
|
||||
.import-db-modal h2 { font-size: 14px; font-weight: 700; margin: 0; }
|
||||
.import-db-modal p { margin: 0; color: var(--muted); line-height: 1.5; }
|
||||
.account-pill { font-size: 10px; color: var(--muted); white-space: nowrap; overflow: hidden;
|
||||
text-overflow: ellipsis; max-width: 140px; display: inline-block; vertical-align: middle; }
|
||||
.role-pill { font-size: 9px; padding: 1px 5px; border-radius: 10px; font-weight: 500; white-space: nowrap; }
|
||||
.role-pill.student { background: #0f3d6e; color: #7ec8ff; }
|
||||
.role-pill.staff { background: #1a3a1a; color: #7ed07e; }
|
||||
[data-theme="light"] .role-pill.student { background: #dbeeff; color: #0054a6; }
|
||||
[data-theme="light"] .role-pill.staff { background: #dff0df; color: #1a6e1a; }
|
||||
.special-cat-badge { font-size: 9px; padding: 1px 5px; border-radius: 10px;
|
||||
background: #4B0082; color: #E0B0FF; font-weight: 500; white-space: nowrap; }
|
||||
[data-theme="light"] .special-cat-badge { background: #EDE0FF; color: #5A007A; }
|
||||
.photo-face-badge { font-size: 9px; padding: 1px 5px; border-radius: 10px;
|
||||
background: #005060; color: #80E8FF; font-weight: 500; white-space: nowrap; }
|
||||
[data-theme="light"] .photo-face-badge { background: #D0F4FF; color: #00505F; }
|
||||
.overdue-badge { font-size: 9px; padding: 1px 5px; border-radius: 10px;
|
||||
background: #7c3200; color: #ffb347; font-weight: 600; white-space: nowrap; }
|
||||
[data-theme="light"] .overdue-badge { background: #fff3e0; color: #c55a00; }
|
||||
.badge-email { background: rgba(139,68,173,.2); color: #b87fd8; }
|
||||
.badge-onedrive { background: rgba(0,120,212,.2); color: #5ba4e8; }
|
||||
.badge-sharepoint { background: rgba(0,160,100,.2); color: #2ecc71; }
|
||||
.badge-teams { background: rgba(88,101,242,.2); color: #9ba4ff; }
|
||||
.badge-local { background: rgba(40,120,40,.2); color: #7ec87e; }
|
||||
.badge-smb { background: rgba(20,100,140,.2); color: #7ec8d0; }
|
||||
.badge-gmail { background: rgba(234,67,53,.18); color: #ea4335; }
|
||||
.badge-gdrive { background: rgba(15,117,210,.18); color: #0f75d2; }
|
||||
|
||||
/* Empty state */
|
||||
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
height: 100%; color: var(--muted); text-align: center; gap: 12px; }
|
||||
.empty-icon { font-size: 48px; opacity: .3; }
|
||||
.empty-text { font-size: 14px; line-height: 1.6; }
|
||||
|
||||
/* Log panel */
|
||||
.log-wrap { display: flex; flex-direction: column; flex-shrink: 0; border-top: 1px solid var(--border); }
|
||||
.sources-resize-handle { height: 5px; cursor: ns-resize; background: transparent; flex-shrink: 0; }
|
||||
.sources-resize-handle:hover { background: var(--border); }
|
||||
.log-resize-handle { height: 5px; cursor: ns-resize; background: transparent; flex-shrink: 0; }
|
||||
.log-resize-handle:hover { background: var(--border); }
|
||||
.log-header { display: flex; align-items: center; gap: 6px; padding: 3px 10px; background: var(--bg); border-bottom: 1px solid var(--border); flex-shrink: 0; }
|
||||
.log-header-title { font-size: 10px; font-weight: 600; color: var(--muted); letter-spacing: 0.04em; text-transform: uppercase; flex: 1; }
|
||||
.log-filter-btn { font-size: 10px; height: 18px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: none; color: var(--muted); cursor: pointer; }
|
||||
.log-filter-btn.active { background: var(--accent); color: #fff; border-color: var(--accent); }
|
||||
.log-copy-btn { font-size: 10px; height: 18px; padding: 0 6px; border: 1px solid var(--border); border-radius: 4px; background: none; color: var(--muted); cursor: pointer; }
|
||||
.log-copy-btn:hover { color: var(--text); }
|
||||
.log-panel { height: 154px; min-height: 60px; overflow-y: auto; background: var(--bg); padding: 6px 14px; font-family: var(--mono); font-size: 11px; line-height: 16px; color: var(--muted); flex: none; scrollbar-width: thin; scrollbar-color: var(--border) transparent; }
|
||||
.log-panel::-webkit-scrollbar { width: 4px; }
|
||||
.log-panel::-webkit-scrollbar-track { background: transparent; }
|
||||
.log-panel::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
|
||||
.log-line { margin-bottom: 2px; white-space: pre-wrap; word-break: break-all; }
|
||||
.log-err { color: var(--danger); }
|
||||
.log-ok { color: var(--success); }
|
||||
.log-warn { color: #e0922a; }
|
||||
.log-live { color: var(--muted); opacity: 0.7; font-style: italic; }
|
||||
.log-line.log-err-hidden { display: none; }
|
||||
.sidebar-footer { padding: 6px 12px; border-top: 1px solid var(--border); display: flex; align-items: center; justify-content: space-between; margin-top: auto; flex-shrink: 0; }
|
||||
.sidebar-footer select { background: var(--surface); border: 1px solid var(--border); border-radius: 4px; color: var(--muted); font-size: 10px; padding: 2px 4px; cursor: pointer; }
|
||||
/* Data subject lookup modal */
|
||||
.dsub-modal-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.45); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.dsub-modal-backdrop.open { display: flex; }
|
||||
.dsub-modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 22px 26px;
|
||||
width: 500px; max-width: 95vw; max-height: 80vh;
|
||||
display: flex; flex-direction: column; gap: 12px;
|
||||
font-family: var(--sans); color: var(--text);
|
||||
}
|
||||
.dsub-modal h2 { font-size: 14px; font-weight: 600; margin: 0; }
|
||||
.dsub-input-row { display: flex; gap: 8px; }
|
||||
.dsub-input-row input { flex: 1; font-size: 13px; letter-spacing: .05em; }
|
||||
.dsub-results { flex: 1; overflow-y: auto; min-height: 0; }
|
||||
.dsub-result-row { display: flex; align-items: center; gap: 8px; padding: 7px 0;
|
||||
border-bottom: 1px solid var(--border); font-size: 12px; }
|
||||
.dsub-result-row:last-child { border-bottom: none; }
|
||||
.dsub-result-name { flex: 1; font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.dsub-result-meta { color: var(--muted); font-size: 11px; white-space: nowrap; }
|
||||
.dsub-footer { display: flex; gap: 8px; padding-top: 4px; }
|
||||
.dsub-footer button { flex: 1; padding: 7px; border-radius: 7px; font-size: 12px; cursor: pointer; font-family: var(--sans); }
|
||||
/* Disposition widget */
|
||||
.disposition-row { display: flex; align-items: center; gap: 8px; padding: 8px 14px;
|
||||
border-top: 1px solid var(--border); flex-shrink: 0; }
|
||||
.disposition-label { font-size: 11px; color: var(--muted); white-space: nowrap; }
|
||||
.disposition-select { flex: 1; font-size: 11px; padding: 4px 6px; }
|
||||
.disposition-save { padding: 4px 10px; border-radius: 6px; font-size: 11px;
|
||||
background: var(--accent); color: #fff; border: none; cursor: pointer; white-space: nowrap; }
|
||||
.disposition-saved { font-size: 10px; color: var(--success); }
|
||||
|
||||
/* Trend sparkline */
|
||||
.spark-wrap { position: relative; height: 60px; margin: 6px 0 2px; }
|
||||
.spark-wrap canvas { width: 100%; height: 60px; }
|
||||
.spark-tip { display:none; position:absolute; background:var(--surface);
|
||||
border:1px solid var(--border); border-radius:5px; padding:3px 7px;
|
||||
font-size:10px; color:var(--text); pointer-events:none; white-space:nowrap;
|
||||
top:0; left:0; z-index:10; }
|
||||
.spark-labels { display:flex; justify-content:space-between;
|
||||
font-size:9px; color:var(--muted); margin-bottom:4px; }
|
||||
.spark-legend { display:flex; gap:10px; font-size:9px;
|
||||
color:var(--muted); margin-top:3px; }
|
||||
.spark-legend span { display:flex; align-items:center; gap:3px; }
|
||||
.spark-dot { width:8px; height:2px; border-radius:1px; }
|
||||
|
||||
.about-modal-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.45); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.about-modal-backdrop.open { display: flex; }
|
||||
.about-modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 8px; padding: 32px 36px;
|
||||
max-width: 380px; width: 90%;
|
||||
font-family: var(--mono); font-size: 12px; color: var(--text);
|
||||
}
|
||||
.about-modal h2 { font-size: 16px; font-weight: 700; margin: 0 0 4px; color: var(--text); font-family: var(--mono); }
|
||||
.about-modal .about-version { color: var(--accent); font-size: 11px; margin-bottom: 20px; }
|
||||
.about-modal .about-row { display: flex; justify-content: space-between; padding: 5px 0; border-bottom: 1px solid var(--border); color: var(--muted); }
|
||||
.about-modal .about-row span:last-child { color: var(--text); }
|
||||
.about-close { margin-top: 20px; width: 100%; padding: 8px; background: var(--accent); color: #fff; border: none; border-radius: 6px; font-size: 13px; cursor: pointer; font-family: var(--mono); }
|
||||
|
||||
/* SMTP modal */
|
||||
.smtp-modal-backdrop {
|
||||
display: none; position: fixed; inset: 0;
|
||||
background: rgba(0,0,0,0.45); z-index: 1000;
|
||||
align-items: center; justify-content: center;
|
||||
}
|
||||
.smtp-modal-backdrop.open { display: flex; }
|
||||
.smtp-modal {
|
||||
background: var(--surface); border: 1px solid var(--border);
|
||||
border-radius: 10px; padding: 24px 28px;
|
||||
width: 460px; max-width: 95vw; max-height: 90vh; overflow-y: auto;
|
||||
font-family: var(--sans); font-size: 12px; color: var(--text);
|
||||
}
|
||||
.smtp-modal h2 { font-size: 15px; font-weight: 600; margin: 0 0 4px; }
|
||||
.smtp-modal .smtp-subtitle { color: var(--muted); font-size: 11px; margin-bottom: 18px; }
|
||||
.smtp-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 16px; }
|
||||
.smtp-grid .full { grid-column: 1 / -1; }
|
||||
.smtp-field label { display: block; font-size: 11px; color: var(--muted); margin-bottom: 3px; }
|
||||
.smtp-field input { width: 100%; padding: 6px 8px; font-size: 12px; }
|
||||
.smtp-toggle-row { display: flex; align-items: center; gap: 8px; margin-top: 4px; font-size: 11px; color: var(--muted); }
|
||||
.smtp-divider { grid-column: 1 / -1; border: none; border-top: 1px solid var(--border); margin: 4px 0; }
|
||||
.smtp-footer { display: flex; gap: 8px; margin-top: 18px; }
|
||||
.smtp-footer button { flex: 1; padding: 8px; border-radius: 7px; font-size: 12px; cursor: pointer; font-family: var(--sans); }
|
||||
.smtp-status { font-size: 11px; margin-top: 8px; min-height: 16px; text-align: center; }
|
||||
|
||||
/* ── Viewer mode — hide scan controls ──────────────────────────────────── */
|
||||
body.viewer-mode #scanBtn,
|
||||
body.viewer-mode #stopBtn,
|
||||
body.viewer-mode #profileBar,
|
||||
body.viewer-mode .topbar-sep,
|
||||
body.viewer-mode .config-group { display: none !important; }
|
||||
body.viewer-mode #resumeBanner { display: none !important; }
|
||||
body.viewer-mode #bulkDeleteBtn { display: none !important; }
|
||||
body.viewer-mode .card-delete-btn { display: none !important; }
|
||||
body.viewer-mode #dsubDeleteBtn { display: none !important; }
|
||||
body.viewer-mode #shareBtn { display: none !important; }
|
||||
body.viewer-mode .sidebar { display: none !important; }
|
||||
body.viewer-mode #viewerBrand { display: inline !important; }
|
||||
body.viewer-mode #logWrap { display: none !important; }
|
||||
body.viewer-mode #progressBar { display: none !important; }
|
||||
Reference in new issue
Block a user